From a6927f0edfb4282dc8e041e0126ab5d336c41102 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:44:53 +0100 Subject: [PATCH] refactor: consolidate duration format --- lib/client/plex_client.dart | 66 +++++----- lib/i18n/strings.g.dart | 52 +++++++- lib/i18n/strings.i18n.json | 6 +- lib/i18n/strings_de.i18n.json | 6 +- lib/i18n/strings_it.i18n.json | 6 +- lib/i18n/strings_nl.i18n.json | 6 +- lib/i18n/strings_sv.i18n.json | 6 +- lib/i18n/strings_zh.i18n.json | 6 +- lib/models/plex_playlist.dart | 11 -- lib/providers/playback_state_provider.dart | 21 ++-- .../base_media_list_detail_screen.dart | 9 +- lib/screens/collection_detail_screen.dart | 58 ++++----- lib/screens/libraries_screen.dart | 30 ++--- .../library_tabs/library_browse_tab.dart | 12 +- .../library_tabs/library_collections_tab.dart | 14 +-- .../library_tabs/library_playlists_tab.dart | 1 - .../library_tabs/library_recommended_tab.dart | 10 +- lib/screens/media_detail_screen.dart | 15 +-- lib/screens/playlist_detail_screen.dart | 5 +- lib/screens/season_detail_screen.dart | 18 +-- lib/screens/video_player_screen.dart | 4 +- lib/utils/duration_formatter.dart | 83 ++++++++++++ lib/widgets/media_card.dart | 118 +++++++++--------- lib/widgets/playlist_item_card.dart | 15 +-- .../video_controls/sheets/chapter_sheet.dart | 15 +-- .../sheets/sleep_timer_sheet.dart | 3 +- .../video_controls/video_controls.dart | 20 +-- .../widgets/sleep_timer_active_status.dart | 41 ++---- .../widgets/sleep_timer_duration_list.dart | 27 ++-- pubspec.lock | 8 ++ pubspec.yaml | 1 + 31 files changed, 378 insertions(+), 315 deletions(-) create mode 100644 lib/utils/duration_formatter.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 3bc42b2e..1aa617c8 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -1498,9 +1498,7 @@ class PlexClient { try { final response = await _dio.get( '/library/sections/$sectionId/collections', - queryParameters: { - 'includeGuids': 1, - }, + queryParameters: {'includeGuids': 1}, ); final allItems = _extractMetadataList(response); @@ -1518,7 +1516,9 @@ class PlexClient { /// Returns the list of metadata items in the collection Future> getCollectionItems(String collectionId) async { try { - final response = await _dio.get('/library/collections/$collectionId/children'); + final response = await _dio.get( + '/library/collections/$collectionId/children', + ); return _extractMetadataList(response); } catch (e) { appLogger.e('Failed to get collection items: $e'); @@ -1530,7 +1530,9 @@ class PlexClient { /// Deletes a library collection from the server Future deleteCollection(String sectionId, String collectionId) async { try { - appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); + appLogger.d( + 'Deleting collection: sectionId=$sectionId, collectionId=$collectionId', + ); final response = await _dio.delete('/library/collections/$collectionId'); appLogger.d('Delete collection response: ${response.statusCode}'); return true; @@ -1550,7 +1552,9 @@ class PlexClient { int? type, }) async { try { - appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); + appLogger.d( + 'Creating collection: sectionId=$sectionId, title=$title, type=$type', + ); final response = await _dio.post( '/library/collections', queryParameters: { @@ -1591,9 +1595,7 @@ class PlexClient { appLogger.d('Adding items to collection: collectionId=$collectionId'); final response = await _dio.put( '/library/collections/$collectionId/items', - queryParameters: { - 'uri': uri, - }, + queryParameters: {'uri': uri}, ); appLogger.d('Add to collection response: ${response.statusCode}'); return true; @@ -1610,7 +1612,9 @@ class PlexClient { required String itemId, }) async { try { - appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); + appLogger.d( + 'Removing item from collection: collectionId=$collectionId, itemId=$itemId', + ); final response = await _dio.delete( '/library/collections/$collectionId/items/$itemId', ); @@ -1739,15 +1743,17 @@ class PlexClient { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); try { - items.add(PlexMetadata( - ratingKey: json['key'] ?? json['ratingKey'] ?? '', - key: json['key'] ?? '', - type: json['type'] ?? 'folder', - title: json['title'] ?? 'Untitled', - thumb: json['thumb'], - art: json['art'], - year: json['year'], - )); + items.add( + PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + year: json['year'], + ), + ); } catch (e2) { appLogger.e('Failed to parse metadata item: $e2'); } @@ -1764,14 +1770,16 @@ class PlexClient { } catch (e) { // If that fails, use minimal folder representation try { - items.add(PlexMetadata( - ratingKey: json['key'] ?? json['ratingKey'] ?? '', - key: json['key'] ?? '', - type: json['type'] ?? 'folder', - title: json['title'] ?? 'Untitled', - thumb: json['thumb'], - art: json['art'], - )); + items.add( + PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + ), + ); } catch (e2) { appLogger.e('Failed to parse directory item: $e2'); } @@ -1789,9 +1797,7 @@ class PlexClient { try { final response = await _dio.get( '/library/sections/$sectionId/folder', - queryParameters: { - 'includeCollections': 0, - }, + queryParameters: {'includeCollections': 0}, ); return _extractMetadataAndDirectories(response); } catch (e) { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 67236e1d..4d9f68fc 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2424 (404 per locale) +/// Strings: 2448 (408 per locale) /// -/// Built on 2025-11-16 at 19:45 UTC +/// Built on 2025-11-16 at 22:35 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -477,6 +477,10 @@ class _StringsVideoControlsEn { String get stretch => 'Stretch'; String get lockRotation => 'Lock rotation'; String get unlockRotation => 'Unlock rotation'; + String get sleepTimer => 'Sleep Timer'; + String get timerActive => 'Timer Active'; + String playbackWillPauseIn({required Object duration}) => 'Playback will pause in ${duration}'; + String get sleepTimerCompleted => 'Sleep timer completed - playback paused'; } // Path: userStatus @@ -1217,6 +1221,10 @@ class _StringsVideoControlsDe implements _StringsVideoControlsEn { @override String get stretch => 'Strecken'; @override String get lockRotation => 'Rotation sperren'; @override String get unlockRotation => 'Rotation entsperren'; + @override String get sleepTimer => 'Schlaf-Timer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + @override String get sleepTimerCompleted => 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; } // Path: userStatus @@ -1957,6 +1965,10 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String get stretch => 'Allunga'; @override String get lockRotation => 'Blocca rotazione'; @override String get unlockRotation => 'Sblocca rotazione'; + @override String get sleepTimer => 'Timer di spegnimento'; + @override String get timerActive => 'Timer attivo'; + @override String playbackWillPauseIn({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + @override String get sleepTimerCompleted => 'Timer di spegnimento completato - riproduzione in pausa'; } // Path: userStatus @@ -2697,6 +2709,10 @@ class _StringsVideoControlsNl implements _StringsVideoControlsEn { @override String get stretch => 'Uitrekken'; @override String get lockRotation => 'Vergrendel rotatie'; @override String get unlockRotation => 'Ontgrendel rotatie'; + @override String get sleepTimer => 'Slaaptimer'; + @override String get timerActive => 'Timer actief'; + @override String playbackWillPauseIn({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + @override String get sleepTimerCompleted => 'Slaaptimer voltooid - afspelen gepauzeerd'; } // Path: userStatus @@ -3437,6 +3453,10 @@ class _StringsVideoControlsSv implements _StringsVideoControlsEn { @override String get stretch => 'Sträck'; @override String get lockRotation => 'Lås rotation'; @override String get unlockRotation => 'Lås upp rotation'; + @override String get sleepTimer => 'Sovtimer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + @override String get sleepTimerCompleted => 'Sovtimer slutförd - uppspelning pausad'; } // Path: userStatus @@ -4177,6 +4197,10 @@ class _StringsVideoControlsZh implements _StringsVideoControlsEn { @override String get stretch => '拉伸'; @override String get lockRotation => '锁定旋转'; @override String get unlockRotation => '解锁旋转'; + @override String get sleepTimer => '睡眠定时器'; + @override String get timerActive => '定时器已激活'; + @override String playbackWillPauseIn({required Object duration}) => '播放将在 ${duration} 后暂停'; + @override String get sleepTimerCompleted => '睡眠定时器已完成 - 播放已暂停'; } // Path: userStatus @@ -4753,6 +4777,10 @@ extension on Translations { case 'videoControls.stretch': return 'Stretch'; case 'videoControls.lockRotation': return 'Lock rotation'; case 'videoControls.unlockRotation': return 'Unlock rotation'; + case 'videoControls.sleepTimer': return 'Sleep Timer'; + case 'videoControls.timerActive': return 'Timer Active'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Playback will pause in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sleep timer completed - playback paused'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Restricted'; case 'userStatus.protected': return 'Protected'; @@ -5165,6 +5193,10 @@ extension on _StringsDe { case 'videoControls.stretch': return 'Strecken'; case 'videoControls.lockRotation': return 'Rotation sperren'; case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'videoControls.sleepTimer': return 'Schlaf-Timer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; case 'userStatus.admin': return 'Eigentümer'; case 'userStatus.restricted': return 'Eingeschränkt'; case 'userStatus.protected': return 'Geschützt'; @@ -5577,6 +5609,10 @@ extension on _StringsIt { case 'videoControls.stretch': return 'Allunga'; case 'videoControls.lockRotation': return 'Blocca rotazione'; case 'videoControls.unlockRotation': return 'Sblocca rotazione'; + case 'videoControls.sleepTimer': return 'Timer di spegnimento'; + case 'videoControls.timerActive': return 'Timer attivo'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Timer di spegnimento completato - riproduzione in pausa'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; @@ -5989,6 +6025,10 @@ extension on _StringsNl { case 'videoControls.stretch': return 'Uitrekken'; case 'videoControls.lockRotation': return 'Vergrendel rotatie'; case 'videoControls.unlockRotation': return 'Ontgrendel rotatie'; + case 'videoControls.sleepTimer': return 'Slaaptimer'; + case 'videoControls.timerActive': return 'Timer actief'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Slaaptimer voltooid - afspelen gepauzeerd'; case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; @@ -6401,6 +6441,10 @@ extension on _StringsSv { case 'videoControls.stretch': return 'Sträck'; case 'videoControls.lockRotation': return 'Lås rotation'; case 'videoControls.unlockRotation': return 'Lås upp rotation'; + case 'videoControls.sleepTimer': return 'Sovtimer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sovtimer slutförd - uppspelning pausad'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; @@ -6813,6 +6857,10 @@ extension on _StringsZh { case 'videoControls.stretch': return '拉伸'; case 'videoControls.lockRotation': return '锁定旋转'; case 'videoControls.unlockRotation': return '解锁旋转'; + case 'videoControls.sleepTimer': return '睡眠定时器'; + case 'videoControls.timerActive': return '定时器已激活'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => '播放将在 ${duration} 后暂停'; + case 'videoControls.sleepTimerCompleted': return '睡眠定时器已完成 - 播放已暂停'; case 'userStatus.admin': return '管理员'; case 'userStatus.restricted': return '受限'; case 'userStatus.protected': return '受保护'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index f2539445..e749ab23 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Fill screen", "stretch": "Stretch", "lockRotation": "Lock rotation", - "unlockRotation": "Unlock rotation" + "unlockRotation": "Unlock rotation", + "sleepTimer": "Sleep Timer", + "timerActive": "Timer Active", + "playbackWillPauseIn": "Playback will pause in ${duration}", + "sleepTimerCompleted": "Sleep timer completed - playback paused" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 2cc35a73..6a3078d8 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Bild füllen", "stretch": "Strecken", "lockRotation": "Rotation sperren", - "unlockRotation": "Rotation entsperren" + "unlockRotation": "Rotation entsperren", + "sleepTimer": "Schlaf-Timer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Wiedergabe wird pausiert in ${duration}", + "sleepTimerCompleted": "Schlaf-Timer abgelaufen - Wiedergabe pausiert" }, "userStatus": { "admin": "Eigentümer", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 43033fe8..76ca3fef 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Riempi schermo", "stretch": "Allunga", "lockRotation": "Blocca rotazione", - "unlockRotation": "Sblocca rotazione" + "unlockRotation": "Sblocca rotazione", + "sleepTimer": "Timer di spegnimento", + "timerActive": "Timer attivo", + "playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}", + "sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 0b7fb138..63551766 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Vul scherm", "stretch": "Uitrekken", "lockRotation": "Vergrendel rotatie", - "unlockRotation": "Ontgrendel rotatie" + "unlockRotation": "Ontgrendel rotatie", + "sleepTimer": "Slaaptimer", + "timerActive": "Timer actief", + "playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}", + "sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd" }, "userStatus": { "admin": "Beheerder", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 2340e6ea..00d7d5e8 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Fyll skärm", "stretch": "Sträck", "lockRotation": "Lås rotation", - "unlockRotation": "Lås upp rotation" + "unlockRotation": "Lås upp rotation", + "sleepTimer": "Sovtimer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Uppspelningen pausas om ${duration}", + "sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index f19a0a78..4db098e4 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "填充屏幕", "stretch": "拉伸", "lockRotation": "锁定旋转", - "unlockRotation": "解锁旋转" + "unlockRotation": "解锁旋转", + "sleepTimer": "睡眠定时器", + "timerActive": "定时器已激活", + "playbackWillPauseIn": "播放将在 ${duration} 后暂停", + "sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停" }, "userStatus": { "admin": "管理员", diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart index d47944f7..7799c3ff 100644 --- a/lib/models/plex_playlist.dart +++ b/lib/models/plex_playlist.dart @@ -45,17 +45,6 @@ class PlexPlaylist { /// Helper to get display image (composite or thumb) String? get displayImage => composite ?? thumb; - /// Helper to get formatted duration - String? get formattedDuration { - if (duration == null) return null; - final hours = duration! ~/ 3600000; - final minutes = (duration! % 3600000) ~/ 60000; - if (hours > 0) { - return '${hours}h ${minutes}m'; - } - return '${minutes}m'; - } - /// Helper to determine if playlist is editable bool get isEditable => !smart; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 3b4976df..fb6acca9 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -68,7 +68,8 @@ class PlaybackStateProvider with ChangeNotifier { /// Update the current play queue item when playing a new item void setCurrentItem(PlexMetadata metadata) { - if (_playbackMode == PlaybackMode.playQueue && metadata.playQueueItemID != null) { + if (_playbackMode == PlaybackMode.playQueue && + metadata.playQueueItemID != null) { _currentPlayQueueItemID = metadata.playQueueItemID; notifyListeners(); } @@ -82,9 +83,10 @@ class PlaybackStateProvider with ChangeNotifier { ) async { _playQueueId = playQueue.playQueueID; // Use size or items length as fallback if totalCount is null - _playQueueTotalCount = playQueue.playQueueTotalCount ?? - playQueue.size ?? - (playQueue.items?.length ?? 0); + _playQueueTotalCount = + playQueue.playQueueTotalCount ?? + playQueue.size ?? + (playQueue.items?.length ?? 0); _playQueueShuffled = playQueue.playQueueShuffled; _currentPlayQueueItemID = playQueue.playQueueSelectedItemID; _loadedItems = playQueue.items ?? []; @@ -140,9 +142,10 @@ class PlaybackStateProvider with ChangeNotifier { if (response != null && response.items != null) { _loadedItems = response.items!; // Use size or items length as fallback if totalCount is null - _playQueueTotalCount = response.playQueueTotalCount ?? - response.size ?? - response.items!.length; + _playQueueTotalCount = + response.playQueueTotalCount ?? + response.size ?? + response.items!.length; _playQueueShuffled = response.playQueueShuffled; notifyListeners(); return true; @@ -198,7 +201,9 @@ class PlaybackStateProvider with ChangeNotifier { // Loop back to beginning - load first item if (_client != null && _playQueueId != null) { final response = await _client!.getPlayQueue(_playQueueId!); - if (response != null && response.items != null && response.items!.isNotEmpty) { + if (response != null && + response.items != null && + response.items!.isNotEmpty) { _loadedItems = response.items!; final firstItem = _loadedItems.first; // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index c0a4d63f..57d693c7 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -9,7 +9,8 @@ import '../mixins/item_updatable.dart'; /// Abstract base class for screens displaying media lists (collections/playlists) /// Provides common state management and playback functionality abstract class BaseMediaListDetailScreen - extends State with Refreshable, ItemUpdatable { + extends State + with Refreshable, ItemUpdatable { // State properties - concrete implementations to avoid duplication List _items = []; bool _isLoading = false; @@ -55,9 +56,9 @@ abstract class BaseMediaListDetailScreen Future _playWithShuffle(bool shuffle) async { if (items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(emptyMessage)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(emptyMessage))); } return; } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 376fd69d..5cd38b3d 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -43,7 +43,9 @@ class _CollectionDetailScreenState try { final client = this.client; - final newItems = await client.getCollectionItems(widget.collection.ratingKey); + final newItems = await client.getCollectionItems( + widget.collection.ratingKey, + ); if (mounted) { setState(() { @@ -108,14 +110,17 @@ class _CollectionDetailScreenState if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.deleted)), - ); - Navigator.pop(context, true); // Return true to indicate refresh needed + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.deleted))); + Navigator.pop( + context, + true, + ); // Return true to indicate refresh needed } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.deleteFailed)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed))); } } } catch (e) { @@ -132,7 +137,6 @@ class _CollectionDetailScreenState } } - @override Widget build(BuildContext context) { return Scaffold( @@ -193,9 +197,7 @@ class _CollectionDetailScreenState ) else if (items.isEmpty) SliverFillRemaining( - child: Center( - child: Text(t.collections.noItems), - ), + child: Center(child: Text(t.collections.noItems)), ) else SliverPadding( @@ -204,27 +206,25 @@ class _CollectionDetailScreenState builder: (context, settingsProvider, child) { return SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), + maxCrossAxisExtent: + GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, mainAxisSpacing: 0, ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - collectionId: widget.collection.ratingKey, - onListRefresh: loadItems, - ); - }, - childCount: items.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: loadItems, + ); + }, childCount: items.length), ); }, ), diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 19c32668..513d3c17 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -167,8 +167,9 @@ class _LibrariesScreenState extends State } if (libraryKeyToLoad != null && mounted) { - final savedFilters = - storage.getLibraryFilters(sectionId: libraryKeyToLoad); + final savedFilters = storage.getLibraryFilters( + sectionId: libraryKeyToLoad, + ); if (savedFilters.isNotEmpty) { _selectedFilters = Map.from(savedFilters); } @@ -280,10 +281,7 @@ class _LibrariesScreenState extends State // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters( - {}, - sectionId: libraryKey, - ); + await storage.saveLibraryFilters({}, sectionId: libraryKey); } // Cancel any existing requests @@ -863,25 +861,13 @@ class _LibrariesScreenState extends State scrollDirection: Axis.horizontal, child: Row( children: [ - _buildTabChip( - t.libraries.tabs.recommended, - 0, - ), + _buildTabChip(t.libraries.tabs.recommended, 0), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.browse, - 1, - ), + _buildTabChip(t.libraries.tabs.browse, 1), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.collections, - 2, - ), + _buildTabChip(t.libraries.tabs.collections, 2), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.playlists, - 3, - ), + _buildTabChip(t.libraries.tabs.playlists, 3), ], ), ), diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index 2fd87712..29dca645 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -302,10 +302,7 @@ class _LibraryBrowseTabState extends State if (error is DioException) { return mapDioErrorToMessage(error, context: t.libraries.content); } - return mapUnexpectedErrorToMessage( - error, - context: t.libraries.content, - ); + return mapUnexpectedErrorToMessage(error, context: t.libraries.content); } void _showGroupingBottomSheet() { @@ -451,7 +448,7 @@ class _LibraryBrowseTabState extends State ), const SizedBox(width: 8), // Filters chip - if (_filters.isNotEmpty) + if (_filters.isNotEmpty && _selectedGrouping != 'folders') _buildFilterChip( icon: Icons.filter_alt, label: _selectedFilters.isEmpty @@ -461,9 +458,10 @@ class _LibraryBrowseTabState extends State ), onPressed: _showFiltersBottomSheet, ), - if (_filters.isNotEmpty) const SizedBox(width: 8), + if (_filters.isNotEmpty && _selectedGrouping != 'folders') + const SizedBox(width: 8), // Sort chip - if (_sortOptions.isNotEmpty) + if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders') _buildFilterChip( icon: Icons.sort, label: _selectedSort?.title ?? t.libraries.sort, diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index f5e0f6a4..681b8231 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -50,7 +50,9 @@ class _LibraryCollectionsTabState extends State _loadCollections(); // Listen for refresh notifications - _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((_) { + _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen(( + _, + ) { if (mounted) { _loadCollections(); } @@ -84,7 +86,9 @@ class _LibraryCollectionsTabState extends State throw Exception(t.errors.noClientAvailable); } - final collections = await client.getLibraryCollections(widget.library.key); + final collections = await client.getLibraryCollections( + widget.library.key, + ); if (!mounted) return; @@ -106,7 +110,6 @@ class _LibraryCollectionsTabState extends State } } - @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin @@ -120,10 +123,7 @@ class _LibraryCollectionsTabState extends State onRetry: _loadCollections, builder: (items) => RefreshIndicator( onRefresh: _loadCollections, - child: AdaptiveMediaGrid( - items: items, - onRefresh: _loadCollections, - ), + child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections), ), ); } diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index 8ef5156b..84f6b1be 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -113,7 +113,6 @@ class _LibraryPlaylistsTabState extends State } } - @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 84ae7c77..89957997 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -14,10 +14,7 @@ import '../../widgets/content_state_builder.dart'; class LibraryRecommendedTab extends StatefulWidget { final PlexLibrary library; - const LibraryRecommendedTab({ - super.key, - required this.library, - }); + const LibraryRecommendedTab({super.key, required this.library}); @override State createState() => _LibraryRecommendedTabState(); @@ -124,10 +121,7 @@ class _LibraryRecommendedTabState extends State itemCount: items.length, itemBuilder: (context, index) { final hub = items[index]; - return HubSection( - hub: hub, - icon: _getHubIcon(hub), - ); + return HubSection(hub: hub, icon: _getHubIcon(hub)); }, ), ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 7133550b..f81db457 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/plex_client_provider.dart'; import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_formatter.dart'; import '../utils/provider_extensions.dart'; import '../utils/shuffle_play_helper.dart'; import '../utils/video_player_navigation.dart'; @@ -489,7 +490,7 @@ class _MediaDetailScreenState extends State { borderRadius: BorderRadius.circular(6), ), child: Text( - _formatDuration(metadata.duration!), + formatDurationTextual(metadata.duration!), style: const TextStyle( color: Colors.white, fontSize: 13, @@ -1122,18 +1123,6 @@ class _MediaDetailScreenState extends State { ); } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - String _getPlayButtonLabel(PlexMetadata metadata) { // For TV shows if (metadata.type.toLowerCase() == 'show') { diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index 5af3ed2f..b5001d8e 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -217,7 +217,6 @@ class _PlaylistDetailScreenState } } - Future _playFromItem(int index) async { if (items.isEmpty || index < 0 || index >= items.length) return; @@ -235,7 +234,9 @@ class _PlaylistDetailScreenState key: selectedItem.key, ); - if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { + if (playQueue == null || + playQueue.items == null || + playQueue.items!.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.messages.failedToCreatePlayQueue)), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index dd2ec503..9ffd3442 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/duration_formatter.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; @@ -330,7 +331,9 @@ class _SeasonDetailScreenState extends State children: [ if (episode.duration != null) Text( - _formatDuration(episode.duration!), + formatDurationTimestamp( + Duration(milliseconds: episode.duration!), + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, @@ -369,17 +372,4 @@ class _SeasonDetailScreenState extends State ), ); } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6d04870f..889508f1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -320,7 +320,9 @@ class VideoPlayerScreenState extends State widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = await playbackState.getPreviousEpisode(widget.metadata.ratingKey); + previous = await playbackState.getPreviousEpisode( + widget.metadata.ratingKey, + ); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { diff --git a/lib/utils/duration_formatter.dart b/lib/utils/duration_formatter.dart new file mode 100644 index 00000000..afe5a8d8 --- /dev/null +++ b/lib/utils/duration_formatter.dart @@ -0,0 +1,83 @@ +import 'package:duration/duration.dart'; +import 'package:duration/locale.dart'; +import '../i18n/strings.g.dart'; + +/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes"). +/// Uses localized unit names based on the current app locale. +/// Shows hours and minutes only (no seconds). +/// +/// Used for: media cards, media details, playlists. +String formatDurationTextual(int milliseconds, {bool abbreviated = true}) { + final duration = Duration(milliseconds: milliseconds); + + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated or full units (h, m) but no seconds + return prettyDuration( + duration, + abbreviated: abbreviated, + locale: durationLocale, + delimiter: abbreviated ? ' ' : ', ', + spacer: '', + // Configure to show only hours and minutes + tersity: DurationTersity.minute, + ); +} + +/// Formats a duration in human-readable textual format with seconds (e.g., "1h 23m 45s"). +/// Uses localized unit names based on the current app locale. +/// Shows hours, minutes, and seconds. +/// +/// Used for: sleep timer countdown. +String formatDurationWithSeconds(Duration duration) { + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated units (h, m, s) including seconds + return prettyDuration( + duration, + abbreviated: true, + locale: durationLocale, + delimiter: ' ', + spacer: '', + // Show all non-zero units + tersity: DurationTersity.second, + ); +} + +/// Formats a duration in timestamp format (e.g., "1:23:45" or "23:45"). +/// This format is not localized as it follows universal digital clock conventions. +/// Shows H:MM:SS or M:SS depending on duration. +/// +/// Used for: video controls, chapters, episode durations. +String formatDurationTimestamp(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } +} + +/// Gets the duration package locale based on the current app locale. +/// Falls back to English if the locale is not supported by the duration package. +DurationLocale _getDurationLocale() { + // Get the current locale from slang's LocaleSettings + final appLocale = LocaleSettings.currentLocale; + final languageCode = appLocale.languageCode; + + // Map supported locales to duration package locales + // The duration package supports many languages, but we'll focus on the ones + // that our app supports: en, de, it, nl, sv, zh + try { + return DurationLocale.fromLanguageCode(languageCode) ?? + const EnglishDurationLocale(); + } catch (e) { + // Fallback to English if language code is not supported + return const EnglishDurationLocale(); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4572273a..e9659b47 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -9,6 +9,7 @@ import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_formatter.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../screens/playlist_detail_screen.dart'; @@ -23,10 +24,12 @@ class MediaCard extends StatefulWidget { final double? height; final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; - final VoidCallback? onListRefresh; // Callback to refresh the entire parent list + final VoidCallback? + onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; final bool isInContinueWatching; - final String? collectionId; // The collection ID if displaying within a collection + final String? + collectionId; // The collection ID if displaying within a collection const MediaCard({ super.key, @@ -55,9 +58,8 @@ class _MediaCardState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => PlaylistDetailScreen( - playlist: widget.item as PlexPlaylist, - ), + builder: (context) => + PlaylistDetailScreen(playlist: widget.item as PlexPlaylist), ), ); return; @@ -70,9 +72,7 @@ class _MediaCardState extends State { final result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => CollectionDetailScreen( - collection: widget.item, - ), + builder: (context) => CollectionDetailScreen(collection: widget.item), ), ); @@ -226,16 +226,18 @@ class _MediaCardGrid extends StatelessWidget { Builder( builder: (context) { final playlist = item as PlexPlaylist; - if (playlist.leafCount != null && playlist.leafCount! > 0) { + if (playlist.leafCount != null && + playlist.leafCount! > 0) { return Text( t.playlists.itemCount(count: playlist.leafCount!), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } return const SizedBox.shrink(); @@ -248,17 +250,19 @@ class _MediaCardGrid extends StatelessWidget { // For collections, show item count if (metadata.type.toLowerCase() == 'collection') { - final count = metadata.childCount ?? metadata.leafCount; + final count = + metadata.childCount ?? metadata.leafCount; if (count != null && count > 0) { return Text( t.playlists.itemCount(count: count), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } } @@ -269,31 +273,34 @@ class _MediaCardGrid extends StatelessWidget { metadata.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } else if (metadata.parentTitle != null) { return Text( metadata.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } else if (metadata.year != null) { return Text( '${metadata.year}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } @@ -332,7 +339,9 @@ class _MediaCardGrid extends StatelessWidget { fallbackIcon = Icons.playlist_play; } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); } if (posterUrl != null) { @@ -454,18 +463,6 @@ class _MediaCardList extends StatelessWidget { } } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - String _buildMetadataLine() { final parts = []; @@ -477,8 +474,8 @@ class _MediaCardList extends StatelessWidget { } // Add duration - if (playlist.formattedDuration != null) { - parts.add(playlist.formattedDuration!); + if (playlist.duration != null) { + parts.add(formatDurationTextual(playlist.duration!)); } // Add smart playlist badge @@ -489,15 +486,16 @@ class _MediaCardList extends StatelessWidget { final metadata = item as PlexMetadata; // For collections, show item count - if (metadata.type.toLowerCase() == 'collection') { - final count = metadata.childCount ?? metadata.leafCount; - if (count != null && count > 0) { - parts.add(t.playlists.itemCount(count: count)); - } - } else { + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + parts.add(t.playlists.itemCount(count: count)); + } + } else { // For other media types, show standard metadata // Add content rating - if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) { + if (metadata.contentRating != null && + metadata.contentRating!.isNotEmpty) { final rating = formatContentRating(metadata.contentRating); if (rating.isNotEmpty) { parts.add(rating); @@ -511,7 +509,7 @@ class _MediaCardList extends StatelessWidget { // Add duration if (metadata.duration != null) { - parts.add(_formatDuration(metadata.duration!)); + parts.add(formatDurationTextual(metadata.duration!)); } // Add user rating @@ -668,7 +666,9 @@ class _MediaCardList extends StatelessWidget { fallbackIcon = Icons.playlist_play; } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); } if (posterUrl != null) { diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 234ae989..004f771e 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../utils/duration_formatter.dart'; import '../i18n/strings.g.dart'; /// Custom list item widget for playlist items @@ -97,7 +98,7 @@ class PlaylistItemCard extends StatelessWidget { // Duration if (item.duration != null) Text( - _formatDuration(item.duration!), + formatDurationTextual(item.duration!), style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), @@ -175,16 +176,4 @@ class PlaylistItemCard extends StatelessWidget { // Default to type return item.type; } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } } diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index a89a9e53..1fa83e61 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import '../../../utils/duration_formatter.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters @@ -34,18 +35,6 @@ class ChapterSheet extends StatelessWidget { ); } - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } - @override Widget build(BuildContext context) { return StreamBuilder( @@ -152,7 +141,7 @@ class ChapterSheet extends StatelessWidget { ), ), subtitle: Text( - _formatDuration(chapter.startTime), + formatDurationTimestamp(chapter.startTime), style: TextStyle( color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index 14c768ad..649d7096 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import 'base_video_control_sheet.dart'; @@ -37,7 +38,7 @@ class SleepTimerSheet extends StatelessWidget { listenable: sleepTimer, builder: (context, _) { return BaseVideoControlSheet( - title: 'Sleep Timer', + title: t.videoControls.sleepTimer, icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, iconColor: sleepTimer.isActive ? Colors.amber : null, child: SleepTimerContent( diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index fde60db0..88029ca2 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -16,6 +16,7 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../services/sleep_timer_service.dart'; import '../../utils/desktop_window_padding.dart'; +import '../../utils/duration_formatter.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -1181,14 +1182,14 @@ class _PlexVideoControlsState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1327,7 +1328,7 @@ class _PlexVideoControlsState extends State return Row( children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1342,7 +1343,7 @@ class _PlexVideoControlsState extends State ), const SizedBox(width: 12), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1624,15 +1625,4 @@ class _PlexVideoControlsState extends State } } - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index b218aa9a..fde98207 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; /// Widget displaying active sleep timer status with extend/cancel actions class SleepTimerActiveStatus extends StatelessWidget { @@ -15,20 +16,6 @@ class SleepTimerActiveStatus extends StatelessWidget { this.onCancel, }); - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - @override Widget build(BuildContext context) { return Container( @@ -36,9 +23,9 @@ class SleepTimerActiveStatus extends StatelessWidget { color: Colors.amber.withValues(alpha: 0.1), child: Column( children: [ - const Text( - 'Timer Active', - style: TextStyle( + Text( + t.videoControls.timerActive, + style: const TextStyle( color: Colors.amber, fontSize: 16, fontWeight: FontWeight.bold, @@ -46,11 +33,10 @@ class SleepTimerActiveStatus extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Playback will pause in ${_formatDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, + t.videoControls.playbackWillPauseIn( + duration: formatDurationWithSeconds(remainingTime), ), + style: const TextStyle(color: Colors.white70, fontSize: 14), ), const SizedBox(height: 16), Row( @@ -59,28 +45,21 @@ class SleepTimerActiveStatus extends StatelessWidget { OutlinedButton.icon( icon: const Icon(Icons.add), label: Text( - t.videoControls.addTime( - amount: "15", - unit: " min", - ), + t.videoControls.addTime(amount: "15", unit: " min"), ), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide(color: Colors.white54), ), onPressed: () { - sleepTimer.extendTimer( - const Duration(minutes: 15), - ); + sleepTimer.extendTimer(const Duration(minutes: 15)); }, ), const SizedBox(width: 12), FilledButton.icon( icon: const Icon(Icons.cancel), label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), + style: FilledButton.styleFrom(backgroundColor: Colors.red), onPressed: () { sleepTimer.cancelTimer(); onCancel?.call(); diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index f1a674bf..5c8a6037 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../services/sleep_timer_service.dart'; +import '../../../utils/duration_formatter.dart'; import '../../../i18n/strings.g.dart'; /// Widget displaying list of sleep timer durations for selection @@ -16,15 +17,6 @@ class SleepTimerDurationList extends StatelessWidget { this.defaultDuration, }); - String _formatLabel(int minutes) { - if (minutes < 60) { - return '$minutes minutes'; - } - final hours = minutes / 60; - final isWholeHour = minutes % 60 == 0; - return '${hours.toStringAsFixed(isWholeHour ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - } - @override Widget build(BuildContext context) { final durations = [5, 10, 15, 30, 45, 60, 90, 120]; @@ -38,7 +30,10 @@ class SleepTimerDurationList extends StatelessWidget { itemCount: durations.length, itemBuilder: (context, index) { final minutes = durations[index]; - final label = _formatLabel(minutes); + final label = formatDurationTextual( + minutes * 60 * 1000, // Convert minutes to milliseconds + abbreviated: false, // Use full format for better readability + ); return ListTile( leading: const Icon(Icons.timer, color: Colors.white70), @@ -57,11 +52,9 @@ class SleepTimerDurationList extends StatelessWidget { // Show a snackbar notification if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), + SnackBar( + content: Text(t.videoControls.sleepTimerCompleted), + duration: const Duration(seconds: 3), ), ); } @@ -71,9 +64,7 @@ class SleepTimerDurationList extends StatelessWidget { // Show confirmation snackbar ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - t.messages.sleepTimerSet(label: label), - ), + content: Text(t.messages.sleepTimerSet(label: label)), duration: const Duration(seconds: 2), ), ); diff --git a/pubspec.lock b/pubspec.lock index e818babd..fea4690d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + duration: + dependency: "direct main" + description: + name: duration + sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501" + url: "https://pub.dev" + source: hosted + version: "4.0.3" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d6ae92be..ff7927f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + duration: ^4.0.3 connectivity_plus: ^6.0.5 os_media_controls: git: