From c4bd40235f6841933e51f05e60d302c360c7bd36 Mon Sep 17 00:00:00 2001 From: Komodo <45665554+Komodo5197@users.noreply.github.com> Date: Sun, 19 May 2024 00:45:53 -0400 Subject: [PATCH] Add info syncing of favorites collection for better syncing of favorite info offline. Add info sync of all playlists item to allow showing partially downloaded playlists while offline. --- .../add_to_playlist_button.dart | 5 +- .../new_playlist_dialog.dart | 4 +- .../playlist_actions_menu.dart | 5 +- .../AlbumScreen/download_button.dart | 24 ++- .../AlbumScreen/download_dialog.dart | 28 ++- lib/components/AlbumScreen/song_menu.dart | 36 ++-- lib/components/MusicScreen/album_item.dart | 9 +- .../MusicScreen/music_screen_tab_view.dart | 9 +- .../player_screen_album_image.dart | 4 +- lib/components/album_list_tile.dart | 8 +- lib/components/favourite_button.dart | 5 +- lib/l10n/app_en.arb | 4 +- lib/models/finamp_models.dart | 53 +++-- lib/models/finamp_models.g.dart | 7 +- lib/models/jellyfin_models.dart | 10 +- lib/models/jellyfin_models.g.dart | 3 +- lib/screens/downloads_settings_screen.dart | 66 ++++-- lib/screens/music_screen.dart | 8 +- lib/services/downloads_service.dart | 80 ++++++- lib/services/downloads_service_backend.dart | 201 +++++++++--------- lib/services/favorite_provider.dart | 69 ++++-- lib/services/favorite_provider.g.dart | 45 +--- lib/services/jellyfin_api_helper.dart | 6 +- lib/services/queue_service.dart | 6 +- 24 files changed, 439 insertions(+), 256 deletions(-) diff --git a/lib/components/AddToPlaylistScreen/add_to_playlist_button.dart b/lib/components/AddToPlaylistScreen/add_to_playlist_button.dart index 86907f809..01f83d668 100644 --- a/lib/components/AddToPlaylistScreen/add_to_playlist_button.dart +++ b/lib/components/AddToPlaylistScreen/add_to_playlist_button.dart @@ -38,12 +38,11 @@ class _AddToPlaylistButtonState extends ConsumerState { return const SizedBox.shrink(); } - bool isFav = ref - .watch(isFavoriteProvider(widget.item?.id, DefaultValue(widget.item))); + bool isFav = ref.watch(isFavoriteProvider(FavoriteRequest(widget.item))); return GestureDetector( onLongPress: () async { ref - .read(isFavoriteProvider(widget.item?.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(widget.item)).notifier) .updateFavorite(!isFav); }, child: IconButton( diff --git a/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart b/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart index 3b8500470..e5ab5503e 100644 --- a/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart +++ b/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart @@ -91,9 +91,7 @@ class _NewPlaylistDialogState extends State { final downloadsService = GetIt.instance(); unawaited(downloadsService.resync( DownloadStub.fromFinampCollection( - collection: - FinampCollection(type: FinampCollectionType.allPlaylists), - name: null), + FinampCollection(type: FinampCollectionType.allPlaylists)), null, keepSlow: true)); return newId.id!; diff --git a/lib/components/AddToPlaylistScreen/playlist_actions_menu.dart b/lib/components/AddToPlaylistScreen/playlist_actions_menu.dart index e0401837a..b82d87ba5 100644 --- a/lib/components/AddToPlaylistScreen/playlist_actions_menu.dart +++ b/lib/components/AddToPlaylistScreen/playlist_actions_menu.dart @@ -53,7 +53,7 @@ Future showPlaylistActionsMenu({ Consumer( builder: (context, ref, child) { bool isFavorite = - ref.watch(isFavoriteProvider(item.id, DefaultValue(item))); + ref.watch(isFavoriteProvider(FavoriteRequest(item))); return ToggleableListTile( title: AppLocalizations.of(context)!.favourites, leading: AspectRatio( @@ -77,8 +77,7 @@ Future showPlaylistActionsMenu({ tapFeedback: false, onToggle: (bool currentState) async { return ref - .read( - isFavoriteProvider(item.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(item)).notifier) .updateFavorite(!isFavorite); }, enabled: !isOffline, diff --git a/lib/components/AlbumScreen/download_button.dart b/lib/components/AlbumScreen/download_button.dart index bcaa5c687..8723504b8 100644 --- a/lib/components/AlbumScreen/download_button.dart +++ b/lib/components/AlbumScreen/download_button.dart @@ -17,17 +17,27 @@ class DownloadButton extends ConsumerWidget { required this.item, this.children, this.isLibrary = false, + this.infoOnly = false, }); final DownloadStub item; final int? children; final bool isLibrary; + final bool infoOnly; @override Widget build(BuildContext context, WidgetRef ref) { final downloadsService = GetIt.instance(); - var status = - ref.watch(downloadsService.statusProvider((item, children))).value; + DownloadItemStatus? status; + if (infoOnly) { + status = (ref.watch(downloadsService.infoForAnchorProvider(item)).value ?? + false) + ? DownloadItemStatus.required + : DownloadItemStatus.notNeeded; + } else { + status = + ref.watch(downloadsService.statusProvider((item, children))).value; + } var isOffline = ref.watch(finampSettingsProvider .select((value) => value.valueOrNull?.isOffline)) ?? true; @@ -67,12 +77,13 @@ class DownloadButton extends ConsumerWidget { AppLocalizations.of(context)!.addButtonLabel, abortButtonText: MaterialLocalizations.of(context).cancelButtonLabel, - onConfirmed: () => - DownloadDialog.show(context, item, viewId), + onConfirmed: () => DownloadDialog.show( + context, item, viewId, + infoOnly: infoOnly), onAborted: () {}, )); } else { - await DownloadDialog.show(context, item, viewId); + await DownloadDialog.show(context, item, viewId, infoOnly: infoOnly); } }, tooltip: parentTooltip, @@ -95,7 +106,8 @@ class DownloadButton extends ConsumerWidget { AppLocalizations.of(context)!.deleteDownloadsAbortButtonText, onConfirmed: () async { try { - await downloadsService.deleteDownload(stub: item); + await downloadsService.deleteDownload( + stub: item, asInfo: infoOnly); GlobalSnackbar.message((scaffold) => AppLocalizations.of(scaffold)!.downloadsDeleted); } catch (error) { diff --git a/lib/components/AlbumScreen/download_dialog.dart b/lib/components/AlbumScreen/download_dialog.dart index 444c33340..d182cc783 100644 --- a/lib/components/AlbumScreen/download_dialog.dart +++ b/lib/components/AlbumScreen/download_dialog.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:file_sizes/file_sizes.dart'; import 'package:Finamp/models/jellyfin_models.dart'; +import 'package:file_sizes/file_sizes.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; @@ -21,6 +21,7 @@ class DownloadDialog extends StatefulWidget { required this.downloadLocationId, required this.needsTranscode, required this.children, + required this.infoOnly, }); final DownloadStub item; @@ -28,6 +29,7 @@ class DownloadDialog extends StatefulWidget { final String? downloadLocationId; final bool needsTranscode; final List? children; + final bool infoOnly; @override State createState() => _DownloadDialogState(); @@ -37,7 +39,8 @@ class DownloadDialog extends StatefulWidget { /// if transcode downloads is set to ask. If neither is needed, the /// download is initiated immediately with no dialog. static Future show( - BuildContext context, DownloadStub item, String? viewId) async { + BuildContext context, DownloadStub item, String? viewId, + {bool infoOnly = false}) async { if (viewId == null) { final finampUserHelper = GetIt.instance(); viewId = finampUserHelper.currentUser!.currentViewId; @@ -74,7 +77,11 @@ class DownloadDialog extends StatefulWidget { (scaffold) => AppLocalizations.of(scaffold)!.confirmDownloadStarted, isConfirmation: true); unawaited(downloadsService - .addDownload(stub: item, viewId: viewId!, transcodeProfile: profile) + .addDownload( + stub: item, + viewId: viewId!, + transcodeProfile: profile, + asInfo: infoOnly) // TODO only show the enqueued confirmation if the enqueuing took longer than ~10 seconds .then((value) => GlobalSnackbar.message( (scaffold) => AppLocalizations.of(scaffold)!.downloadsQueued))); @@ -93,11 +100,13 @@ class DownloadDialog extends StatefulWidget { await showDialog( context: context, builder: (context) => DownloadDialog._build( - item: item, - viewId: viewId!, - downloadLocationId: downloadLocation, - needsTranscode: needTranscode, - children: children), + item: item, + viewId: viewId!, + downloadLocationId: downloadLocation, + needsTranscode: needTranscode, + children: children, + infoOnly: infoOnly, + ), ); } } @@ -217,7 +226,8 @@ class _DownloadDialogState extends State { .addDownload( stub: widget.item, viewId: widget.viewId, - transcodeProfile: profile) + transcodeProfile: profile, + asInfo: widget.infoOnly) .onError( (error, stackTrace) => GlobalSnackbar.error(error)); diff --git a/lib/components/AlbumScreen/song_menu.dart b/lib/components/AlbumScreen/song_menu.dart index d0d158362..31b212cef 100644 --- a/lib/components/AlbumScreen/song_menu.dart +++ b/lib/components/AlbumScreen/song_menu.dart @@ -215,7 +215,8 @@ class _SongMenuState extends ConsumerState { null); var iconColor = Theme.of(context).colorScheme.primary; - final isInCurrentPlaylist = widget.isInPlaylist && widget.parentItem != null; + final isInCurrentPlaylist = + widget.isInPlaylist && widget.parentItem != null; final currentTrack = _queueService.getCurrentTrack(); FinampQueueItem? queueItem; @@ -243,7 +244,9 @@ class _SongMenuState extends ConsumerState { Icons.playlist_add, color: iconColor, ), - title: Text(isInCurrentPlaylist ? AppLocalizations.of(context)!.addToMorePlaylistsTitle : AppLocalizations.of(context)!.addToPlaylistTitle), + title: Text(isInCurrentPlaylist + ? AppLocalizations.of(context)!.addToMorePlaylistsTitle + : AppLocalizations.of(context)!.addToPlaylistTitle), enabled: !widget.isOffline, onTap: () { Navigator.pop(context); // close menu @@ -443,8 +446,8 @@ class _SongMenuState extends ConsumerState { ), Consumer( builder: (context, ref, child) { - bool isFav = ref.watch( - isFavoriteProvider(widget.item.id, DefaultValue(widget.item))); + bool isFav = + ref.watch(isFavoriteProvider(FavoriteRequest(widget.item))); return ListTile( enabled: !widget.isOffline, leading: isFav @@ -465,8 +468,8 @@ class _SongMenuState extends ConsumerState { : AppLocalizations.of(context)!.addFavourite), onTap: () async { ref - .read(isFavoriteProvider(widget.item.id, DefaultValue()) - .notifier) + .read( + isFavoriteProvider(FavoriteRequest(widget.item)).notifier) .updateFavorite(!isFav); if (context.mounted) Navigator.pop(context); }, @@ -787,7 +790,7 @@ class SongInfo extends ConsumerStatefulWidget { final BaseItemDto item; final bool useThemeImage; - final bool condensed; + final bool condensed; @override ConsumerState createState() => _SongInfoState(); @@ -800,7 +803,8 @@ class _SongInfoState extends ConsumerState { color: Colors.transparent, child: Center( child: Container( - margin: EdgeInsets.symmetric(horizontal: widget.condensed ? 28.0 : 12.0), + margin: + EdgeInsets.symmetric(horizontal: widget.condensed ? 28.0 : 12.0), height: widget.condensed ? 80 : 120, clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( @@ -848,7 +852,9 @@ class _SongInfoState extends ConsumerState { maxLines: 2, ), Padding( - padding: widget.condensed ? const EdgeInsets.only(top: 6.0) : const EdgeInsets.symmetric(vertical: 4.0), + padding: widget.condensed + ? const EdgeInsets.only(top: 6.0) + : const EdgeInsets.symmetric(vertical: 4.0), child: ArtistChips( baseItem: widget.item, backgroundColor: IconTheme.of(context) @@ -864,12 +870,14 @@ class _SongInfoState extends ConsumerState { if (!widget.condensed) AlbumChip( item: widget.item, - color: Theme.of(context).textTheme.bodyMedium?.color ?? - Colors.white, - backgroundColor: - IconTheme.of(context).color?.withOpacity(0.1) ?? - Theme.of(context).textTheme.bodyMedium?.color ?? + color: + Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white, + backgroundColor: IconTheme.of(context) + .color + ?.withOpacity(0.1) ?? + Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, key: widget.item.album == null ? null : ValueKey("${widget.item.album}-album"), diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index 33fe8d089..dcb464b74 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -159,8 +159,7 @@ class _AlbumItemState extends ConsumerState { screenSize.height - globalPosition.dy, ), items: [ - ref.watch(isFavoriteProvider( - mutableAlbum.id, DefaultValue(mutableAlbum))) + ref.watch(isFavoriteProvider(FavoriteRequest(mutableAlbum))) ? PopupMenuItem<_AlbumListTileMenuItems>( enabled: !isOffline, value: _AlbumListTileMenuItems.removeFavourite, @@ -283,14 +282,12 @@ class _AlbumItemState extends ConsumerState { switch (selection) { case _AlbumListTileMenuItems.addFavourite: ref - .read( - isFavoriteProvider(mutableAlbum.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(mutableAlbum)).notifier) .updateFavorite(true); break; case _AlbumListTileMenuItems.removeFavourite: ref - .read( - isFavoriteProvider(mutableAlbum.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(mutableAlbum)).notifier) .updateFavorite(false); break; case _AlbumListTileMenuItems.addToMixList: diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index e42e56950..0ed6ae774 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -138,7 +138,9 @@ class _MusicScreenTabViewState extends State offlineItems = await _isarDownloader.getAllSongs( nameFilter: widget.searchTerm, viewFilter: widget.view?.id, - nullableViewFilters: settings.showDownloadsWithUnknownLibrary); + nullableViewFilters: settings.showDownloadsWithUnknownLibrary, + onlyFavorites: + settings.onlyShowFavourite && settings.trackOfflineFavorites); } else { offlineItems = await _isarDownloader.getAllCollections( nameFilter: widget.searchTerm, @@ -152,7 +154,9 @@ class _MusicScreenTabViewState extends State ? widget.view?.id : null, nullableViewFilters: widget.tabContentType == TabContentType.albums && - settings.showDownloadsWithUnknownLibrary); + settings.showDownloadsWithUnknownLibrary, + onlyFavorites: + settings.onlyShowFavourite && settings.trackOfflineFavorites); } var items = offlineItems.map((e) => e.baseItem).whereNotNull().toList(); @@ -301,6 +305,7 @@ class _MusicScreenTabViewState extends State widget.view?.id, settings.isOffline, settings.tabOrder.indexOf(widget.tabContentType), + settings.trackOfflineFavorites, ); if (refreshHash == null) { refreshHash = newRefreshHash; diff --git a/lib/components/PlayerScreen/player_screen_album_image.dart b/lib/components/PlayerScreen/player_screen_album_image.dart index 3398f6c9e..b0be9ef88 100644 --- a/lib/components/PlayerScreen/player_screen_album_image.dart +++ b/lib/components/PlayerScreen/player_screen_album_image.dart @@ -61,8 +61,8 @@ class PlayerScreenAlbumImage extends ConsumerWidget { if (currentTrack?.baseItem != null && !FinampSettingsHelper.finampSettings.isOffline) { ref - .read(isFavoriteProvider(currentTrack!.baseItem!.id, - DefaultValue(currentTrack.baseItem)) + .read(isFavoriteProvider( + FavoriteRequest(currentTrack!.baseItem)) .notifier) .toggleFavorite(); } diff --git a/lib/components/album_list_tile.dart b/lib/components/album_list_tile.dart index f98dfb7f5..ec67a367d 100644 --- a/lib/components/album_list_tile.dart +++ b/lib/components/album_list_tile.dart @@ -88,8 +88,8 @@ class _AlbumListTileState extends ConsumerState { type: DownloadItemType.collection, item: widget.item), null) .isRequired; - final bool isFav = ref - .watch(isFavoriteProvider(widget.item.id, DefaultValue(widget.item))); + final bool isFav = + ref.watch(isFavoriteProvider(FavoriteRequest(widget.item))); final selection = await showMenu( context: context, @@ -216,12 +216,12 @@ class _AlbumListTileState extends ConsumerState { switch (selection) { case AlbumListTileMenuItems.addFavourite: ref - .read(isFavoriteProvider(widget.item.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(widget.item)).notifier) .updateFavorite(true); break; case AlbumListTileMenuItems.removeFavourite: ref - .read(isFavoriteProvider(widget.item.id, DefaultValue()).notifier) + .read(isFavoriteProvider(FavoriteRequest(widget.item)).notifier) .updateFavorite(false); break; case AlbumListTileMenuItems.addToMixList: diff --git a/lib/components/favourite_button.dart b/lib/components/favourite_button.dart index bdb499758..47e1c05a9 100644 --- a/lib/components/favourite_button.dart +++ b/lib/components/favourite_button.dart @@ -37,8 +37,7 @@ class _FavoriteButtonState extends ConsumerState { return const SizedBox.shrink(); } - bool isFav = ref - .watch(isFavoriteProvider(widget.item?.id, DefaultValue(widget.item))); + bool isFav = ref.watch(isFavoriteProvider(FavoriteRequest(widget.item))); if (widget.onlyIfFav) { if (isFav && !FinampSettingsHelper.finampSettings.onlyShowFavourite) { return Icon( @@ -65,7 +64,7 @@ class _FavoriteButtonState extends ConsumerState { ? null : () { ref - .read(isFavoriteProvider(widget.item?.id, DefaultValue()) + .read(isFavoriteProvider(FavoriteRequest(widget.item)) .notifier) .updateFavorite(!isFav); }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 954c34b0e..3b88c66d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1502,5 +1502,7 @@ "addPlaylistSubheader": "Add track to a playlist", "@addPlaylistSubheader": { "description": "Subheader for adding to a playlist in the add to/remove from playlist popup menu" - } + }, + "trackOfflineFavorites": "Sync all favorite statuses", + "trackOfflineFavoritesSubtitle": "This allows showing more up-to-date favorite statuses while offline. Does not download any additional files." } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index f76b55eba..debb8d3d8 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:Finamp/components/global_snackbar.dart'; import 'package:audio_service/audio_service.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; @@ -101,6 +102,7 @@ const _hideQueueButton = false; const _reportQueueToServerDefault = false; const _periodicPlaybackSessionUpdateFrequencySecondsDefault = 150; const _showArtistChipImage = true; +const _trackOfflineFavoritesDefault = true; @HiveType(typeId: 28) class FinampSettings { @@ -171,6 +173,7 @@ class FinampSettings { this.periodicPlaybackSessionUpdateFrequencySeconds = _periodicPlaybackSessionUpdateFrequencySecondsDefault, this.showArtistChipImage = _showArtistChipImage, + this.trackOfflineFavorites = _trackOfflineFavoritesDefault, }); @HiveField(0, defaultValue: _isOfflineDefault) @@ -375,6 +378,9 @@ class FinampSettings { @HiveField(62, defaultValue: _defaultSplitScreenPlayerWidth) double splitScreenPlayerWidth; + @HiveField(63, defaultValue: _trackOfflineFavoritesDefault) + bool trackOfflineFavorites; + static Future create() async { final downloadLocation = await DownloadLocation.create( name: "Internal Storage", @@ -860,9 +866,15 @@ class DownloadStub { baseItemType: BaseItemDtoType.unknown); } - factory DownloadStub.fromFinampCollection( - {required FinampCollection collection, required String? name}) { + factory DownloadStub.fromFinampCollection(FinampCollection collection) { String id = collection.id; + // Fetch localized name from default global context. + String? name; + var context = GlobalSnackbar.materialAppScaffoldKey.currentContext; + if (context != null) { + name = collection.getName(context); + } + return DownloadStub._build( id: id, isarId: getHash(id, DownloadItemType.finampCollection), @@ -1209,20 +1221,24 @@ enum DownloadItemStatus { /// The type of a BaseItemDto as determined from its type field. /// Enumerated by Isar, do not modify order or delete existing entries enum BaseItemDtoType { - unknown(null, false), - album("MusicAlbum", false), - artist("MusicArtist", true), - playlist("Playlist", true), - genre("MusicGenre", true), - song("Audio", false), - library("CollectionFolder", true), - folder("Folder", false), - musicVideo("MusicVideo", false); - - const BaseItemDtoType(this.idString, this.expectChanges); + unknown(null, true, null), + album("MusicAlbum", false, [song]), + artist("MusicArtist", true, [album, song]), + playlist("Playlist", true, [song]), + genre("MusicGenre", true, [album, song]), + song("Audio", false, []), + library("CollectionFolder", true, [album, song]), + folder("Folder", true, null), + musicVideo("MusicVideo", false, []); + + const BaseItemDtoType(this.idString, this.expectChanges, this.childTypes); final String? idString; final bool expectChanges; + final List? childTypes; + + bool get expectChangesInChildren => + childTypes?.any((x) => x.expectChanges) ?? true; static BaseItemDtoType fromItem(BaseItemDto item) { switch (item.type) { @@ -1915,6 +1931,17 @@ class FinampCollection { "Cache Library Images:${library!.id}", }; + String getName(BuildContext context) => switch (type) { + FinampCollectionType.favorites => + AppLocalizations.of(context)!.finampCollectionNames("favorites"), + FinampCollectionType.allPlaylists => + AppLocalizations.of(context)!.finampCollectionNames("allPlaylists"), + FinampCollectionType.latest5Albums => AppLocalizations.of(context)! + .finampCollectionNames("fiveLatestAlbums"), + FinampCollectionType.libraryImages => AppLocalizations.of(context)! + .cacheLibraryImagesName(library!.name ?? ""), + }; + factory FinampCollection.fromJson(Map json) => _$FinampCollectionFromJson(json); Map toJson() => _$FinampCollectionToJson(this); diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 9d1889e59..be4402b85 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -162,6 +162,7 @@ class FinampSettingsAdapter extends TypeAdapter { periodicPlaybackSessionUpdateFrequencySeconds: fields[53] == null ? 150 : fields[53] as int, showArtistChipImage: fields[55] == null ? true : fields[55] as bool, + trackOfflineFavorites: fields[63] == null ? true : fields[63] as bool, ) ..disableGesture = fields[19] == null ? false : fields[19] as bool ..showFastScroller = fields[25] == null ? true : fields[25] as bool @@ -171,7 +172,7 @@ class FinampSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(61) + ..writeByte(62) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -293,7 +294,9 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(61) ..write(obj.allowSplitScreen) ..writeByte(62) - ..write(obj.splitScreenPlayerWidth); + ..write(obj.splitScreenPlayerWidth) + ..writeByte(63) + ..write(obj.trackOfflineFavorites); } @override diff --git a/lib/models/jellyfin_models.dart b/lib/models/jellyfin_models.dart index 1f29fa29d..77b2b6e9c 100644 --- a/lib/models/jellyfin_models.dart +++ b/lib/models/jellyfin_models.dart @@ -2199,6 +2199,8 @@ class BaseItemDto with RunTimeTickDuration { @HiveField(151) double? normalizationGain; + bool? finampOffline; + /// Checks if the item has its own image (not inherited from a parent) bool get hasOwnImage => imageTags?.containsKey("Primary") ?? false; @@ -2268,7 +2270,13 @@ class BaseItemDto with RunTimeTickDuration { factory BaseItemDto.fromJson(Map json) => _$BaseItemDtoFromJson(json); - Map toJson() => _$BaseItemDtoToJson(this); + Map toJson({bool setOffline = true}) { + var json = _$BaseItemDtoToJson(this); + if (setOffline) { + json["FinampOffline"] = true; + } + return json; + } bool mostlyEqual(BaseItemDto other) { var equal = const DeepCollectionEquality().equals; diff --git a/lib/models/jellyfin_models.g.dart b/lib/models/jellyfin_models.g.dart index 477cb2f0b..56d3fb9a3 100644 --- a/lib/models/jellyfin_models.g.dart +++ b/lib/models/jellyfin_models.g.dart @@ -4004,7 +4004,7 @@ BaseItemDto _$BaseItemDtoFromJson(Map json) => BaseItemDto( channelType: json['ChannelType'] as String?, audio: json['Audio'] as String?, normalizationGain: (json['NormalizationGain'] as num?)?.toDouble(), - ); + )..finampOffline = json['FinampOffline'] as bool?; Map _$BaseItemDtoToJson(BaseItemDto instance) { final val = {}; @@ -4175,6 +4175,7 @@ Map _$BaseItemDtoToJson(BaseItemDto instance) { writeNotNull('ChannelType', instance.channelType); writeNotNull('Audio', instance.audio); writeNotNull('NormalizationGain', instance.normalizationGain); + writeNotNull('FinampOffline', instance.finampOffline); return val; } diff --git a/lib/screens/downloads_settings_screen.dart b/lib/screens/downloads_settings_screen.dart index d5ce56cc2..d60cead12 100644 --- a/lib/screens/downloads_settings_screen.dart +++ b/lib/screens/downloads_settings_screen.dart @@ -37,6 +37,17 @@ class DownloadsSettingsScreen extends StatelessWidget { ), if (Platform.isIOS || Platform.isAndroid) const RequireWifiSwitch(), const ShowPlaylistSongsSwitch(), + const SyncFavoritesSwitch(), + ListTile( + // TODO real UI for this + title: const Text("Show all playlists offline"), + subtitle: const Text( + "Sync metadata for all playlists to show partially downloaded playlists offline"), + trailing: DownloadButton( + infoOnly: true, + item: DownloadStub.fromFinampCollection( + FinampCollection(type: FinampCollectionType.allPlaylists))), + ), // Do not limit enqueued downloads on IOS, it throttles them like crazy on its own. if (!Platform.isIOS) const ConcurentDownloadsSelector(), ListTile( @@ -44,20 +55,14 @@ class DownloadsSettingsScreen extends StatelessWidget { title: const Text("Download all favorites"), trailing: DownloadButton( item: DownloadStub.fromFinampCollection( - collection: - FinampCollection(type: FinampCollectionType.favorites), - name: AppLocalizations.of(context)! - .finampCollectionNames("favorites"))), + FinampCollection(type: FinampCollectionType.favorites))), ), ListTile( // TODO real UI for this title: const Text("Download all playlists"), trailing: DownloadButton( item: DownloadStub.fromFinampCollection( - collection: FinampCollection( - type: FinampCollectionType.allPlaylists), - name: AppLocalizations.of(context)! - .finampCollectionNames("allPlaylists"))), + FinampCollection(type: FinampCollectionType.allPlaylists))), ), ListTile( // TODO real UI for this @@ -65,11 +70,8 @@ class DownloadsSettingsScreen extends StatelessWidget { subtitle: const Text( "Downloads will be removed as they age out. Lock the download to prevent an album from being removed."), trailing: DownloadButton( - item: DownloadStub.fromFinampCollection( - collection: FinampCollection( - type: FinampCollectionType.latest5Albums), - name: AppLocalizations.of(context)! - .finampCollectionNames("fiveLatestAlbums"))), + item: DownloadStub.fromFinampCollection(FinampCollection( + type: FinampCollectionType.latest5Albums))), ), ListTile( // TODO real UI for this @@ -77,12 +79,9 @@ class DownloadsSettingsScreen extends StatelessWidget { subtitle: const Text( "All album, artist, genre, and playlist covers in the currently active library will be downloaded."), trailing: DownloadButton( - item: DownloadStub.fromFinampCollection( - collection: FinampCollection( - type: FinampCollectionType.libraryImages, - library: userHelper.currentUser!.currentView!), - name: AppLocalizations.of(context)!.cacheLibraryImagesName( - userHelper.currentUser!.currentView!.name ?? ""))), + item: DownloadStub.fromFinampCollection(FinampCollection( + type: FinampCollectionType.libraryImages, + library: userHelper.currentUser!.currentView!))), ), const SyncOnStartupSwitch(), const PreferQuickSyncsSwitch(), @@ -121,6 +120,35 @@ class RequireWifiSwitch extends StatelessWidget { } } +class SyncFavoritesSwitch extends StatelessWidget { + const SyncFavoritesSwitch({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + bool? syncFavorites = box.get("FinampSettings")?.trackOfflineFavorites; + + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.trackOfflineFavorites), + subtitle: + Text(AppLocalizations.of(context)!.trackOfflineFavoritesSubtitle), + value: syncFavorites ?? false, + onChanged: syncFavorites == null + ? null + : (value) { + FinampSettings finampSettingsTemp = + box.get("FinampSettings")!; + finampSettingsTemp.trackOfflineFavorites = value; + box.put("FinampSettings", finampSettingsTemp); + }, + ); + }, + ); + } +} + class ShowPlaylistSongsSwitch extends StatelessWidget { const ShowPlaylistSongsSwitch({super.key}); diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 425a50ddb..3d4c6ce5a 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -287,14 +287,14 @@ class _MusicScreenState extends ConsumerState tooltip: AppLocalizations.of(context)! .onlyShowFullyDownloaded, ), - if (!finampSettings.isOffline) + if (!finampSettings.isOffline || + finampSettings.trackOfflineFavorites) IconButton( icon: finampSettings.onlyShowFavourite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_outline), - onPressed: finampSettings.isOffline - ? null - : () => FinampSettingsHelper.setOnlyShowFavourite( + onPressed: () => + FinampSettingsHelper.setOnlyShowFavourite( !finampSettings.onlyShowFavourite), tooltip: AppLocalizations.of(context)!.favourites, ), diff --git a/lib/services/downloads_service.dart b/lib/services/downloads_service.dart index 7f3e991ae..0edb9b931 100644 --- a/lib/services/downloads_service.dart +++ b/lib/services/downloads_service.dart @@ -106,6 +106,25 @@ class DownloadsService { .distinct(); }); + late final infoForAnchorProvider = + StreamProvider.family.autoDispose((ref, stub) { + assert(stub.type != DownloadItemType.image && + stub.type != DownloadItemType.anchor); + // Refresh on addDownload/removeDownload as well as state change + ref.watch(_anchorProvider); + return _isar.downloadItems + .watchObjectLazy(stub.isarId, fireImmediately: true) + .map((event) { + return _isar.downloadItems + .where() + .isarIdEqualTo(_anchor.isarId) + .filter() + .info((q) => q.isarIdEqualTo(stub.isarId)) + .countSync() > + 0; + }).distinct(); + }); + /// Provider for the download status of an item. See [getStatus] for details. /// This provider relies on the fact that [_syncDownload] always re-inserts /// processed items into Isar to know when to re-check status. @@ -419,6 +438,7 @@ class DownloadsService { required DownloadStub stub, required String viewId, required DownloadProfile transcodeProfile, + bool asInfo = false, }) async { // Comment https://github.com/jmshrv/finamp/issues/134#issuecomment-1563441355 // suggests this does not make a request and always returns failure @@ -437,7 +457,11 @@ class DownloadsService { var anchorItem = _anchor.asItem(null); // This may be the first download ever, so the anchor might not be present _isar.downloadItems.putSync(anchorItem); - anchorItem.requires.updateSync(link: [canonItem]); + if (asInfo) { + anchorItem.info.updateSync(link: [canonItem]); + } else { + anchorItem.requires.updateSync(link: [canonItem]); + } // Update download location id/transcode profile for all our children syncItemDownloadSettings(canonItem); }); @@ -448,7 +472,8 @@ class DownloadsService { /// Removes the anchor link to an item and sync deletes it. This will allow the /// item to be deleted but may not result in deletion actually occurring as the /// item may be required by other collections. - Future deleteDownload({required DownloadStub stub}) async { + Future deleteDownload( + {required DownloadStub stub, bool asInfo = false}) async { DownloadItem? canonItem; _isar.writeTxnSync(() { var anchorItem = _anchor.asItem(null); @@ -462,7 +487,11 @@ class DownloadsService { _isar.downloadItems.putSync(anchorItem); deleteBuffer.addAll([stub.isarId]); // Actual item is not required for updating links - anchorItem.requires.updateSync(unlink: [canonItem!]); + if (asInfo) { + anchorItem.info.updateSync(unlink: [canonItem!]); + } else { + anchorItem.requires.updateSync(unlink: [canonItem!]); + } canonItem!.userTranscodingProfile = null; _isar.downloadItems.putSync(canonItem!); }); @@ -617,10 +646,11 @@ class DownloadsService { .typeEqualTo(DownloadItemType.image))), // Select nodes which only info link images or have no info links (q) => q.not().info((q) => q.not().typeEqualTo(DownloadItemType.image)), + (q) => q.typeEqualTo(DownloadItemType.anchor), ]; // albums/playlist can require info on songs. required songs can require info on - // collections. Anyone can require info on an image. - // All other info links are disallowed. + // collections. Anyone can require info on an image. The anchor can info link + // anyone. All other info links are disallowed. var badInfoItems = _isar.downloadItems .filter() .not() @@ -1231,8 +1261,6 @@ class DownloadsService { {bool playable = true}) async { var stub = DownloadStub.fromItem(type: DownloadItemType.collection, item: item); - assert(stub.baseItemType == BaseItemDtoType.playlist || - stub.baseItemType == BaseItemDtoType.album); var id = DownloadStub.getHash(item.id, DownloadItemType.collection); var query = _isar.downloadItems @@ -1275,7 +1303,12 @@ class DownloadsService { {String? nameFilter, BaseItemDto? relatedTo, String? viewFilter, - bool nullableViewFilters = true}) { + bool nullableViewFilters = true, + bool onlyFavorites = false}) { + List favoriteIds = []; + if (onlyFavorites) { + favoriteIds = _getFavoriteIds() ?? []; + } return _isar.downloadItems .where() .typeEqualTo(DownloadItemType.song) @@ -1284,6 +1317,8 @@ class DownloadsService { .stateEqualTo(DownloadItemState.complete) .or() .stateEqualTo(DownloadItemState.needsRedownloadComplete)) + .optional(onlyFavorites, + (q) => q.anyOf(favoriteIds, (q, v) => q.isarIdEqualTo(v))) .optional(nameFilter != null, (q) => q.nameContains(nameFilter!, caseSensitive: false)) .optional( @@ -1308,6 +1343,7 @@ class DownloadsService { /// + viewFilter - only return collections in the given library. /// + childViewFilter - only return collections with children in the given library. /// Useful for artists/genres, which may need to be shown in several libraries. + /// + onlyFavorites - return only favorite items Future> getAllCollections( {String? nameFilter, BaseItemDtoType? baseTypeFilter, @@ -1315,7 +1351,12 @@ class DownloadsService { bool fullyDownloaded = false, String? viewFilter, String? childViewFilter, - bool nullableViewFilters = true}) { + bool nullableViewFilters = true, + bool onlyFavorites = false}) { + List favoriteIds = []; + if (onlyFavorites && baseTypeFilter != BaseItemDtoType.genre) { + favoriteIds = _getFavoriteIds() ?? []; + } return _isar.downloadItems .where() .typeEqualTo(DownloadItemType.collection) @@ -1324,6 +1365,10 @@ class DownloadsService { (q) => q.nameContains(nameFilter!, caseSensitive: false)) .optional(baseTypeFilter != null, (q) => q.baseItemTypeEqualTo(baseTypeFilter!)) + // If allPlaylists is info downloaded, we may have info for empty + // playlists. These should not be returned. + .optional(baseTypeFilter == BaseItemDtoType.playlist, + (q) => q.info((q) => q.requiredByIsNotEmpty())) .optional( relatedTo != null, (q) => q.infoFor((q) => q.info((q) => q.isarIdEqualTo( @@ -1331,6 +1376,8 @@ class DownloadsService { relatedTo!.id, DownloadItemType.collection))))) .optional(fullyDownloaded, (q) => q.not().stateEqualTo(DownloadItemState.notDownloaded)) + .optional(onlyFavorites, + (q) => q.anyOf(favoriteIds, (q, v) => q.isarIdEqualTo(v))) .optional( viewFilter != null, (q) => q.group((q) => q.viewIdEqualTo(viewFilter).optional( @@ -1390,6 +1437,21 @@ class DownloadsService { return item; } + bool? isFavorite(BaseItemDto item) { + var stubId = DownloadStub.getHash( + item.id, + item.type == "Audio" + ? DownloadItemType.song + : DownloadItemType.collection); + return _getFavoriteIds()?.contains(stubId); + } + + List? _getFavoriteIds() { + var stub = DownloadStub.fromFinampCollection( + FinampCollection(type: FinampCollectionType.favorites)); + return _isar.downloadItems.getSync(stub.isarId)?.orderedChildren ?? []; + } + /// Get a downloadItem with verified files by id. DownloadItem? _getDownloadByID(String id, DownloadItemType type) { assert(type.hasFiles); diff --git a/lib/services/downloads_service_backend.dart b/lib/services/downloads_service_backend.dart index 691db290b..6fee1ea3b 100644 --- a/lib/services/downloads_service_backend.dart +++ b/lib/services/downloads_service_backend.dart @@ -902,10 +902,7 @@ class DownloadsSyncService { // Skip items that are unlikely to need syncing if allowed. if (FinampSettingsHelper.finampSettings.preferQuickSyncs && !_downloadsService.forceFullSync) { - if (parent.type == DownloadItemType.song || - parent.type == DownloadItemType.image || - (parent.type == DownloadItemType.collection && - parent.baseItemType == BaseItemDtoType.album)) { + if (parent.type.requiresItem && !parent.baseItemType.expectChanges) { isarParent = _isar.downloadItems.getSync(parent.isarId); if (isarParent?.state == DownloadItemState.complete) { _syncLogger.finest("Skipping sync of ${parent.name}"); @@ -945,7 +942,7 @@ class DownloadsSyncService { // // Calculate needed children for item based on type and asRequired flag // - bool updateChildren = true; + bool updateRequiredChildren = true; Set requiredChildren = {}; Set infoChildren = {}; List? orderedChildItems; @@ -1017,12 +1014,37 @@ class DownloadsSyncService { .requiredBy((q) => q.isarIdEqualTo(parent.isarId)) .findAllSync(); requiredChildren.addAll(children); - updateChildren = false; + var infoItems = _isar.downloadItems + .filter() + .infoFor((q) => q.isarIdEqualTo(parent.isarId)) + .findAllSync(); + // If trackOfflineFavorites is set and we have downloads, add an info link + // to the favorites collection, otherwise remove it. + var favorites = FinampCollection(type: FinampCollectionType.favorites); + infoItems.removeWhere((item) => item.id == favorites.id); + infoChildren.addAll(infoItems); + if (children.isNotEmpty && + FinampSettingsHelper.finampSettings.trackOfflineFavorites) { + infoChildren.add(DownloadStub.fromFinampCollection( + FinampCollection(type: FinampCollectionType.favorites))); + } + infoChildren.addAll(infoItems); + updateRequiredChildren = false; case DownloadItemType.finampCollection: try { if (asRequired) { orderedChildItems = await _getFinampCollectionChildren(parent); requiredChildren.addAll(orderedChildItems); + } else { + switch (parent.finampCollection!.type) { + case FinampCollectionType.allPlaylists: + orderedChildItems = await _getFinampCollectionChildren(parent); + infoChildren.addAll(orderedChildItems); + case FinampCollectionType.favorites: + orderedChildItems = await _getFinampCollectionChildren(parent); + case var type: + throw "FinampCollection of type $type was info linked, cannot handle"; + } } } catch (e) { _syncLogger.info( @@ -1039,102 +1061,91 @@ class DownloadsSyncService { // once network requests come back. await SchedulerBinding.instance.scheduleTask(() async { DownloadItem? canonParent; - if (updateChildren) { - _isar.writeTxnSync(() { - canonParent = _isar.downloadItems.getSync(parent.isarId); - if (canonParent == null) { - throw StateError( - "_syncDownload called on missing node ${parent.id}"); - } - try { - var newParent = canonParent!.copyWith( - item: newBaseItem, - viewId: viewId, - orderedChildItems: orderedChildItems, - forceCopy: _downloadsService.forceFullSync); - // copyWith returns null if no updates to important fields are needed - if (newParent != null) { - _isar.downloadItems.putSync(newParent); - canonParent = newParent; - } - } catch (e) { - _syncLogger.warning(e); - } - - viewId ??= canonParent!.viewId; - - // Run appropriate _updateChildren calls and store changes to allow skipping - // unneeded syncs when nothing changed - // _updatechildren output is inserted nodes, linked nodes, unlinked nodes - (Set, Set, Set) requiredChanges = ({}, {}, {}); - (Set, Set, Set) infoChanges = ({}, {}, {}); - if (asRequired) { - requiredChanges = - _updateChildren(canonParent!, true, requiredChildren); - infoChanges = _updateChildren(canonParent!, false, infoChildren); - } else if (canonParent!.type == DownloadItemType.song) { - // For info only songs, we put image link into required so that we can delete - // all info links in _syncDelete, so if not processing as required only - // update that and ignore info links - requiredChanges = - _updateChildren(canonParent!, true, requiredChildren); - } else { - infoChanges = _updateChildren(canonParent!, false, infoChildren); + _isar.writeTxnSync(() { + canonParent = _isar.downloadItems.getSync(parent.isarId); + if (canonParent == null) { + throw StateError("_syncDownload called on missing node ${parent.id}"); + } + try { + var newParent = canonParent!.copyWith( + item: newBaseItem, + viewId: viewId, + orderedChildItems: orderedChildItems, + forceCopy: _downloadsService.forceFullSync); + // copyWith returns null if no updates to important fields are needed + if (newParent != null) { + _isar.downloadItems.putSync(newParent); + canonParent = newParent; } + } catch (e) { + _syncLogger.warning(e); + } - if (FinampSettingsHelper.finampSettings.preferQuickSyncs && - !_downloadsService.forceFullSync && - canonParent!.type == DownloadItemType.collection && - canonParent!.baseItemType == BaseItemDtoType.playlist && - canonParent!.state == DownloadItemState.complete) { - // When quicksyncing, unchanged songs in playlists do not need to be resynced. - // Songs we just linked may need download settings updated - var quicksyncRequiredIds = - requiredChanges.$1.union(requiredChanges.$2); - var quicksyncInfoIds = infoChanges.$1.union(infoChanges.$2); - addAll(quicksyncRequiredIds, - quicksyncInfoIds.difference(quicksyncRequiredIds), viewId); - } else { - addAll( - requiredChildren.map((e) => e.isarId), - infoChildren.difference(requiredChildren).map((e) => e.isarId), - viewId); - } - // If we are a collection, move out of syncFailed because we just completed a - // successful sync. songs/images will be moved out by _initiateDownload. - // If our linked children just changed, recalculate state with new children. - if (!canonParent!.type.hasFiles && - (canonParent!.state == DownloadItemState.syncFailed || - requiredChanges.$1.isNotEmpty || - requiredChanges.$2.isNotEmpty || - requiredChanges.$3.isNotEmpty || - infoChanges.$1.isNotEmpty || - infoChanges.$2.isNotEmpty || - infoChanges.$3.isNotEmpty)) { - _downloadsService.syncItemState(canonParent!, - removeSyncFailed: true); - } + viewId ??= canonParent!.viewId; + + // Run appropriate _updateChildren calls and store changes to allow skipping + // unneeded syncs when nothing changed + // _updatechildren output is inserted nodes, linked nodes, unlinked nodes + (Set, Set, Set) requiredChanges = ({}, {}, {}); + (Set, Set, Set) infoChanges = ({}, {}, {}); + if (asRequired && updateRequiredChildren) { + requiredChanges = + _updateChildren(canonParent!, true, requiredChildren); + infoChanges = _updateChildren(canonParent!, false, infoChildren); + } else if (canonParent!.type == DownloadItemType.song) { + // For info only songs, we put image link into required so that we can delete + // all info links in _syncDelete, so if not processing as required only + // update that and ignore info links + requiredChanges = + _updateChildren(canonParent!, true, requiredChildren); + } else { + infoChanges = _updateChildren(canonParent!, false, infoChildren); + } - // sync download settings on all newly required children. Newly inserted children - // may be skipped, as they inherit the parent settings. Children who exactly match - // the parent's download settings already may be skipped. - for (var child in _isar.downloadItems - .getAllSync(requiredChanges.$2.toList()) - .whereNotNull()) { - if (child.syncTranscodingProfile != - canonParent!.syncTranscodingProfile) { - _downloadsService.syncItemDownloadSettings(child); - } - } - }); - } else { - _isar.writeTxnSync(() { + if (FinampSettingsHelper.finampSettings.preferQuickSyncs && + !_downloadsService.forceFullSync && + canonParent!.type == DownloadItemType.collection && + !canonParent!.baseItemType.expectChangesInChildren && + canonParent!.state == DownloadItemState.complete) { + // When quicksyncing, unchanged songs/albums do not need to be resynced. + // Items we just linked may need download settings updated. + var quicksyncRequiredIds = + requiredChanges.$1.union(requiredChanges.$2); + var quicksyncInfoIds = infoChanges.$1.union(infoChanges.$2); + addAll(quicksyncRequiredIds, + quicksyncInfoIds.difference(quicksyncRequiredIds), viewId); + } else { addAll( requiredChildren.map((e) => e.isarId), infoChildren.difference(requiredChildren).map((e) => e.isarId), viewId); - }); - } + } + // If we are a collection, move out of syncFailed because we just completed a + // successful sync. songs/images will be moved out by _initiateDownload. + // If our linked children just changed, recalculate state with new children. + if (!canonParent!.type.hasFiles && + (canonParent!.state == DownloadItemState.syncFailed || + requiredChanges.$1.isNotEmpty || + requiredChanges.$2.isNotEmpty || + requiredChanges.$3.isNotEmpty || + infoChanges.$1.isNotEmpty || + infoChanges.$2.isNotEmpty || + infoChanges.$3.isNotEmpty)) { + _downloadsService.syncItemState(canonParent!, removeSyncFailed: true); + } + + // sync download settings on all newly required children. Newly inserted children + // may be skipped, as they inherit the parent settings. Children who exactly match + // the parent's download settings already may be skipped. + for (var child in _isar.downloadItems + .getAllSync(requiredChanges.$2.toList()) + .whereNotNull()) { + if (child.syncTranscodingProfile != + canonParent!.syncTranscodingProfile) { + _downloadsService.syncItemDownloadSettings(child); + } + } + }); // // Download item files if needed diff --git a/lib/services/favorite_provider.dart b/lib/services/favorite_provider.dart index 556457502..cfba4f967 100644 --- a/lib/services/favorite_provider.dart +++ b/lib/services/favorite_provider.dart @@ -5,6 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../components/global_snackbar.dart'; import '../models/jellyfin_models.dart'; +import 'downloads_service.dart'; import 'feedback_helper.dart'; import 'finamp_settings_helper.dart'; import 'jellyfin_api_helper.dart'; @@ -12,36 +13,70 @@ import 'jellyfin_api_helper.dart'; part 'favorite_provider.g.dart'; /// All DefaultValues should be considered equal -class DefaultValue { - late final bool? isFavorite; +class FavoriteRequest { + final BaseItemDto? item; - DefaultValue([BaseItemDto? item]) { - isFavorite = item?.userData?.isFavorite; - } + FavoriteRequest(this.item); @override bool operator ==(Object other) { - return other is DefaultValue; + return other is FavoriteRequest && other.item?.id == item?.id; } @override - int get hashCode => 7236544576; + int get hashCode => item?.id.hashCode ?? 5436345667; } @riverpod class IsFavorite extends _$IsFavorite { + bool _changed = false; + Future? _initializing; + @override // Because DefaultValue is always equal and we never invalidate, this should only // be called once per itemId and all other DefaultValues should be ignored - bool build(String? itemId, DefaultValue value) { - if (itemId == null) { + bool build(FavoriteRequest value) { + if (value.item == null) { return false; } - return value.isFavorite ?? false; + var item = value.item!; + ref.listen(finampSettingsProvider.select((value) => value.value?.isOffline), + (_, value) { + if (!_changed && value == false) { + // If we have not had updateFavorite run and are moving from offline to + // online, invalidate to fetch latest data from server. + ref.invalidateSelf(); + } + }); + + if ((item.finampOffline ?? false) || item.userData?.isFavorite == null) { + if (!FinampSettingsHelper.finampSettings.isOffline) { + // Fetch the latest value from the server to replace the request's default value + // as soon as possible. + _initializing = Future.sync(() async { + try { + final jellyfinApiHelper = GetIt.instance(); + var newItem = await jellyfinApiHelper.getItemById(item.id); + if (!_changed) { + state = newItem.userData?.isFavorite ?? false; + } + } catch (e) { + GlobalSnackbar.error(e); + } + }); + } + // The favorites status in offline items is unreliable, use isFavorite instead + // if possible. + return GetIt.instance().isFavorite(item) ?? + item.userData?.isFavorite ?? + false; + } else { + return item.userData?.isFavorite ?? false; + } } bool updateFavorite(bool isFavorite) { - assert(itemId != null); + assert(value.item != null); final isOffline = FinampSettingsHelper.finampSettings.isOffline; final jellyfinApiHelper = GetIt.instance(); if (isOffline) { @@ -56,14 +91,15 @@ class IsFavorite extends _$IsFavorite { // of date, so we can never safely re-create the widget from a DefaultValue // and must keep it alive. ref.keepAlive(); + _changed = true; Future.sync(() async { try { UserItemDataDto newUserData; if (isFavorite) { - newUserData = await jellyfinApiHelper.addFavourite(itemId!); + newUserData = await jellyfinApiHelper.addFavourite(value.item!.id); } else { - newUserData = await jellyfinApiHelper.removeFavourite(itemId!); + newUserData = await jellyfinApiHelper.removeFavourite(value.item!.id); } state = newUserData.isFavorite; @@ -77,7 +113,10 @@ class IsFavorite extends _$IsFavorite { return state; } - bool toggleFavorite() { - return updateFavorite(!state); + void toggleFavorite() async { + if (_initializing != null) { + await _initializing; + } + updateFavorite(!state); } } diff --git a/lib/services/favorite_provider.g.dart b/lib/services/favorite_provider.g.dart index 3c647f045..ac9a4f24b 100644 --- a/lib/services/favorite_provider.g.dart +++ b/lib/services/favorite_provider.g.dart @@ -6,7 +6,7 @@ part of 'favorite_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$isFavoriteHash() => r'72fb1365755e053a70b239dd1c5e757065af3104'; +String _$isFavoriteHash() => r'd3e8dbfc2d63ed1af22fa61e9d33550b6f566580'; /// Copied from Dart SDK class _SystemHash { @@ -30,12 +30,10 @@ class _SystemHash { } abstract class _$IsFavorite extends BuildlessAutoDisposeNotifier { - late final String? itemId; - late final DefaultValue value; + late final FavoriteRequest value; bool build( - String? itemId, - DefaultValue value, + FavoriteRequest value, ); } @@ -50,11 +48,9 @@ class IsFavoriteFamily extends Family { /// See also [IsFavorite]. IsFavoriteProvider call( - String? itemId, - DefaultValue value, + FavoriteRequest value, ) { return IsFavoriteProvider( - itemId, value, ); } @@ -64,7 +60,6 @@ class IsFavoriteFamily extends Family { covariant IsFavoriteProvider provider, ) { return call( - provider.itemId, provider.value, ); } @@ -89,12 +84,9 @@ class IsFavoriteProvider extends AutoDisposeNotifierProviderImpl { /// See also [IsFavorite]. IsFavoriteProvider( - String? itemId, - DefaultValue value, + FavoriteRequest value, ) : this._internal( - () => IsFavorite() - ..itemId = itemId - ..value = value, + () => IsFavorite()..value = value, from: isFavoriteProvider, name: r'isFavoriteProvider', debugGetCreateSourceHash: @@ -104,7 +96,6 @@ class IsFavoriteProvider dependencies: IsFavoriteFamily._dependencies, allTransitiveDependencies: IsFavoriteFamily._allTransitiveDependencies, - itemId: itemId, value: value, ); @@ -115,19 +106,16 @@ class IsFavoriteProvider required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.itemId, required this.value, }) : super.internal(); - final String? itemId; - final DefaultValue value; + final FavoriteRequest value; @override bool runNotifierBuild( covariant IsFavorite notifier, ) { return notifier.build( - itemId, value, ); } @@ -137,15 +125,12 @@ class IsFavoriteProvider return ProviderOverride( origin: this, override: IsFavoriteProvider._internal( - () => create() - ..itemId = itemId - ..value = value, + () => create()..value = value, from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - itemId: itemId, value: value, ), ); @@ -158,15 +143,12 @@ class IsFavoriteProvider @override bool operator ==(Object other) { - return other is IsFavoriteProvider && - other.itemId == itemId && - other.value == value; + return other is IsFavoriteProvider && other.value == value; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, itemId.hashCode); hash = _SystemHash.combine(hash, value.hashCode); return _SystemHash.finish(hash); @@ -174,11 +156,8 @@ class IsFavoriteProvider } mixin IsFavoriteRef on AutoDisposeNotifierProviderRef { - /// The parameter `itemId` of this provider. - String? get itemId; - /// The parameter `value` of this provider. - DefaultValue get value; + FavoriteRequest get value; } class _IsFavoriteProviderElement @@ -187,9 +166,7 @@ class _IsFavoriteProviderElement _IsFavoriteProviderElement(super.provider); @override - String? get itemId => (origin as IsFavoriteProvider).itemId; - @override - DefaultValue get value => (origin as IsFavoriteProvider).value; + FavoriteRequest get value => (origin as IsFavoriteProvider).value; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index 8573857d0..e0857db80 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -540,8 +540,7 @@ class JellyfinApiHelper { final downloadsService = GetIt.instance(); unawaited(downloadsService.resync( DownloadStub.fromFinampCollection( - collection: FinampCollection(type: FinampCollectionType.favorites), - name: null), + FinampCollection(type: FinampCollectionType.favorites)), null, keepSlow: true)); return UserItemDataDto.fromJson(response); @@ -556,8 +555,7 @@ class JellyfinApiHelper { final downloadsService = GetIt.instance(); unawaited(downloadsService.resync( DownloadStub.fromFinampCollection( - collection: FinampCollection(type: FinampCollectionType.favorites), - name: null), + FinampCollection(type: FinampCollectionType.favorites)), null, keepSlow: true)); return UserItemDataDto.fromJson(response); diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 56d8a30a5..75714f760 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:audio_service/audio_service.dart'; -import 'package:collection/collection.dart'; import 'package:Finamp/components/global_snackbar.dart'; import 'package:Finamp/models/finamp_models.dart'; import 'package:Finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:audio_service/audio_service.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -942,7 +942,7 @@ class QueueService { : null), title: item.name ?? "unknown", extras: { - "itemJson": item.toJson(), + "itemJson": item.toJson(setOffline: false), "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, "downloadedSongPath": downloadedSong?.file?.path, "isOffline": FinampSettingsHelper.finampSettings.isOffline,