refactor: consolidate duration format

This commit is contained in:
edde746
2025-11-16 23:53:11 +01:00
parent a952ab0a00
commit a6927f0edf
31 changed files with 378 additions and 315 deletions
+36 -30
View File
@@ -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<List<PlexMetadata>> 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<bool> 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) {
+50 -2
View File
@@ -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 '受保护';
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -202,7 +202,11 @@
"fillScreen": "填充屏幕",
"stretch": "拉伸",
"lockRotation": "锁定旋转",
"unlockRotation": "解锁旋转"
"unlockRotation": "解锁旋转",
"sleepTimer": "睡眠定时器",
"timerActive": "定时器已激活",
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停"
},
"userStatus": {
"admin": "管理员",
-11
View File
@@ -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;
+13 -8
View File
@@ -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
@@ -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<T extends StatefulWidget>
extends State<T> with Refreshable, ItemUpdatable {
extends State<T>
with Refreshable, ItemUpdatable {
// State properties - concrete implementations to avoid duplication
List<PlexMetadata> _items = [];
bool _isLoading = false;
@@ -55,9 +56,9 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
Future<void> _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;
}
+29 -29
View File
@@ -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),
);
},
),
+8 -22
View File
@@ -167,8 +167,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
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<LibrariesScreen>
// 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<LibrariesScreen>
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),
],
),
),
@@ -302,10 +302,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
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<LibraryBrowseTab>
),
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<LibraryBrowseTab>
),
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,
@@ -50,7 +50,9 @@ class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
_loadCollections();
// Listen for refresh notifications
_refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((_) {
_refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((
_,
) {
if (mounted) {
_loadCollections();
}
@@ -84,7 +86,9 @@ class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
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<LibraryCollectionsTab>
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -120,10 +123,7 @@ class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
onRetry: _loadCollections,
builder: (items) => RefreshIndicator(
onRefresh: _loadCollections,
child: AdaptiveMediaGrid(
items: items,
onRefresh: _loadCollections,
),
child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections),
),
);
}
@@ -113,7 +113,6 @@ class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -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<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
@@ -124,10 +121,7 @@ class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
itemCount: items.length,
itemBuilder: (context, index) {
final hub = items[index];
return HubSection(
hub: hub,
icon: _getHubIcon(hub),
);
return HubSection(hub: hub, icon: _getHubIcon(hub));
},
),
),
+2 -13
View File
@@ -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<MediaDetailScreen> {
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<MediaDetailScreen> {
);
}
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') {
+3 -2
View File
@@ -217,7 +217,6 @@ class _PlaylistDetailScreenState
}
}
Future<void> _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)),
+4 -14
View File
@@ -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<SeasonDetailScreen>
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<SeasonDetailScreen>
),
);
}
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')}';
}
}
}
+3 -1
View File
@@ -320,7 +320,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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) {
+83
View File
@@ -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();
}
}
+59 -59
View File
@@ -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<MediaCard> {
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<MediaCard> {
final result = await Navigator.push<bool>(
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<SettingsProvider>().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 = <String>[];
@@ -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<SettingsProvider>().useSeasonPoster;
posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster);
posterUrl = (item as PlexMetadata).posterThumb(
useSeasonPoster: useSeasonPoster,
);
}
if (posterUrl != null) {
+2 -13
View File
@@ -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';
}
}
}
@@ -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<Duration>(
@@ -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)
@@ -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(
+5 -15
View File
@@ -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<PlexVideoControls>
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<PlexVideoControls>
return Row(
children: [
Text(
_formatDuration(position),
formatDurationTimestamp(position),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
@@ -1342,7 +1343,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
),
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<PlexVideoControls>
}
}
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')}';
}
}
}
@@ -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();
@@ -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),
),
);
+8
View File
@@ -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:
+1
View File
@@ -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: