refactor: consolidate duration format
This commit is contained in:
+36
-30
@@ -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
@@ -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 '受保护';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -202,7 +202,11 @@
|
||||
"fillScreen": "填充屏幕",
|
||||
"stretch": "拉伸",
|
||||
"lockRotation": "锁定旋转",
|
||||
"unlockRotation": "解锁旋转"
|
||||
"unlockRotation": "解锁旋转",
|
||||
"sleepTimer": "睡眠定时器",
|
||||
"timerActive": "定时器已激活",
|
||||
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
|
||||
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "管理员",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user