diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index fa3b3442..dee16b91 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -433,6 +433,7 @@ extension _ArtistScreenSections on _ArtistScreenState { context, ref, track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -527,7 +528,10 @@ extension _ArtistScreenSections on _ArtistScreenState { ], ), ), - TrackCollectionQuickActions(track: track), + TrackCollectionQuickActions( + track: track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, + ), ], ), ), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 88e4874d..812ff401 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -264,6 +264,7 @@ class _TrackItemWithStatus extends ConsumerWidget { context, ref, track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), @@ -336,7 +337,10 @@ class _TrackItemWithStatus extends ConsumerWidget { ), ), PreviewButton(track: track), - TrackCollectionQuickActions(track: track), + TrackCollectionQuickActions( + track: track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, + ), ], ), ), diff --git a/lib/widgets/track_collection_action_policy.dart b/lib/widgets/track_collection_action_policy.dart new file mode 100644 index 00000000..f8d87e2b --- /dev/null +++ b/lib/widgets/track_collection_action_policy.dart @@ -0,0 +1,15 @@ +enum QualityVariantMenuAction { downloadAnotherQuality, playLocal } + +/// Resolves the quality-variant entry shown in a remote track's options menu. +/// +/// When tapping an already-local track is repurposed to download another +/// quality, the menu becomes the escape hatch for playing that local copy. +QualityVariantMenuAction? resolveQualityVariantMenuAction({ + required bool allowQualityVariants, + required bool hasLocalPlaybackCandidate, +}) { + if (!allowQualityVariants) return null; + return hasLocalPlaybackCandidate + ? QualityVariantMenuAction.playLocal + : QualityVariantMenuAction.downloadAnotherQuality; +} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 944715fb..d78df892 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -6,20 +6,28 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/track_collection_action_policy.dart'; import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class TrackCollectionQuickActions extends ConsumerWidget { final Track track; + final bool hasLocalPlaybackCandidate; - const TrackCollectionQuickActions({super.key, required this.track}); + const TrackCollectionQuickActions({ + super.key, + required this.track, + this.hasLocalPlaybackCandidate = false, + }); static void showTrackOptionsSheet( BuildContext context, WidgetRef ref, - Track track, - ) { + Track track, { + bool hasLocalPlaybackCandidate = false, + }) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, @@ -29,7 +37,10 @@ class TrackCollectionQuickActions extends ConsumerWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), - builder: (sheetContext) => _TrackOptionsSheet(track: track), + builder: (sheetContext) => _TrackOptionsSheet( + track: track, + hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, + ), ); } @@ -44,7 +55,12 @@ class TrackCollectionQuickActions extends ConsumerWidget { color: colorScheme.onSurfaceVariant, size: 20, ), - onPressed: () => showTrackOptionsSheet(context, ref, track), + onPressed: () => showTrackOptionsSheet( + context, + ref, + track, + hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, + ), padding: const EdgeInsets.only(left: 12), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ); @@ -53,8 +69,12 @@ class TrackCollectionQuickActions extends ConsumerWidget { class _TrackOptionsSheet extends ConsumerWidget { final Track track; + final bool hasLocalPlaybackCandidate; - const _TrackOptionsSheet({required this.track}); + const _TrackOptionsSheet({ + required this.track, + required this.hasLocalPlaybackCandidate, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -69,6 +89,10 @@ class _TrackOptionsSheet extends ConsumerWidget { final allowQualityVariants = ref.watch( settingsProvider.select((settings) => settings.allowQualityVariants), ); + final qualityVariantAction = resolveQualityVariantMenuAction( + allowQualityVariants: allowQualityVariants, + hasLocalPlaybackCandidate: hasLocalPlaybackCandidate, + ); return SafeArea( child: ConstrainedBox( @@ -168,23 +192,18 @@ class _TrackOptionsSheet extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - if (allowQualityVariants) + if (qualityVariantAction == QualityVariantMenuAction.playLocal) + _OptionTile( + icon: Icons.play_arrow_rounded, + title: context.l10n.trackMetadataPlay, + onTap: () => _playLocal(context, ref), + ) + else if (qualityVariantAction == + QualityVariantMenuAction.downloadAnotherQuality) _OptionTile( icon: Icons.download_outlined, title: context.l10n.trackOptionDownloadQualityVariant, - onTap: () { - final rootContext = Navigator.of( - context, - rootNavigator: true, - ).context; - Navigator.pop(context); - downloadSingleTrack( - rootContext, - ref, - track, - forceQualityPicker: true, - ); - }, + onTap: () => _downloadQualityVariant(context, ref), ), _OptionTile( @@ -255,6 +274,23 @@ class _TrackOptionsSheet extends ConsumerWidget { ), ); } + + void _downloadQualityVariant(BuildContext context, WidgetRef ref) { + final rootContext = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true); + } + + Future _playLocal(BuildContext context, WidgetRef ref) async { + final rootContext = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + final played = await playLocalIfAvailable(rootContext, ref, track); + if (!played && rootContext.mounted) { + ScaffoldMessenger.of(rootContext).showSnackBar( + SnackBar(content: Text(rootContext.l10n.snackbarFileNotFound)), + ); + } + } } class _OptionTile extends StatelessWidget { diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index 4551587e..e15d4e6f 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -116,7 +116,10 @@ class TrackListTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ PreviewButton(track: track), - TrackCollectionQuickActions(track: track), + TrackCollectionQuickActions( + track: track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, + ), ], ), onTap: () => _handleTap( @@ -129,6 +132,7 @@ class TrackListTile extends ConsumerWidget { context, ref, track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), ), ), diff --git a/test/track_collection_action_policy_test.dart b/test/track_collection_action_policy_test.dart new file mode 100644 index 00000000..0cbc933b --- /dev/null +++ b/test/track_collection_action_policy_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/track_collection_action_policy.dart'; + +void main() { + group('quality variant track menu', () { + test('is hidden when quality variants are disabled', () { + expect( + resolveQualityVariantMenuAction( + allowQualityVariants: false, + hasLocalPlaybackCandidate: true, + ), + isNull, + ); + }); + + test('offers another download when the track is not local', () { + expect( + resolveQualityVariantMenuAction( + allowQualityVariants: true, + hasLocalPlaybackCandidate: false, + ), + QualityVariantMenuAction.downloadAnotherQuality, + ); + }); + + test('swaps to local playback when row tap handles the download', () { + expect( + resolveQualityVariantMenuAction( + allowQualityVariants: true, + hasLocalPlaybackCandidate: true, + ), + QualityVariantMenuAction.playLocal, + ); + }); + }); +}