diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 04468a39..5ae7a394 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -10,9 +10,7 @@ import 'package:spotiflac_android/providers/extension_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/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; @@ -24,6 +22,8 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; +import 'package:spotiflac_android/utils/local_playback.dart'; +import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; @@ -1299,35 +1299,7 @@ class _AlbumTrackItem extends ConsumerWidget { ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), + const InLibraryBadge(), ], ], ), @@ -1356,78 +1328,11 @@ class _AlbumTrackItem extends ConsumerWidget { }) async { if (isQueued) return; - final playedLocal = await _playLocalIfAvailable(context, ref); + final playedLocal = await playLocalIfAvailable(context, ref, track); if (playedLocal) { return; } onDownload(); } - - Future _playLocalIfAvailable( - BuildContext context, - WidgetRef ref, - ) async { - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - - try { - DownloadHistoryItem? historyItem = await historyNotifier - .getBySpotifyIdAsync(track.id); - final isrc = track.isrc?.trim(); - historyItem ??= (isrc != null && isrc.isNotEmpty) - ? await historyNotifier.getByIsrcAsync(isrc) - : null; - historyItem ??= await historyNotifier.findByTrackAndArtistAsync( - 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); - } - - final localItem = await ref - .read(localLibraryProvider.notifier) - .findExistingAsync( - isrc: isrc, - trackName: track.name, - artistName: 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 457a9ead..375e5fb4 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -11,15 +11,15 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_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/utils/string_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; +import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -1643,35 +1643,7 @@ class _ArtistScreenState extends ConsumerState { if (isInLocalLibrary || isInHistory) ...[ if (track.albumName.isNotEmpty) const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), + const InLibraryBadge(), ], ], ), @@ -1690,7 +1662,7 @@ class _ArtistScreenState extends ConsumerState { void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; - final playedLocal = await _playLocalIfAvailable(track); + final playedLocal = await playLocalIfAvailable(context, ref, track); if (playedLocal) { return; } @@ -1698,70 +1670,6 @@ class _ArtistScreenState extends ConsumerState { _downloadTrack(track); } - Future _playLocalIfAvailable(Track track) async { - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - - try { - DownloadHistoryItem? historyItem = await historyNotifier - .getBySpotifyIdAsync(track.id); - final isrc = track.isrc?.trim(); - historyItem ??= (isrc != null && isrc.isNotEmpty) - ? await historyNotifier.getByIsrcAsync(isrc) - : null; - historyItem ??= await historyNotifier.findByTrackAndArtistAsync( - 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); - } - - final localItem = await ref - .read(localLibraryProvider.notifier) - .findExistingAsync( - isrc: isrc, - trackName: track.name, - artistName: 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; - } - - return false; - } - void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); ref.read(settingsProvider.notifier).setHasSearchedBefore(); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 9102aea8..c542f871 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index e67786f3..688c9b65 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -342,35 +342,7 @@ class _TrackItemWithStatus extends ConsumerWidget { ), if (isInLocalLibrary) ...[ const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), + const InLibraryBadge(), ], ], ), diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart deleted file mode 100644 index cf0a0046..00000000 --- a/lib/screens/library_playlists_screen.dart +++ /dev/null @@ -1,546 +0,0 @@ -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; -import 'package:spotiflac_android/utils/nav_bar_inset.dart'; - -class LibraryPlaylistsScreen extends ConsumerWidget { - const LibraryPlaylistsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), - ); - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); - final bottomInset = context.navBarBottomInset; - - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - context.l10n.collectionPlaylists, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ), - if (playlists.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.playlist_play, - size: 60, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - context.l10n.collectionNoPlaylistsYet, - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - context.l10n.collectionNoPlaylistsSubtitle, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - // Even indices = playlist tiles, odd indices = dividers - if (index.isOdd) { - return const Divider(height: 1); - } - final playlistIndex = index ~/ 2; - final playlist = playlists[playlistIndex]; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 2, - ), - leading: _buildPlaylistThumbnail(context, playlist), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.playlist, - playlistId: playlist.id, - ), - ), - ); - }, - onLongPress: () => - _showPlaylistOptionsSheet(context, ref, playlist), - ); - }, childCount: playlists.length * 2 - 1), - ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreatePlaylistDialog(context, ref), - icon: const Icon(Icons.add), - label: Text(context.l10n.collectionCreatePlaylist), - ), - ); - } - - void _showPlaylistOptionsSheet( - BuildContext context, - WidgetRef ref, - UserPlaylistCollection playlist, - ) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - _buildPlaylistThumbnail(context, playlist), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - playlist.name, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - - BottomSheetOptionTile( - icon: Icons.edit_outlined, - title: context.l10n.collectionRenamePlaylist, - onTap: () { - Navigator.pop(sheetContext); - _showRenamePlaylistDialog( - context, - ref, - playlist.id, - playlist.name, - ); - }, - ), - - BottomSheetOptionTile( - icon: Icons.image_outlined, - title: context.l10n.collectionPlaylistChangeCover, - onTap: () { - Navigator.pop(sheetContext); - _pickCoverImage(context, ref, playlist.id); - }, - ), - - BottomSheetOptionTile( - icon: Icons.delete_outline, - iconColor: colorScheme.error, - title: context.l10n.collectionDeletePlaylist, - onTap: () { - Navigator.pop(sheetContext); - _confirmDeletePlaylist( - context, - ref, - playlist.id, - playlist.name, - ); - }, - ), - - const SizedBox(height: 16), - ], - ), - ), - ); - } - - Widget _buildPlaylistThumbnail( - BuildContext context, - UserPlaylistCollection playlist, - ) { - final colorScheme = Theme.of(context).colorScheme; - const double size = 48; - final borderRadius = BorderRadius.circular(8); - final dpr = MediaQuery.devicePixelRatioOf(context); - final cacheWidth = (size * dpr).round().clamp(64, 512); - final placeholder = _playlistIconFallback(colorScheme, size); - - // Priority: custom cover > first track cover URL > icon fallback - final customCoverPath = playlist.coverImagePath; - if (customCoverPath != null && customCoverPath.isNotEmpty) { - return ClipRRect( - borderRadius: borderRadius, - child: Image.file( - File(customCoverPath), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return placeholder; - }, - errorBuilder: (_, _, _) => placeholder, - ), - ); - } - - String? firstCoverUrl; - for (final entry in playlist.tracks) { - final coverUrl = entry.track.coverUrl; - if (coverUrl != null && coverUrl.isNotEmpty) { - firstCoverUrl = coverUrl; - break; - } - } - - if (firstCoverUrl != null) { - final isLocalPath = - !firstCoverUrl.startsWith('http://') && - !firstCoverUrl.startsWith('https://'); - - if (isLocalPath) { - return ClipRRect( - borderRadius: borderRadius, - child: Image.file( - File(firstCoverUrl), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: cacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return placeholder; - }, - errorBuilder: (_, _, _) => placeholder, - ), - ); - } - - return ClipRRect( - borderRadius: borderRadius, - child: CachedNetworkImage( - imageUrl: firstCoverUrl, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => placeholder, - errorWidget: (_, _, _) => placeholder, - ), - ); - } - - return placeholder; - } - - Widget _playlistIconFallback(ColorScheme colorScheme, double size) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), - ); - } - - Future _pickCoverImage( - BuildContext context, - WidgetRef ref, - String playlistId, - ) async { - final picked = await FilePicker.pickFile(type: FileType.image); - if (picked == null) return; - - final path = picked.path; - if (path == null || path.isEmpty) return; - - await ref - .read(libraryCollectionsProvider.notifier) - .setPlaylistCover(playlistId, path); - } - - Future _showCreatePlaylistDialog( - BuildContext context, - WidgetRef ref, - ) async { - final controller = TextEditingController(); - final formKey = GlobalKey(); - - final playlistName = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(dialogContext.l10n.collectionCreatePlaylist), - content: Form( - key: formKey, - child: TextFormField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - hintText: dialogContext.l10n.collectionPlaylistNameHint, - ), - validator: (value) { - final trimmed = value?.trim() ?? ''; - if (trimmed.isEmpty) { - return dialogContext.l10n.collectionPlaylistNameRequired; - } - return null; - }, - onFieldSubmitted: (_) { - if (formKey.currentState?.validate() != true) return; - Navigator.of(dialogContext).pop(controller.text.trim()); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(dialogContext.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - if (formKey.currentState?.validate() != true) return; - Navigator.of(dialogContext).pop(controller.text.trim()); - }, - child: Text(dialogContext.l10n.actionCreate), - ), - ], - ); - }, - ); - - if (playlistName == null || - playlistName.trim().isEmpty || - !context.mounted) { - return; - } - - await ref - .read(libraryCollectionsProvider.notifier) - .createPlaylist(playlistName.trim()); - - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), - ); - } - - Future _showRenamePlaylistDialog( - BuildContext context, - WidgetRef ref, - String playlistId, - String currentName, - ) async { - final controller = TextEditingController(text: currentName); - final formKey = GlobalKey(); - - final nextName = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(dialogContext.l10n.collectionRenamePlaylist), - content: Form( - key: formKey, - child: TextFormField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - hintText: dialogContext.l10n.collectionPlaylistNameHint, - ), - validator: (value) { - final trimmed = value?.trim() ?? ''; - if (trimmed.isEmpty) { - return dialogContext.l10n.collectionPlaylistNameRequired; - } - return null; - }, - onFieldSubmitted: (_) { - if (formKey.currentState?.validate() != true) return; - Navigator.of(dialogContext).pop(controller.text.trim()); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(dialogContext.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - if (formKey.currentState?.validate() != true) return; - Navigator.of(dialogContext).pop(controller.text.trim()); - }, - child: Text(dialogContext.l10n.dialogSave), - ), - ], - ); - }, - ); - - if (nextName == null || nextName.trim().isEmpty || !context.mounted) { - return; - } - - await ref - .read(libraryCollectionsProvider.notifier) - .renamePlaylist(playlistId, nextName.trim()); - - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)), - ); - } - - Future _confirmDeletePlaylist( - BuildContext context, - WidgetRef ref, - String playlistId, - String playlistName, - ) async { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(dialogContext.l10n.collectionDeletePlaylist), - content: Text( - dialogContext.l10n.collectionDeletePlaylistMessage(playlistName), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text(dialogContext.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text(dialogContext.l10n.dialogDelete), - ), - ], - ); - }, - ); - - if (confirmed != true || !context.mounted) return; - - await ref - .read(libraryCollectionsProvider.notifier) - .deletePlaylist(playlistId); - - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)), - ); - } -} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 0715d72b..37976cfb 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -1415,35 +1416,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), + const InLibraryBadge(), ], ], ), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 733b66b2..36d6bed4 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -7,19 +7,19 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.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'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/utils/local_playback.dart'; +import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; @@ -1069,35 +1069,7 @@ class _PlaylistTrackItem extends ConsumerWidget { ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder_outlined, - size: 10, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 3), - Text( - context.l10n.libraryInLibrary, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), + const InLibraryBadge(), ], ], ), @@ -1126,78 +1098,11 @@ class _PlaylistTrackItem extends ConsumerWidget { }) async { if (isQueued) return; - final playedLocal = await _playLocalIfAvailable(context, ref); + final playedLocal = await playLocalIfAvailable(context, ref, track); if (playedLocal) { return; } onDownload(); } - - Future _playLocalIfAvailable( - BuildContext context, - WidgetRef ref, - ) async { - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - - try { - DownloadHistoryItem? historyItem = await historyNotifier - .getBySpotifyIdAsync(track.id); - final isrc = track.isrc?.trim(); - historyItem ??= (isrc != null && isrc.isNotEmpty) - ? await historyNotifier.getByIsrcAsync(isrc) - : null; - historyItem ??= await historyNotifier.findByTrackAndArtistAsync( - 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); - } - - final localItem = await ref - .read(localLibraryProvider.notifier) - .findExistingAsync( - isrc: isrc, - trackName: track.name, - artistName: 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/search_screen.dart b/lib/screens/search_screen.dart deleted file mode 100644 index 71e7d467..00000000 --- a/lib/screens/search_screen.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/providers/track_provider.dart'; -import 'package:spotiflac_android/providers/preview_player_provider.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; -import 'package:spotiflac_android/widgets/animation_utils.dart'; -import 'package:spotiflac_android/utils/clickable_metadata.dart'; -import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; -import 'package:spotiflac_android/widgets/cached_cover_image.dart'; -import 'package:spotiflac_android/widgets/preview_button.dart'; - -class SearchScreen extends ConsumerStatefulWidget { - final String query; - - const SearchScreen({super.key, required this.query}); - - @override - ConsumerState createState() => _SearchScreenState(); -} - -class _SearchScreenState extends ConsumerState { - late TextEditingController _searchController; - late PreviewPlayerController _previewPlayerController; - - @override - void initState() { - super.initState(); - _searchController = TextEditingController(text: widget.query); - _previewPlayerController = ref.read(previewPlayerProvider.notifier); - if (widget.query.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(trackProvider.notifier).search(widget.query); - }); - } - } - - @override - void dispose() { - _previewPlayerController.stop(); - _searchController.dispose(); - super.dispose(); - } - - void _search() { - final query = _searchController.text.trim(); - if (query.isNotEmpty) { - ref.read(trackProvider.notifier).search(query); - } - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: TextField( - controller: _searchController, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration( - hintText: context.l10n.searchTracksHint, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), - onSubmitted: (_) => _search(), - autofocus: widget.query.isEmpty, - ), - actions: [ - IconButton( - tooltip: MaterialLocalizations.of(context).searchFieldLabel, - icon: const Icon(Icons.search), - onPressed: _search, - ), - ], - ), - body: const _SearchResultsBody(), - ); - } -} - -class _SearchResultsBody extends ConsumerWidget { - const _SearchResultsBody(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); - final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final error = ref.watch(trackProvider.select((s) => s.error)); - final colorScheme = Theme.of(context).colorScheme; - - return Column( - children: [ - if (isLoading) LinearProgressIndicator(color: colorScheme.primary), - if (error != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text(error, style: TextStyle(color: colorScheme.error)), - ), - Expanded( - child: AnimatedStateSwitcher( - child: isLoading && tracks.isEmpty - ? const TrackListSkeleton(key: ValueKey('loading')) - : tracks.isEmpty - ? _SearchEmptyState( - key: const ValueKey('empty'), - colorScheme: colorScheme, - ) - : ListView.builder( - key: const ValueKey('results'), - itemCount: tracks.length, - itemBuilder: (context, index) => StaggeredListItem( - key: ValueKey('search-track-${tracks[index].id}-$index'), - index: index, - child: _SearchTrackTile(track: tracks[index]), - ), - ), - ), - ), - ], - ); - } -} - -class _SearchEmptyState extends StatelessWidget { - final ColorScheme colorScheme; - - const _SearchEmptyState({super.key, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text( - context.l10n.searchTracksEmptyPrompt, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - -class _SearchTrackTile extends ConsumerWidget { - final Track track; - - const _SearchTrackTile({required this.track}); - - void _downloadTrack(BuildContext context, WidgetRef ref) { - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final service = resolveEffectiveDownloadService( - settings.defaultService, - extensionState, - ); - if (service.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), - ); - return; - } - ref.read(downloadQueueProvider.notifier).addToQueue(track, service); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final coverWidget = track.coverUrl != null - ? CachedCoverImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - borderRadius: BorderRadius.circular(8), - ) - : Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ); - - return ListTile( - leading: coverWidget, - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClickableArtistName( - artistName: track.artistName, - artistId: track.artistId, - coverUrl: track.coverUrl, - extensionId: track.source, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - Row( - children: [ - Flexible( - child: ClickableAlbumName( - albumName: track.albumName, - albumId: track.albumId, - artistName: track.artistName, - coverUrl: track.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ), - ...buildQualityBadges( - audioQuality: track.audioQuality, - audioModes: track.audioModes, - colorScheme: colorScheme, - explicit: track.isExplicit, - ), - ], - ), - ], - ), - onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( - context, - ref, - track, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PreviewButton(track: track), - IconButton( - icon: const Icon(Icons.download_rounded), - tooltip: context.l10n.dialogDownload, - onPressed: () => _downloadTrack(context, ref), - ), - ], - ), - onTap: () => _downloadTrack(context, ref), - ); - } -} diff --git a/lib/utils/local_playback.dart b/lib/utils/local_playback.dart new file mode 100644 index 00000000..2ecd4528 --- /dev/null +++ b/lib/utils/local_playback.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +/// Plays [track] from download history or the local library when a matching +/// file already exists on disk. +/// +/// Returns true when playback started (or when an error was surfaced to the +/// user), so callers can skip enqueueing a download; returns false when no +/// local copy is available. Stale history entries whose file no longer exists +/// are pruned as a side effect. +Future playLocalIfAvailable( + BuildContext context, + WidgetRef ref, + Track track, +) async { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + try { + DownloadHistoryItem? historyItem = await historyNotifier.getBySpotifyIdAsync( + track.id, + ); + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? await historyNotifier.getByIsrcAsync(isrc) + : null; + historyItem ??= await historyNotifier.findByTrackAndArtistAsync( + 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); + } + + final localItem = await ref + .read(localLibraryProvider.notifier) + .findExistingAsync( + isrc: isrc, + trackName: track.name, + artistName: 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/collapsing_header.dart b/lib/widgets/collapsing_header.dart deleted file mode 100644 index 790d4ce2..00000000 --- a/lib/widgets/collapsing_header.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; - -class CollapsingHeader extends StatelessWidget { - final String title; - final bool showBackButton; - final Widget? infoCard; - final List slivers; - - const CollapsingHeader({ - super.key, - required this.title, - this.showBackButton = false, - this.infoCard, - required this.slivers, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); - - return CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 140, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: showBackButton - ? IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ) - : null, - automaticallyImplyLeading: false, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final expandRatio = _calculateExpandRatio( - constraints, - topPadding, - ); - final animation = AlwaysStoppedAnimation(expandRatio); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.zero, - title: Container( - alignment: Alignment.bottomLeft, - padding: EdgeInsets.only( - left: Tween( - begin: showBackButton ? 56 : 24, - end: 24, - ).evaluate(animation), - bottom: Tween( - begin: 16, - end: 24, - ).evaluate(animation), - ), - child: Text( - title, - style: TextStyle( - fontSize: Tween( - begin: 20, - end: 28, - ).evaluate(animation), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ), - ); - }, - ), - ), - - if (infoCard != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: infoCard, - ), - ), - - ...slivers, - ], - ); - } - - double _calculateExpandRatio(BoxConstraints constraints, double topPadding) { - final maxHeight = 140; - final minHeight = kToolbarHeight + topPadding; - final currentHeight = constraints.maxHeight; - final expandRatio = (currentHeight - minHeight) / (maxHeight - minHeight); - return expandRatio.clamp(0.0, 1.0); - } -} - -class SettingsSection extends StatelessWidget { - final String title; - const SettingsSection({super.key, required this.title}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - -class InfoCard extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback? onTap; - - const InfoCard({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(icon, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.bodyLarge), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/in_library_badge.dart b/lib/widgets/in_library_badge.dart new file mode 100644 index 00000000..e0a77250 --- /dev/null +++ b/lib/widgets/in_library_badge.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Small "In Library" chip shown next to a track that already exists in the +/// local library or download history. +class InLibraryBadge extends StatelessWidget { + const InLibraryBadge({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ); + } +}