From fd7424dd8146e2d43819fb571973a9f561377414 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 9 Jul 2026 19:02:05 +0700 Subject: [PATCH] refactor(ui): extract shared widgets, drop dead screens Extract InLibraryBadge (pasted verbatim in five screens) and playLocalIfAvailable (duplicated in three) into shared code, and delete SearchScreen, LibraryPlaylistsScreen, and CollapsingHeader, which are imported nowhere in lib/. --- lib/screens/album_screen.dart | 103 +--- lib/screens/artist_screen.dart | 100 +--- lib/screens/home_tab.dart | 1 + lib/screens/home_tab_widgets.dart | 30 +- lib/screens/library_playlists_screen.dart | 546 ------------------ lib/screens/library_tracks_folder_screen.dart | 31 +- lib/screens/playlist_screen.dart | 103 +--- lib/screens/search_screen.dart | 259 --------- lib/utils/local_playback.dart | 84 +++ lib/widgets/collapsing_header.dart | 168 ------ lib/widgets/in_library_badge.dart | 39 ++ 11 files changed, 139 insertions(+), 1325 deletions(-) delete mode 100644 lib/screens/library_playlists_screen.dart delete mode 100644 lib/screens/search_screen.dart create mode 100644 lib/utils/local_playback.dart delete mode 100644 lib/widgets/collapsing_header.dart create mode 100644 lib/widgets/in_library_badge.dart 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, + ), + ), + ], + ), + ); + } +}