From a07c12545490fb12b29a7b9c5eed0f6c978c8e8b Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Feb 2026 14:30:10 +0700 Subject: [PATCH] feat: update collection actions for offline-first playback --- lib/screens/album_screen.dart | 123 ++++++++---- lib/screens/artist_screen.dart | 185 +++++++++++++----- lib/screens/playlist_screen.dart | 123 ++++++++---- .../track_collection_quick_actions.dart | 184 ++++++++++++++--- 4 files changed, 450 insertions(+), 165 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 5cd0d030..a5411ae3 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -761,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -861,13 +867,7 @@ class _AlbumTrackItem extends ConsumerWidget { ], ), trailing: TrackCollectionQuickActions(track: track), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -882,47 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } + + Future _playLocalIfAvailable( + BuildContext context, + WidgetRef ref, + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 420ee857..43cfa2eb 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; @@ -1243,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState { ); final isInHistory = ref.watch( - downloadHistoryProvider.select( - (state) => state.isDownloaded(track.id), - ), + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && + isrc.isNotEmpty && + state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != + null; + }), ); final showLocalLibraryIndicator = ref.watch( @@ -1268,12 +1277,7 @@ class _ArtistScreenState extends ConsumerState { final isQueued = queueItem != null; return InkWell( - onTap: () => _handlePopularTrackTap( - track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -1344,17 +1348,61 @@ class _ArtistScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (track.albumName.isNotEmpty) - ClickableAlbumName( - albumName: track.albumName, - albumId: track.albumId, - artistName: track.artistName, - coverUrl: track.coverUrl, - extensionId: widget.extensionId, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (track.albumName.isNotEmpty || + isInLocalLibrary || + isInHistory) + Row( + children: [ + if (track.albumName.isNotEmpty) + Expanded( + child: ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + extensionId: widget.extensionId, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + if (track.albumName.isNotEmpty) + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], ), ], ), @@ -1369,51 +1417,82 @@ class _ArtistScreenState extends ConsumerState { } /// Handle tap on popular track item - void _handlePopularTrackTap( - Track track, { - required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, - }) async { + void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; - if (isInLocalLibrary) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(track); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); + _downloadTrack(track); + } + + Future _playLocalIfAvailable(Track track) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; } + historyNotifier.removeFromHistory(historyItem.id); } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; } - _downloadTrack(track); + return false; } void _downloadTrack(Track track) { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 8d43dc90..d005678d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -631,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -742,13 +748,7 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ), trailing: TrackCollectionQuickActions(track: track), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), + onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, @@ -763,47 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } + + Future _playLocalIfAvailable( + BuildContext context, + WidgetRef ref, + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 601fac9c..c45649f1 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,8 +7,11 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -171,9 +176,20 @@ class _TrackOptionsSheet extends ConsumerWidget { // Action items (matches _QualityOption style) _OptionTile( icon: Icons.download_rounded, - title: context.l10n.downloadTitle, + title: 'Download & Play', onTap: () async { Navigator.pop(context); + final playedLocal = await _playLocalIfAvailable( + container, + rootContext, + ); + if (playedLocal) { + return; + } + if (!rootContext.mounted) { + return; + } + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( rootContext, @@ -181,35 +197,19 @@ class _TrackOptionsSheet extends ConsumerWidget { artistName: track.artistName, coverUrl: track.coverUrl, onSelect: (quality, service) { - container - .read(downloadQueueProvider.notifier) - .addToQueue( - track, - service, - qualityOverride: quality, - ); - ScaffoldMessenger.of(rootContext).showSnackBar( - SnackBar( - content: Text( - rootContext.l10n.snackbarAddedToQueue(track.name), - ), - ), + _enqueueDownloadAndAutoPlay( + container: container, + context: rootContext, + service: service, + quality: quality, ); }, ); } else { - container - .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); - if (!rootContext.mounted) { - return; - } - ScaffoldMessenger.of(rootContext).showSnackBar( - SnackBar( - content: Text( - rootContext.l10n.snackbarAddedToQueue(track.name), - ), - ), + _enqueueDownloadAndAutoPlay( + container: container, + context: rootContext, + service: settings.defaultService, ); } }, @@ -282,6 +282,138 @@ class _TrackOptionsSheet extends ConsumerWidget { ), ); } + + Future _playLocalIfAvailable( + ProviderContainer container, + BuildContext context, + ) async { + final localState = container.read(localLibraryProvider); + final historyState = container.read(downloadHistoryProvider); + final historyNotifier = container.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; + } + + return false; + } + + void _enqueueDownloadAndAutoPlay({ + required ProviderContainer container, + required BuildContext context, + required String service, + String? quality, + }) { + container + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + unawaited(_waitForDownloadedFileAndPlay(container, context)); + } + + Future _waitForDownloadedFileAndPlay( + ProviderContainer container, + BuildContext context, + ) async { + const maxAttempts = 180; // up to ~3 minutes + for (var i = 0; i < maxAttempts; i++) { + final item = _findHistoryMatch(container); + if (item != null && await fileExists(item.filePath)) { + try { + await container + .read(playbackProvider.notifier) + .playLocalPath( + path: item.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile('$e')), + ), + ); + } + } + return; + } + await Future.delayed(const Duration(seconds: 1)); + } + } + + DownloadHistoryItem? _findHistoryMatch(ProviderContainer container) { + final historyState = container.read(downloadHistoryProvider); + final historyNotifier = container.read(downloadHistoryProvider.notifier); + final isrc = track.isrc?.trim(); + + return historyNotifier.getBySpotifyId(track.id) ?? + ((isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null) ?? + historyState.findByTrackAndArtist(track.name, track.artistName); + } } /// Styled like _QualityOption in download_service_picker.dart