diff --git a/lib/bootstrap/platform/platform_app_wrapper.dart b/lib/bootstrap/platform/platform_app_wrapper.dart index 4f464a542..7b96d1823 100644 --- a/lib/bootstrap/platform/platform_app_wrapper.dart +++ b/lib/bootstrap/platform/platform_app_wrapper.dart @@ -7,19 +7,48 @@ import 'package:fladder/bootstrap/platform/base_app_wrapper.dart'; import 'package:fladder/bootstrap/platform/desktop_platform_wrapper.dart'; import 'package:fladder/bootstrap/platform/mobile_app_wrapper.dart'; import 'package:fladder/bootstrap/platform/web_app_wrapper.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; -class PlatformAppWrapper extends ConsumerWidget { +class PlatformAppWrapper extends ConsumerStatefulWidget { const PlatformAppWrapper({super.key, required this.builder}); final PlatformAppBuilder builder; @override - Widget build(BuildContext context, WidgetRef ref) { - if (kIsWeb) return WebAppWrapper(builder: builder); + ConsumerState createState() => _PlatformAppWrapperState(); +} + +class _PlatformAppWrapperState extends ConsumerState with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + // Safety check to ensure connectivity status is up to date when the app is resumed + ref.read(connectivityStatusProvider.notifier).checkConnectivity(); + default: + break; + } + } + + @override + Widget build(BuildContext context) { + if (kIsWeb) return WebAppWrapper(builder: widget.builder); - if (AdaptiveLayout.isDesktop(context)) return DesktopAppWrapper(builder: builder); + if (AdaptiveLayout.isDesktop(context)) return DesktopAppWrapper(builder: widget.builder); - return MobileAppWrapper(builder: builder); + return MobileAppWrapper(builder: widget.builder); } } diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 504b4b81a..1192ed36a 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -177,8 +177,7 @@ class PlaybackModelHelper { oldModel: currentModel, ); if (newModel == null) return null; - final advancedQueue = - currentModel?.playbackQueue.advanceFromCurrentTo(currentModel.item.id, newItem.id); + final advancedQueue = currentModel?.playbackQueue.advanceFromCurrentTo(currentModel.item.id, newItem.id); final modelToLoad = advancedQueue != null ? newModel.updatePlaybackQueue(advancedQueue) : newModel; ref.read(videoPlayerProvider.notifier).loadPlaybackItem(modelToLoad, Duration.zero); return modelToLoad; @@ -306,6 +305,24 @@ class PlaybackModelHelper { } } + Future getOfflineModel() => _createOfflinePlaybackModel( + fullItem, + item.streamModel, + syncedItem, + oldModel: oldModel, + queueSource: effectiveQueueSource, + ); + + Future getServerModel(PlaybackType type) => _createServerPlaybackModel( + fullItem, + item.streamModel, + forcedPlaybackType ?? type, + oldModel: oldModel, + libraryQueue: queue, + queueSource: effectiveQueueSource, + startPosition: actualStartPosition, + ); + if (((showPlaybackOptions || firstItemIsSynced) && !isOffline) && context != null) { final playbackType = await showPlaybackTypeSelection( context: context, @@ -315,42 +332,17 @@ class PlaybackModelHelper { if (!context.mounted) return null; return switch (playbackType) { - PlaybackType.directStream || PlaybackType.transcode || PlaybackType.tv => await _createServerPlaybackModel( - fullItem, - item.streamModel, - forcedPlaybackType ?? playbackType, - oldModel: oldModel, - libraryQueue: queue, - queueSource: effectiveQueueSource, - startPosition: actualStartPosition, - ), - PlaybackType.offline => await _createOfflinePlaybackModel( - fullItem, - item.streamModel, - syncedItem, - oldModel: oldModel, - queueSource: effectiveQueueSource, - ), - null => null + PlaybackType.directStream || PlaybackType.transcode || PlaybackType.tv => await getServerModel(playbackType!), + PlaybackType.offline => await getOfflineModel(), + null => null, }; - } else { - return (await _createServerPlaybackModel( - fullItem, - item.streamModel, - forcedPlaybackType ?? PlaybackType.directStream, - startPosition: actualStartPosition, - oldModel: oldModel, - libraryQueue: queue, - queueSource: effectiveQueueSource, - )) ?? - await _createOfflinePlaybackModel( - fullItem, - item.streamModel, - syncedItem, - oldModel: oldModel, - queueSource: effectiveQueueSource, - ); } + + if (isOffline) { + return await getOfflineModel(); + } + + return await getServerModel(PlaybackType.directStream) ?? await getOfflineModel(); } catch (e) { log("Error creating playback model: ${e.toString()}"); return null; @@ -392,7 +384,7 @@ class PlaybackModelHelper { newStreamModel?.subStreams, newStreamModel?.defaultSubStreamIndex); - //Native player does not allow for loading external subtitles with transcoding +//Native player does not allow for loading external subtitles with transcoding final isNativePlayer = ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer == PlayerOptions.nativePlayer)); final isExternalSub = newStreamModel?.currentSubStream?.isExternal == true; diff --git a/lib/providers/api_provider.dart b/lib/providers/api_provider.dart index 84c256f04..9930586c0 100644 --- a/lib/providers/api_provider.dart +++ b/lib/providers/api_provider.dart @@ -89,6 +89,7 @@ class JellyRequest implements Interceptor { @override FutureOr> intercept(Chain chain) async { final connectivityNotifier = ref.read(connectivityStatusProvider.notifier); + // final serverUrl = "https://example.com"; // ref.read(serverUrlProvider); --- IGNORE --- final serverUrl = ref.read(serverUrlProvider); if (serverUrl == null || serverUrl.isEmpty) { @@ -112,7 +113,7 @@ class JellyRequest implements Interceptor { ), ); - connectivityNotifier.checkConnectivity(); + unawaited(connectivityNotifier.checkConnectivity()); return response; } catch (e) { if (!_isConnectionError(e) || attempt == _maxRetries) { diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart index 87b8732db..2c6f1b969 100644 --- a/lib/providers/connectivity_provider.dart +++ b/lib/providers/connectivity_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -48,7 +49,7 @@ class ConnectivityStatus extends _$ConnectivityStatus { } } - void onStateChange(List connectivityResult) async { + Future onStateChange(List connectivityResult) async { if (connectivityResult.contains(ConnectivityResult.ethernet)) { state = ConnectionState.ethernet; } else if (connectivityResult.contains(ConnectivityResult.wifi)) { @@ -68,9 +69,28 @@ class ConnectivityStatus extends _$ConnectivityStatus { ref.read(localConnectionAvailableProvider.notifier).update((state) => correctServerResponse); } - void checkConnectivity() async { + Future checkConnectivity() async { final connectivityResult = await Connectivity().checkConnectivity(); - onStateChange(connectivityResult); + final serverUrl = ref.read(serverUrlProvider); + final checkServer = await probeJellyfinUrl( + serverUrl ?? "", + ); + if (checkServer != null) { + onStateChange(connectivityResult); + } else { + onStateChange([ConnectivityResult.none]); + } + } + + ConnectionState getConnectivityStates() { + unawaited(ref.read(jellyApiProvider).systemInfoPublicGet().then( + (value) async { + if (!value.isSuccessful) { + onStateChange([ConnectivityResult.none]); + } + }, + )); + return state; } } diff --git a/lib/providers/items/album_details_provider.dart b/lib/providers/items/album_details_provider.dart index 13b6261c7..5fc4ef087 100644 --- a/lib/providers/items/album_details_provider.dart +++ b/lib/providers/items/album_details_provider.dart @@ -9,7 +9,9 @@ import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/album_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; +import 'package:fladder/providers/sync_provider.dart'; final albumDetailsProvider = StateNotifierProvider.autoDispose.family((ref, id) { @@ -45,6 +47,15 @@ class AlbumDetailsNotifier extends StateNotifier { Future fetchTracks() async { if (state == null) return; + if (ref.read(connectivityStatusProvider) == ConnectionState.offline) { + final tracks = (await ref.read(syncProvider.notifier).getChildren(state!.id)) + .map((item) => item.itemModel) + .whereType() + .toList(); + state = state?.copyWith(tracks: tracks); + return; + } + try { final response = await api.itemsGet( parentId: state!.id, @@ -70,6 +81,20 @@ class AlbumDetailsNotifier extends StateNotifier { Future fetchArtistRelated() async { if (state == null) return; + if (ref.read(connectivityStatusProvider) == ConnectionState.offline) { + final parentId = state!.parentId; + if (parentId == null) return; + + final albums = (await ref.read(syncProvider.notifier).getChildren(parentId)) + .map((item) => item.itemModel) + .whereType() + .where((album) => album.id != state!.id) + .toList(); + + state = state?.copyWith(relatedAlbums: albums); + return; + } + try { final artistIds = state!.artistIds.isNotEmpty ? state!.artistIds : state!.albumArtistIds; if (artistIds.isEmpty) return; diff --git a/lib/providers/items/artist_details_provider.dart b/lib/providers/items/artist_details_provider.dart index 93ff1f0c4..3e8eaf522 100644 --- a/lib/providers/items/artist_details_provider.dart +++ b/lib/providers/items/artist_details_provider.dart @@ -10,34 +10,9 @@ import 'package:fladder/models/items/album_model.dart'; import 'package:fladder/models/items/artist_model.dart'; import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; - -Future> fetchArtistLatestTracks( - JellyService api, - String artistId, { - int limit = 10, -}) async { - final response = await api.itemsGet( - parentId: artistId, - includeItemTypes: [BaseItemKind.audio], - enableUserData: true, - enableImages: true, - recursive: true, - imageTypeLimit: 1, - fields: [ItemFields.primaryimageaspectratio], - sortBy: [ - ItemSortBy.playcount, - ItemSortBy.productionyear, - ItemSortBy.premieredate, - ItemSortBy.datecreated, - ItemSortBy.sortname, - ], - sortOrder: [SortOrder.descending], - limit: limit, - ); - - return response.body?.items.whereType().toList() ?? []; -} +import 'package:fladder/providers/sync_provider.dart'; final artistDetailsProvider = StateNotifierProvider.autoDispose.family((ref, id) { @@ -89,28 +64,26 @@ class ArtistDetailsNotifier extends StateNotifier { Future fetchAlbums() async { if (state == null) return; - try { - final response = await api.itemsGet( - parentId: state!.id, - includeItemTypes: [BaseItemKind.musicalbum], - enableUserData: true, - enableImages: true, - imageTypeLimit: 1, - fields: [ItemFields.primaryimageaspectratio], - sortBy: [ItemSortBy.sortname], - sortOrder: [SortOrder.ascending], - limit: 100, - ); + if (ref.read(connectivityStatusProvider) == ConnectionState.offline) { + final albums = (await ref.read(syncProvider.notifier).getChildren(state!.id)) + .map((item) => item.itemModel) + .whereType() + .toList(); + + state = state?.copyWith(albums: albums); + return; + } - final albums = response.body?.items.whereType().toList(); - if (albums != null) { + try { + final albums = await fetchArtistAlbums(state!.id); + if (albums.isNotEmpty) { final tracksResponse = await api.itemsGet( parentId: state!.id, includeItemTypes: [BaseItemKind.audio], enableUserData: false, recursive: true, fields: [ItemFields.candownload], - limit: 5000, + limit: 10, ); final downloadableAlbumIds = tracksResponse.body?.items @@ -138,6 +111,20 @@ class ArtistDetailsNotifier extends StateNotifier { Future fetchTracks({int limit = 10}) async { if (state == null) return; + if (ref.read(connectivityStatusProvider) == ConnectionState.offline) { + final syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(state!.id); + if (syncedItem == null) return; + + final tracks = (await ref.read(syncProvider.notifier).getNestedChildren(syncedItem)) + .map((item) => item.itemModel) + .whereType() + .take(limit) + .toList(); + + state = state?.copyWith(tracks: tracks); + return; + } + try { final tracks = await fetchArtistLatestTracks(api, state!.id, limit: limit); state = state?.copyWith(tracks: tracks); @@ -147,8 +134,84 @@ class ArtistDetailsNotifier extends StateNotifier { } } + Future> fetchArtistAlbums(String artistId) async { + final response = await api.itemsGet( + parentId: state!.id, + includeItemTypes: [BaseItemKind.musicalbum], + enableUserData: true, + enableImages: true, + imageTypeLimit: 1, + fields: [ItemFields.primaryimageaspectratio], + sortBy: [ + ItemSortBy.airtime, + ItemSortBy.productionyear, + ItemSortBy.premieredate, + ItemSortBy.datecreated, + ItemSortBy.sortname, + ], + sortOrder: [SortOrder.descending], + limit: 100, + ); + + return response.body?.items.whereType().toList() ?? []; + } + + Future> fetchArtistLatestTracks( + JellyService api, + String artistId, { + int limit = 10, + }) async { + final response = await api.itemsGet( + parentId: artistId, + includeItemTypes: [BaseItemKind.audio], + enableUserData: true, + enableImages: true, + recursive: true, + imageTypeLimit: 1, + fields: [ItemFields.primaryimageaspectratio], + sortBy: [ + ItemSortBy.airtime, + ItemSortBy.playcount, + ItemSortBy.productionyear, + ItemSortBy.premieredate, + ItemSortBy.datecreated, + ItemSortBy.sortname, + ], + sortOrder: [SortOrder.descending], + limit: limit, + ); + if (response.body?.items.isEmpty == true) { + final albums = await fetchArtistAlbums(artistId); + + final retryResponse = await api.itemsGet( + albumIds: albums.map((album) => album.id).toList(), + includeItemTypes: [BaseItemKind.audio], + enableUserData: true, + enableImages: true, + recursive: true, + imageTypeLimit: 1, + fields: [ItemFields.primaryimageaspectratio], + sortBy: [ + ItemSortBy.productionyear, + ItemSortBy.premieredate, + ItemSortBy.datecreated, + ItemSortBy.sortname, + ], + sortOrder: [SortOrder.descending], + limit: limit, + ); + return retryResponse.body?.items.whereType().toList() ?? []; + } + + return response.body?.items.whereType().toList() ?? []; + } + Future fetchSimilarArtists() async { if (state == null) return; + if (ref.read(connectivityStatusProvider) == ConnectionState.offline) { + return; + } + try { final response = await api.itemsItemIdSimilarGet(itemId: state!.id, limit: 12); final related = diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 914c7935f..b9cc65b35 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -22,6 +22,7 @@ import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/auth_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/image_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/user_provider.dart'; @@ -92,9 +93,22 @@ class JellyService { final Ref ref; AccountModel? get account => ref.read(userProvider); + Future> _syncedItemResponse(String? itemId) async { + final item = (await ref.read(syncProvider.notifier).getSyncedItem(itemId))?.itemModel; + return Response( + http.Response("", 202), + item, + ); + } + Future> usersUserIdItemsItemIdGet({ String? itemId, }) async { + final isOffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + if (isOffline) { + return _syncedItemResponse(itemId); + } + try { final response = await api.itemsItemIdGet( userId: account?.id, @@ -102,34 +116,43 @@ class JellyService { ); return response.copyWith(body: ItemBaseModel.fromBaseDto(response.bodyOrThrow, ref)); } catch (e) { - final item = (await ref.read(syncProvider.notifier).getSyncedItem(itemId))?.itemModel; - return Response( - http.Response("", 202), - item, - ); + return _syncedItemResponse(itemId); } } Future> usersUserIdItemsItemIdGetBaseItem({ String? itemId, }) async { + final isOffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + if (isOffline) { + final syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(itemId); + return syncedItem?.data != null + ? Response( + http.Response("", 202), + syncedItem?.data, + ) + : Response( + http.Response("", 404), + null, + ); + } + try { return await api.itemsItemIdGet( userId: account?.id, itemId: itemId, ); } catch (e) { - return ref.read(syncProvider.notifier).getSyncedItem(itemId).then( - (value) => value?.data != null - ? Response( - http.Response("", 202), - value?.data, - ) - : Response( - http.Response("", 404), - null, - ), - ); + final syncedItem = await ref.read(syncProvider.notifier).getSyncedItem(itemId); + return syncedItem?.data != null + ? Response( + http.Response("", 202), + syncedItem?.data, + ) + : Response( + http.Response("", 404), + null, + ); } } @@ -343,6 +366,24 @@ class JellyService { enableImages: enableImages, ); + final isOffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + + if (isOffline) { + final syncedItems = ref.read(syncProvider).items.where((e) => e.parentId == parentId).toList(); + + return Response( + http.Response("", 202), + ServerQueryResult.fromBaseQuery( + BaseItemDtoQueryResult( + items: syncedItems.map((e) => e.data).nonNulls.toList(), + totalRecordCount: syncedItems.length, + startIndex: 0, + ), + ref, + ), + ); + } + return response.copyWith( body: ServerQueryResult.fromBaseQuery(response.bodyOrThrow, ref), ); @@ -641,28 +682,7 @@ class JellyService { bool? enableUserData, ShowsSeriesIdEpisodesGetSortBy? sortBy, }) async { - try { - var response = await api.showsSeriesIdEpisodesGet( - seriesId: seriesId, - userId: account?.id, - fields: [ - ...?fields, - ItemFields.parentid, - ], - isMissing: isMissing, - limit: limit, - sortBy: sortBy, - enableUserData: enableUserData, - startIndex: startIndex, - adjacentTo: adjacentTo, - startItemId: startItemId, - season: season, - seasonId: seasonId, - enableImages: enableImages, - enableImageTypes: enableImageTypes, - ); - return response; - } catch (e) { + Future> fetchOfflineEpisodes() async { final seriesItem = await ref.read(syncProvider.notifier).getSyncedItem(seriesId); if (seriesItem != null) { final episodes = await ref.read(syncProvider.notifier).getNestedChildren(seriesItem) @@ -686,6 +706,37 @@ class JellyService { ); } } + + final isoffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + + if (isoffline) { + return fetchOfflineEpisodes(); + } + + try { + var response = await api.showsSeriesIdEpisodesGet( + seriesId: seriesId, + userId: account?.id, + fields: [ + ...?fields, + ItemFields.parentid, + ], + isMissing: isMissing, + limit: limit, + sortBy: sortBy, + enableUserData: enableUserData, + startIndex: startIndex, + adjacentTo: adjacentTo, + startItemId: startItemId, + season: season, + seasonId: seasonId, + enableImages: enableImages, + enableImageTypes: enableImageTypes, + ); + return response; + } catch (e) { + return fetchOfflineEpisodes(); + } } Future> fetchEpisodeFromShow({ @@ -704,13 +755,7 @@ class JellyService { String? itemId, int? limit, }) async { - try { - return await api.itemsItemIdSimilarGet(userId: account?.id, itemId: itemId, limit: limit, fields: [ - ItemFields.parentid, - ItemFields.candelete, - ItemFields.candownload, - ]); - } catch (e) { + Future> fetchSimilarGet() async { return Response( http.Response("", 400), const BaseItemDtoQueryResult( @@ -720,6 +765,22 @@ class JellyService { ), ); } + + final isOffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + + if (isOffline) { + return fetchSimilarGet(); + } + + try { + return await api.itemsItemIdSimilarGet(userId: account?.id, itemId: itemId, limit: limit, fields: [ + ItemFields.parentid, + ItemFields.candelete, + ItemFields.candownload, + ]); + } catch (e) { + return fetchSimilarGet(); + } } Future> usersUserIdItemsGet({ @@ -969,15 +1030,7 @@ class JellyService { bool? isMissing, List? fields, }) async { - try { - final response = await api.showsSeriesIdSeasonsGet( - seriesId: seriesId, - isMissing: isMissing, - enableUserData: enableUserData, - fields: fields, - ); - return response; - } catch (e) { + Future> fetchOfflineSeasons() async { final seriesItem = await ref.read(syncProvider.notifier).getSyncedItem(seriesId); if (seriesItem != null) { final seasons = await ref.read(syncProvider.notifier).getChildren(seriesItem.id); @@ -1000,6 +1053,23 @@ class JellyService { ); } } + + final isOffline = ref.read(connectivityStatusProvider.notifier).getConnectivityStates() == ConnectionState.offline; + if (isOffline) { + return fetchOfflineSeasons(); + } + + try { + final response = await api.showsSeriesIdSeasonsGet( + seriesId: seriesId, + isMissing: isMissing, + enableUserData: enableUserData, + fields: fields, + ); + return response; + } catch (e) { + return fetchOfflineSeasons(); + } } Future> itemsFilters2Get({ diff --git a/lib/providers/sync/sync_provider_helpers.dart b/lib/providers/sync/sync_provider_helpers.dart index a94ad206a..b22cde0fb 100644 --- a/lib/providers/sync/sync_provider_helpers.dart +++ b/lib/providers/sync/sync_provider_helpers.dart @@ -1,10 +1,19 @@ import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as path; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/album_model.dart'; +import 'package:fladder/models/items/artist_model.dart'; +import 'package:fladder/models/items/audio_model.dart'; import 'package:fladder/models/syncing/download_stream.dart'; import 'package:fladder/models/syncing/sync_item.dart'; +import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/connectivity_provider.dart'; +import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; part 'sync_provider_helpers.g.dart'; @@ -22,7 +31,119 @@ Stream syncedItem(Ref ref, ItemBaseModel? item) { @riverpod class SyncedChildren extends _$SyncedChildren { @override - FutureOr> build(SyncedItem item) => ref.read(syncProvider.notifier).getChildrenForItem(item); + FutureOr> build(SyncedItem item) async { + final syncNotifier = ref.read(syncProvider.notifier); + final localChildren = await syncNotifier.getChildrenForItem(item); + final isOnline = ref.watch(connectivityStatusProvider.select((value) => value != ConnectionState.offline)); + if (!isOnline) { + return localChildren; + } + + try { + final api = ref.read(jellyApiProvider); + return switch (item.itemModel) { + AlbumModel _ => await _mergeAlbumChildren(item: item, localChildren: localChildren, api: api), + ArtistModel _ => await _mergeArtistChildren(item: item, localChildren: localChildren, api: api, ref: ref), + _ => localChildren, + }; + } catch (_) { + return localChildren; + } + } +} + +Future> _mergeAlbumChildren({ + required SyncedItem item, + required List localChildren, + required JellyService api, +}) async { + final localTracks = localChildren.where((child) => child.itemModel is AudioModel).toList(); + final localById = {for (final child in localTracks) child.id: child}; + final response = await api.itemsGet( + parentId: item.id, + includeItemTypes: [BaseItemKind.audio], + recursive: false, + enableUserData: true, + enableImages: true, + imageTypeLimit: 1, + fields: [ItemFields.primaryimageaspectratio], + sortBy: [ItemSortBy.sortname], + sortOrder: [SortOrder.ascending], + ); + + final serverTracks = response.body?.items.whereType().toList() ?? []; + final merged = serverTracks + .map((track) => localById[track.id] ?? _createUnsyncedRemoteItem(parent: item, model: track)) + .toList(); + + final missingLocal = localTracks.where((child) => !merged.any((mergedChild) => mergedChild.id == child.id)); + merged.addAll(missingLocal); + merged.sort(_syncChildComparator); + return merged; +} + +Future> _mergeArtistChildren({ + required SyncedItem item, + required List localChildren, + required JellyService api, + required Ref ref, +}) async { + final localNested = await ref.read(syncProvider.notifier).getNestedChildren(item); + final localById = { + for (final child in [...localChildren, ...localNested]) child.id: child, + }; + + final albumsResponse = await api.itemsGet( + parentId: item.id, + includeItemTypes: [BaseItemKind.musicalbum], + recursive: false, + enableUserData: true, + enableImages: true, + imageTypeLimit: 1, + fields: [ItemFields.primaryimageaspectratio], + sortBy: [ItemSortBy.sortname], + sortOrder: [SortOrder.ascending], + ); + + final remoteAlbums = albumsResponse.body?.items.whereType() ?? const []; + final merged = remoteAlbums + .map((remoteItem) => localById[remoteItem.id] ?? _createUnsyncedRemoteItem(parent: item, model: remoteItem)) + .toList(); + + final localAlbums = localChildren.where((child) => child.itemModel is AlbumModel); + final missingLocal = localAlbums.where((child) => !merged.any((mergedChild) => mergedChild.id == child.id)); + merged.addAll(missingLocal); + + final deduplicated = {for (final child in merged) child.id: child}.values.toList(); + deduplicated.sort(_syncChildComparator); + return deduplicated; +} + +SyncedItem _createUnsyncedRemoteItem({required SyncedItem parent, required ItemBaseModel model}) { + final isAudio = model is AudioModel; + final syncPath = parent.path != null ? path.join(parent.path!, model.id) : null; + return SyncedItem( + id: model.id, + parentId: parent.id, + userId: parent.userId, + path: syncPath, + sortName: model.name.toLowerCase(), + fileSize: isAudio ? 1 : null, + videoFileName: isAudio ? '${model.id}.audio' : null, + itemModel: model, + ); +} + +int _syncChildComparator(SyncedItem a, SyncedItem b) { + final aIsAlbum = a.itemModel is AlbumModel; + final bIsAlbum = b.itemModel is AlbumModel; + if (aIsAlbum != bIsAlbum) { + return aIsAlbum ? -1 : 1; + } + + final aName = a.sortName ?? a.itemModel?.name.toLowerCase() ?? ''; + final bName = b.sortName ?? b.itemModel?.name.toLowerCase() ?? ''; + return compareNatural(aName, bName); } @riverpod diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 8ef181ed5..c00cf4486 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -539,7 +539,73 @@ class SyncNotifier extends StateNotifier { return _db.insertItem(syncedItem); } + Future syncSyncedItem( + BuildContext context, + SyncedItem syncedItem, { + TranscodeDownloadModel? transcodeModel, + TranscodeMusicDownloadModel? musicTranscodeModel, + }) async { + final model = syncedItem.itemModel; + + switch (model) { + case AudioModel audio: + await syncAudio(audio, musicTranscodeModel: musicTranscodeModel); + return; + case AlbumModel album: + await syncAlbum(album, musicTranscodeModel: musicTranscodeModel); + return; + case ArtistModel artist: + await syncArtist(artist, musicTranscodeModel: musicTranscodeModel); + return; + default: + await syncFile( + syncedItem, + false, + transcodeModel: transcodeModel, + musicTranscodeModel: musicTranscodeModel, + ); + return; + } + } + Future deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async { + final itemType = syncedItem.itemModel?.type; + + if (itemType == FladderItemType.audio) { + await _deleteSyncedItemAndFiles(syncedItem); + ref.read(downloadTasksProvider(syncedItem.id).notifier).update((state) => DownloadStream.empty()); + await _cleanupOrphanedMusicParents([syncedItem]); + cleanupTemporaryFiles(); + refresh(); + return syncedItem; + } + + if (itemType == FladderItemType.musicAlbum) { + final nestedChildren = await getNestedChildren(syncedItem); + final removedTracks = nestedChildren.where((element) => element.itemModel is AudioModel).toList(); + + for (var i = 0; i < nestedChildren.length; i++) { + final child = nestedChildren[i]; + await ref.read(backgroundDownloaderProvider).cancelTaskWithId(child.id); + ref.read(downloadTasksProvider(child.id).notifier).update((state) => DownloadStream.empty()); + } + + await ref.read(backgroundDownloaderProvider).cancelTaskWithId(syncedItem.id); + ref.read(downloadTasksProvider(syncedItem.id).notifier).update((state) => DownloadStream.empty()); + + await _db.deleteAllItems([...nestedChildren, syncedItem]); + + if (await syncedItem.directory.exists()) { + await syncedItem.directory.delete(recursive: true); + } + + await _cleanupOrphanedMusicParents(removedTracks); + + cleanupTemporaryFiles(); + refresh(); + return syncedItem; + } + await syncedItem.deleteDatFiles(ref); syncedItem = syncedItem.copyWith( diff --git a/lib/screens/syncing/widgets/sync_file_button.dart b/lib/screens/syncing/widgets/sync_file_button.dart index 91a7506c4..3b5865bc9 100644 --- a/lib/screens/syncing/widgets/sync_file_button.dart +++ b/lib/screens/syncing/widgets/sync_file_button.dart @@ -28,7 +28,10 @@ class SyncFileButton extends ConsumerWidget { return Tooltip( message: transcodeEnabled ? context.localized.downloadTranscoded : context.localized.downloadOriginal, child: IconButtonAwait( - onPressed: () async => await ref.read(syncProvider.notifier).syncFile(syncedItem, false), + onPressed: () async => await ref.read(syncProvider.notifier).syncSyncedItem( + context, + syncedItem, + ), onLongPress: () async { TranscodeDownloadModel? transcodeModel; TranscodeMusicDownloadModel? musicTranscodeModel; @@ -64,9 +67,9 @@ class SyncFileButton extends ConsumerWidget { if (cancelled) { return; } - await ref.read(syncProvider.notifier).syncFile( + await ref.read(syncProvider.notifier).syncSyncedItem( + context, syncedItem, - false, transcodeModel: transcodeModel, musicTranscodeModel: musicTranscodeModel, ); diff --git a/lib/screens/syncing/widgets/sync_options_button.dart b/lib/screens/syncing/widgets/sync_options_button.dart index 5d696cbca..ea569154c 100644 --- a/lib/screens/syncing/widgets/sync_options_button.dart +++ b/lib/screens/syncing/widgets/sync_options_button.dart @@ -36,7 +36,8 @@ class SyncOptionsButton extends ConsumerWidget { itemBuilder: (context) { final unSyncedChildren = children.where((element) { final hasDownload = ref.read(syncDownloadStatusProvider(element, [])); - return element.hasVideoFile && !element.videoFile.existsSync() && hasDownload?.status == TaskStatus.notFound; + final canSync = element.itemModel?.syncAble == true || element.hasVideoFile; + return canSync && !element.videoFile.existsSync() && hasDownload?.status == TaskStatus.notFound; }).toList(); final isAudioBatch = unSyncedChildren.isNotEmpty && unSyncedChildren.every((element) => element.itemModel is AudioModel); @@ -263,9 +264,9 @@ Future _syncRemainingItems( ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)), FilledButtonAwait( onPressed: () async { - final syncList = unSyncedChildren.map((e) => ref.read(syncProvider.notifier).syncFile( + final syncList = unSyncedChildren.map((e) => ref.read(syncProvider.notifier).syncSyncedItem( + context, e, - false, transcodeModel: transcodeModel, musicTranscodeModel: musicTranscodeModel, )); diff --git a/lib/screens/syncing/widgets/synced_album_item.dart b/lib/screens/syncing/widgets/synced_album_item.dart index b729cc378..e1764b77c 100644 --- a/lib/screens/syncing/widgets/synced_album_item.dart +++ b/lib/screens/syncing/widgets/synced_album_item.dart @@ -36,104 +36,107 @@ class _SyncedAlbumItemState extends ConsumerState { Widget build(BuildContext context) { final childrenAsync = ref.watch(syncedChildrenProvider(widget.syncedItem)); - return childrenAsync.when( - data: (children) { - final trackChildren = children.where((item) => item.itemModel is AudioModel).toList(); - final album = widget.album; - final syncedItem = widget.syncedItem; - return ExpansionTile( - tilePadding: EdgeInsets.zero, - shape: const Border(), - title: Row( - spacing: 12, - children: [ - SizedBox( - width: 125, - child: AspectRatio( - aspectRatio: 1.0, - child: FlatButton( - onTap: () { - album.navigateTo(context); - return context.maybePop(); - }, - child: Card( - child: FladderImage( - image: album.getPosters?.primary ?? album.getPosters?.backDrop?.firstOrNull, - ), + Widget buildWidget(List children, bool loading) { + final trackChildren = children.where((item) => item.itemModel is AudioModel).toList(); + final album = widget.album; + final syncedItem = widget.syncedItem; + return ExpansionTile( + tilePadding: EdgeInsets.zero, + shape: const Border(), + title: Row( + spacing: 12, + children: [ + SizedBox( + width: 125, + child: AspectRatio( + aspectRatio: 1.0, + child: FlatButton( + onTap: () { + album.navigateTo(context); + return context.maybePop(); + }, + child: Card( + child: FladderImage( + image: album.getPosters?.primary ?? album.getPosters?.backDrop?.firstOrNull, ), ), ), ), - Flexible( - child: SyncProgressBuilder( - item: syncedItem, - children: trackChildren, - builder: (context, combinedStream) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Flexible( - child: Text( - album.name, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), + ), + Flexible( + child: SyncProgressBuilder( + item: syncedItem, + children: trackChildren, + builder: (context, combinedStream) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Flexible( + child: Text( + album.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, ), - if (album.artistLabel.isNotEmpty) - Flexible( - child: Opacity( - opacity: 0.75, - child: Text( - album.artistLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), + ), + if (album.artistLabel.isNotEmpty) Flexible( - child: SyncSubtitle( - syncItem: syncedItem, - children: trackChildren, + child: Opacity( + opacity: 0.75, + child: Text( + album.artistLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), ), ), - Flexible( - child: Consumer( - builder: (context, ref, child) => SyncLabel( - label: context.localized.totalSize( - ref.watch(syncSizeProvider(syncedItem, trackChildren))?.byteFormat ?? '--'), - status: combinedStream?.status ?? TaskStatus.notFound, - ), + Flexible( + child: SyncSubtitle( + syncItem: syncedItem, + children: trackChildren, + ), + ), + Flexible( + child: Consumer( + builder: (context, ref, child) => SyncLabel( + label: context.localized + .totalSize(ref.watch(syncSizeProvider(syncedItem, trackChildren))?.byteFormat ?? '--'), + status: combinedStream?.status ?? TaskStatus.notFound, ), ), - if (combinedStream != null && combinedStream.hasDownload) - SyncProgressBar(item: syncedItem, task: combinedStream), - ], - ); - }, - ), + ), + if (combinedStream != null && combinedStream.hasDownload) + SyncProgressBar(item: syncedItem, task: combinedStream), + ], + ); + }, ), - ], - ), - trailing: SyncOptionsButton(syncedItem: syncedItem, children: trackChildren), - children: trackChildren - .map( - (item) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: SyncedAudioItem( - audio: item.itemModel as AudioModel, - syncedItem: item, - ), + ), + ], + ), + trailing: loading + ? const CircularProgressIndicator() + : SyncOptionsButton(syncedItem: syncedItem, children: trackChildren), + children: trackChildren + .map( + (item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SyncedAudioItem( + audio: item.itemModel as AudioModel, + syncedItem: item, ), - ) - .toList(), - ); - }, - error: (error, stackTrace) => const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - ); + ), + ) + .toList(), + ); + } + + return switch (childrenAsync) { + AsyncData(:final asData) => buildWidget(asData?.value ?? [], false), + _ => buildWidget([], true), + }; } } diff --git a/lib/screens/syncing/widgets/synced_audio_item.dart b/lib/screens/syncing/widgets/synced_audio_item.dart index dd643c34a..386d8b3cb 100644 --- a/lib/screens/syncing/widgets/synced_audio_item.dart +++ b/lib/screens/syncing/widgets/synced_audio_item.dart @@ -74,116 +74,125 @@ class _SyncedAudioItemState extends ConsumerState { @override Widget build(BuildContext context) { - final downloadTask = ref.watch(downloadTasksProvider(syncedItem.id)); - final hasFile = syncedItem.videoFile.existsSync(); - final artistLabel = widget.audio.artistsLabel; - final trackLabel = widget.audio.trackLabel(context, widget.audio.trackNumber); - final albumLabel = widget.audio.albumLabel(); - final coverImage = widget.audio.getPosters?.primary ?? - widget.audio.getPosters?.backDrop?.firstOrNull ?? - parentAlbumItem?.itemModel?.getPosters?.primary ?? - parentAlbumItem?.itemModel?.getPosters?.backDrop?.firstOrNull; + final syncedItem = ref.watch(syncedItemProvider(widget.syncedItem.itemModel)); - return IntrinsicHeight( - child: Row( - children: [ - SyncItemPoster( - item: syncedItem, - child: FlatButton( - onTap: () { - widget.audio.navigateTo(context); - return context.maybePop(); - }, - child: SizedBox( - width: 64, - child: AspectRatio( - aspectRatio: 1, - child: Card( - child: FladderImage( - image: coverImage, - fit: BoxFit.cover, + Widget buildWidget(SyncedItem syncedItem) { + final downloadTask = ref.watch(downloadTasksProvider(syncedItem.id)); + final hasFile = syncedItem.videoFile.existsSync(); + final artistLabel = widget.audio.artistsLabel; + final trackLabel = widget.audio.trackLabel(context, widget.audio.trackNumber); + final albumLabel = widget.audio.albumLabel(); + final coverImage = widget.audio.getPosters?.primary ?? + widget.audio.getPosters?.backDrop?.firstOrNull ?? + parentAlbumItem?.itemModel?.getPosters?.primary ?? + parentAlbumItem?.itemModel?.getPosters?.backDrop?.firstOrNull; + + return IntrinsicHeight( + child: Row( + children: [ + SyncItemPoster( + item: syncedItem, + child: FlatButton( + onTap: () { + widget.audio.navigateTo(context); + return context.maybePop(); + }, + child: SizedBox( + width: 64, + child: AspectRatio( + aspectRatio: 1, + child: Card( + child: FladderImage( + image: coverImage, + fit: BoxFit.cover, + ), ), ), ), ), ), - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Text( - widget.audio.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Flexible( - child: Opacity( - opacity: 0.75, + Expanded( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( child: Text( - [ - if (trackLabel.isNotEmpty) trackLabel, - if (artistLabel.isNotEmpty) artistLabel, - if (albumLabel.isNotEmpty) albumLabel, - ].join(' • '), + widget.audio.name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, + style: Theme.of(context).textTheme.titleMedium, ), ), - ), - ], - ), - ), - if (!hasFile && downloadTask.hasDownload) - Flexible( - child: SyncProgressBar(item: syncedItem, task: downloadTask), - ) - else - Flexible( - child: SyncLabel( - label: - context.localized.totalSize(ref.watch(syncSizeProvider(syncedItem, [])).byteFormat ?? '--'), - status: ref.watch(syncDownloadStatusProvider(syncedItem, []) - .select((value) => value?.status ?? TaskStatus.notFound)), + Flexible( + child: Opacity( + opacity: 0.75, + child: Text( + [ + if (trackLabel.isNotEmpty) trackLabel, + if (artistLabel.isNotEmpty) artistLabel, + if (albumLabel.isNotEmpty) albumLabel, + ].join(' • '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ], ), ), - ], - ), - ), - if (!hasFile && !downloadTask.hasDownload) - SyncFileButton(syncedItem: syncedItem) - else if (hasFile) - IconButtonAwait( - color: Theme.of(context).colorScheme.error, - onPressed: () async { - await showDefaultAlertDialog( - context, - context.localized.syncRemoveDataTitle, - context.localized.syncRemoveDataDesc, - (context) async { - await ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task); - Navigator.pop(context); - }, - context.localized.delete, - (context) => Navigator.pop(context), - context.localized.cancel, - ); - }, - icon: const Icon(IconsaxPlusLinear.trash), + if (!hasFile && downloadTask.hasDownload) + Flexible( + child: SyncProgressBar(item: syncedItem, task: downloadTask), + ) + else + Flexible( + child: SyncLabel( + label: + context.localized.totalSize(ref.watch(syncSizeProvider(syncedItem, [])).byteFormat ?? '--'), + status: ref.watch(syncDownloadStatusProvider(syncedItem, []) + .select((value) => value?.status ?? TaskStatus.notFound)), + ), + ), + ], + ), ), - ].addInBetween(const SizedBox(width: 16)), - ), - ); + if (!hasFile && !downloadTask.hasDownload) + SyncFileButton(syncedItem: syncedItem) + else if (hasFile) + IconButtonAwait( + color: Theme.of(context).colorScheme.error, + onPressed: () async { + await showDefaultAlertDialog( + context, + context.localized.syncRemoveDataTitle, + context.localized.syncRemoveDataDesc, + (context) async { + await ref.read(syncProvider.notifier).deleteFullSyncFiles(syncedItem, downloadTask.task); + Navigator.pop(context); + }, + context.localized.delete, + (context) => Navigator.pop(context), + context.localized.cancel, + ); + }, + icon: const Icon(IconsaxPlusLinear.trash), + ), + ].addInBetween(const SizedBox(width: 16)), + ), + ); + } + + return switch (syncedItem) { + AsyncData(:final asData) => buildWidget(asData?.value ?? widget.syncedItem), + _ => const SizedBox.shrink(), + }; } } diff --git a/lib/widgets/navigation_scaffold/navigation_scaffold.dart b/lib/widgets/navigation_scaffold/navigation_scaffold.dart index 21e8dde98..1768dda46 100644 --- a/lib/widgets/navigation_scaffold/navigation_scaffold.dart +++ b/lib/widgets/navigation_scaffold/navigation_scaffold.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide ConnectionState; @@ -93,7 +95,7 @@ class _NavigationScaffoldState extends ConsumerState { final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); - final offlineMessageHeight = isOffline && !isDesktop ? 12 : 0; + final offlineMessageHeight = isOffline && !isDesktop ? 18 : 0; final calculatedBottomViewPadding = showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding; @@ -175,6 +177,8 @@ class _NavigationScaffoldState extends ConsumerState { ) : const SizedBox.shrink(); + final offlineMessagePadding = max((kToolbarHeight), MediaQuery.of(context).padding.top) + offlineMessageHeight; + return PopScope( canPop: !showAudioOverlay && currentIndex == 0, onPopInvokedWithResult: (didPop, result) { @@ -193,7 +197,7 @@ class _NavigationScaffoldState extends ConsumerState { child: MediaQuery( data: mediaQuery.copyWith( padding: paddingOf.copyWith( - top: mediaQuery.padding.top + offlineMessageHeight, + top: offlineMessagePadding, bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding, ), viewPadding: viewPaddingOf.copyWith( @@ -244,7 +248,7 @@ class _NavigationScaffoldState extends ConsumerState { duration: const Duration(milliseconds: 250), opacity: isOffline ? 1 : 0, child: Container( - height: kToolbarHeight + offlineMessageHeight, + height: offlineMessagePadding, alignment: Alignment.bottomCenter, decoration: BoxDecoration( gradient: LinearGradient( @@ -256,9 +260,9 @@ class _NavigationScaffoldState extends ConsumerState { end: Alignment.bottomCenter, ), ), - child: const Padding( - padding: EdgeInsets.only(bottom: 8), - child: OfflineBanner(), + child: Padding( + padding: EdgeInsets.only(bottom: offlineMessageHeight / 2), + child: const OfflineBanner(), ), ), ), diff --git a/lib/widgets/shared/offline_banner.dart b/lib/widgets/shared/offline_banner.dart index aaa0cf411..8b6a5ed4c 100644 --- a/lib/widgets/shared/offline_banner.dart +++ b/lib/widgets/shared/offline_banner.dart @@ -22,7 +22,7 @@ class OfflineBanner extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - IconsaxPlusLinear.cloud_cross, + IconsaxPlusBold.cloud_cross, color: theme.colorScheme.onErrorContainer, size: 20, ), diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 5823dfc4b..c9372b07c 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -380,9 +380,15 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final isMusic = playBackItem is AudioModel; + final album = playBackItem is AudioModel ? playBackItem.album : null; + final artist = playBackItem is AudioModel ? playBackItem.artistModel?.name : null; + mediaItem.add(MediaItem( id: playBackItem.id, + album: album, + artist: artist, title: playBackItem.title, + genre: playBackItem.overview.genres.join(', '), rating: Rating.newHeartRating(playBackItem.userData.isFavourite), duration: playBackItem.overview.runTime ?? const Duration(seconds: 0), artUri: poster != null ? _imageDataToUri(poster.path) : null,