diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f9b77a9d..a9db1296 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -6,9 +6,7 @@ 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/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/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -17,7 +15,7 @@ import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; -import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/utils/ttl_cache.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; @@ -25,30 +23,15 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; class _AlbumCache { - static final Map _cache = {}; - static const Duration _ttl = Duration(minutes: 10); + static final _cache = TtlCache>(const Duration(minutes: 10)); - static List? get(String albumId) { - final entry = _cache[albumId]; - if (entry == null) return null; - if (DateTime.now().isAfter(entry.expiresAt)) { - _cache.remove(albumId); - return null; - } - return entry.tracks; - } + static List? get(String albumId) => _cache.get(albumId); - static void set(String albumId, List tracks) { - _cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl)); - } -} - -class _CacheEntry { - final List tracks; - final DateTime expiresAt; - _CacheEntry(this.tracks, this.expiresAt); + static void set(String albumId, List tracks) => + _cache.set(albumId, tracks); } class AlbumScreen extends ConsumerStatefulWidget { @@ -170,21 +153,6 @@ class _AlbumScreenState extends ConsumerState { return (mediaSize.height * 0.6).clamp(400.0, 580.0); } - String _formatReleaseDate(String date) { - if (date.length >= 10) { - final parts = date.substring(0, 10).split('-'); - if (parts.length == 3) { - return '${parts[2]}/${parts[1]}/${parts[0]}'; - } - } else if (date.length >= 7) { - final parts = date.split('-'); - if (parts.length >= 2) { - return '${parts[1]}/${parts[0]}'; - } - } - return date; - } - Future _fetchTracks() async { setState(() => _isLoading = true); try { @@ -195,49 +163,10 @@ class _AlbumScreenState extends ConsumerState { 'album', _metadataResourceId(directProviderId), ); - final trackList = metadata['track_list'] as List; - final albumInfo = metadata['album_info'] as Map?; - final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) - ?.toString(); - final albumType = normalizeOptionalString( - albumInfo?['album_type']?.toString(), + _applyAlbumMetadata( + metadata['track_list'] as List, + metadata['album_info'] as Map?, ); - final totalTracks = albumInfo?['total_tracks'] as int?; - final headerVideo = albumInfo?['header_video']?.toString(); - final headerImage = albumInfo?['header_image']?.toString(); - final audioTraits = (albumInfo?['audio_traits'] as List?) - ?.map((e) => e.toString()) - .toList(); - final tracks = trackList - .map( - (t) => _parseTrack( - t as Map, - albumTypeFallback: albumType, - totalTracksFallback: totalTracks, - ), - ) - .toList(); - - _AlbumCache.set(widget.albumId, tracks); - - if (mounted) { - setState(() { - _tracks = tracks; - _artistId = artistId; - _albumType = albumType; - _albumTotalTracks = totalTracks; - _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) - ? headerVideo - : _headerVideoUrl; - _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) - ? headerImage - : _headerImageUrl; - _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) - ? audioTraits - : _audioTraits; - _isLoading = false; - }); - } return; } else { final url = 'https://open.spotify.com/album/${widget.albumId}'; @@ -246,52 +175,11 @@ class _AlbumScreenState extends ConsumerState { throw StateError('Failed to load album metadata from extension'); } - final trackList = result['tracks'] as List; - final albumInfo = result['album'] as Map?; - final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) - ?.toString(); - final albumType = normalizeOptionalString( - albumInfo?['album_type']?.toString(), + _applyAlbumMetadata( + result['tracks'] as List, + result['album'] as Map?, + fallbackSource: result, ); - final totalTracks = albumInfo?['total_tracks'] as int?; - final headerVideo = - (albumInfo?['header_video'] ?? result['header_video'])?.toString(); - final headerImage = - (albumInfo?['header_image'] ?? result['header_image'])?.toString(); - final audioTraits = - ((albumInfo?['audio_traits'] ?? result['audio_traits']) as List?) - ?.map((e) => e.toString()) - .toList(); - final tracks = trackList - .map( - (t) => _parseTrack( - t as Map, - albumTypeFallback: albumType, - totalTracksFallback: totalTracks, - ), - ) - .toList(); - - _AlbumCache.set(widget.albumId, tracks); - - if (mounted) { - setState(() { - _tracks = tracks; - _artistId = artistId; - _albumType = albumType; - _albumTotalTracks = totalTracks; - _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) - ? headerVideo - : _headerVideoUrl; - _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) - ? headerImage - : _headerImageUrl; - _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) - ? audioTraits - : _audioTraits; - _isLoading = false; - }); - } return; } } catch (e) { @@ -304,6 +192,63 @@ class _AlbumScreenState extends ConsumerState { } } + /// Parses tracks + header metadata from either the direct-provider payload + /// (`albumInfo` only) or the extension payload (`albumInfo` falling back to + /// top-level [fallbackSource] keys), then updates state and the TTL cache. + void _applyAlbumMetadata( + List trackList, + Map? albumInfo, { + Map? fallbackSource, + }) { + final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) + ?.toString(); + final albumType = normalizeOptionalString( + albumInfo?['album_type']?.toString(), + ); + final totalTracks = albumInfo?['total_tracks'] as int?; + final headerVideo = + (albumInfo?['header_video'] ?? fallbackSource?['header_video']) + ?.toString(); + final headerImage = + (albumInfo?['header_image'] ?? fallbackSource?['header_image']) + ?.toString(); + final audioTraits = + ((albumInfo?['audio_traits'] ?? fallbackSource?['audio_traits']) + as List?) + ?.map((e) => e.toString()) + .toList(); + final tracks = trackList + .map( + (t) => _parseTrack( + t as Map, + albumTypeFallback: albumType, + totalTracksFallback: totalTracks, + ), + ) + .toList(); + + _AlbumCache.set(widget.albumId, tracks); + + if (mounted) { + setState(() { + _tracks = tracks; + _artistId = artistId; + _albumType = albumType; + _albumTotalTracks = totalTracks; + _headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty) + ? headerVideo + : _headerVideoUrl; + _headerImageUrl = (headerImage != null && headerImage.isNotEmpty) + ? headerImage + : _headerImageUrl; + _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) + ? audioTraits + : _audioTraits; + _isLoading = false; + }); + } + } + String? _directMetadataProviderId() { final providerId = _effectiveMetadataProviderIdFromAlbumId(); return providerId.isEmpty ? null : providerId; @@ -631,44 +576,7 @@ class _AlbumScreenState extends ConsumerState { BuildContext context, ColorScheme colorScheme, List tracks, - ) { - final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; - final totalSeconds = tracks.fold( - 0, - (sum, t) => sum + (t.duration > 0 ? t.duration : 0), - ); - final totalMinutes = (totalSeconds / 60).round(); - - final lines = []; - if (releaseDate != null && releaseDate.isNotEmpty) { - lines.add(_formatReleaseDate(releaseDate)); - } - final countText = context.l10n.tracksCount(tracks.length); - lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final line in lines) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - line, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ); - } + ) => buildTrackListFooter(context, colorScheme, tracks); Widget _buildTrackList( BuildContext context, @@ -720,139 +628,24 @@ class _AlbumScreenState extends ConsumerState { } void _downloadTrack(BuildContext context, Track track) { - final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - recommendedService: _recommendedDownloadService(), - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), - ); - }, - ); - } else { - 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))), - ); - } + downloadSingleTrack( + context, + ref, + track, + recommendedService: _recommendedDownloadService(), + ); } Future _downloadAll(BuildContext context) async { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); - if (!context.mounted) return; - final settings = ref.read(settingsProvider); - final localLibState = - (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) - ? ref.read(localLibraryProvider) - : null; - final tracksToQueue = []; - int skippedCount = 0; - - for (var i = 0; i < tracks.length; i++) { - final track = tracks[i]; - final isInHistory = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); - final isInLocal = - localLibState?.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ) ?? - false; - - if (isInHistory || isInLocal) { - skippedCount++; - } else { - tracksToQueue.add(track); - } - } - - if (tracksToQueue.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.discographySkippedDownloaded(0, skippedCount), - ), - ), - ); - return; - } - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: '${tracksToQueue.length} tracks', - artistName: widget.albumName, - recommendedService: _recommendedDownloadService(), - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - ); - _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); - }, - ); - } else { - 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) - .addMultipleToQueue(tracksToQueue, service); - _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); - } - } - - void _showQueuedSnackbar(BuildContext context, int added, int skipped) { - final message = skipped > 0 - ? context.l10n.discographySkippedDownloaded(added, skipped) - : context.l10n.snackbarAddedTracksToQueue(added); - ScaffoldMessenger.of( + await queueTracksSkippingDownloaded( context, - ).showSnackBar(SnackBar(content: Text(message))); + ref, + tracks, + artistNameForPicker: widget.albumName, + recommendedService: _recommendedDownloadService(), + ); } Widget _buildLoveAllButton() { @@ -935,41 +728,5 @@ class _AlbumScreenState extends ConsumerState { ); } - Future _loveAll(List tracks) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final state = ref.read(libraryCollectionsProvider); - final allLoved = tracks.every((t) => state.isLoved(t)); - - if (allLoved) { - for (final track in tracks) { - final key = trackCollectionKey(track); - await notifier.removeFromLoved(key); - } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarRemovedTracksFromLoved(tracks.length), - ), - ), - ); - } - } else { - int addedCount = 0; - for (final track in tracks) { - if (!state.isLoved(track)) { - await notifier.toggleLoved(track); - addedCount++; - } - } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), - ), - ); - } - } - } - + Future _loveAll(List tracks) => loveAllTracks(context, ref, tracks); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index ef50a170..0cc23221 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; +import 'package:spotiflac_android/utils/ttl_cache.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; @@ -30,18 +31,9 @@ import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; class _ArtistCache { - static final Map _cache = {}; - static const Duration _ttl = Duration(minutes: 10); + static final _cache = TtlCache<_CacheEntry>(const Duration(minutes: 10)); - static _CacheEntry? get(String artistId) { - final entry = _cache[artistId]; - if (entry == null) return null; - if (DateTime.now().isAfter(entry.expiresAt)) { - _cache.remove(artistId); - return null; - } - return entry; - } + static _CacheEntry? get(String artistId) => _cache.get(artistId); static void set( String artistId, { @@ -52,14 +44,16 @@ class _ArtistCache { String? headerVideoUrl, int? monthlyListeners, }) { - _cache[artistId] = _CacheEntry( - albums: albums, - releases: releases, - topTracks: topTracks, - headerImageUrl: headerImageUrl, - headerVideoUrl: headerVideoUrl, - monthlyListeners: monthlyListeners, - expiresAt: DateTime.now().add(_ttl), + _cache.set( + artistId, + _CacheEntry( + albums: albums, + releases: releases, + topTracks: topTracks, + headerImageUrl: headerImageUrl, + headerVideoUrl: headerVideoUrl, + monthlyListeners: monthlyListeners, + ), ); } } @@ -71,7 +65,6 @@ class _CacheEntry { final String? headerImageUrl; final String? headerVideoUrl; final int? monthlyListeners; - final DateTime expiresAt; _CacheEntry({ required this.albums, @@ -80,7 +73,6 @@ class _CacheEntry { this.headerImageUrl, this.headerVideoUrl, this.monthlyListeners, - required this.expiresAt, }); } diff --git a/lib/screens/collapsing_header_scroll_mixin.dart b/lib/screens/collapsing_header_scroll_mixin.dart new file mode 100644 index 00000000..841be65e --- /dev/null +++ b/lib/screens/collapsing_header_scroll_mixin.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Shared collapsing-header scroll behavior for album-style screens: owns the +/// [ScrollController] and flips [showTitleInAppBar] once the header has +/// scrolled out of view. +mixin CollapsingHeaderScrollMixin on State { + final ScrollController scrollController = ScrollController(); + bool showTitleInAppBar = false; + + @override + void initState() { + super.initState(); + scrollController.addListener(_onHeaderScroll); + } + + @override + void dispose() { + scrollController.removeListener(_onHeaderScroll); + scrollController.dispose(); + super.dispose(); + } + + double calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.sizeOf(context); + return (mediaSize.height * 0.6).clamp(400.0, 580.0); + } + + void _onHeaderScroll() { + final expandedHeight = calculateExpandedHeight(context); + final shouldShow = + scrollController.offset > (expandedHeight - kToolbarHeight - 20); + if (shouldShow != showTitleInAppBar) { + setState(() => showTitleInAppBar = shouldShow); + } + } +} diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 4f25bcdd..56db0338 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:share_plus/share_plus.dart'; @@ -10,6 +9,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; @@ -17,9 +17,14 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/widgets/album_scaffold_body.dart'; +import 'package:spotiflac_android/widgets/album_track_tile.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; @@ -42,11 +47,10 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget { _DownloadedAlbumScreenState(); } -class _DownloadedAlbumScreenState extends ConsumerState { - bool _isSelectionMode = false; - final Set _selectedIds = {}; - bool _showTitleInAppBar = false; - final ScrollController _scrollController = ScrollController(); +class _DownloadedAlbumScreenState extends ConsumerState + with + SelectionModeMixin, + CollapsingHeaderScrollMixin { bool _embeddedCoverRefreshScheduled = false; List? _albumTracksSourceCache; List? _albumTracksCache; @@ -62,19 +66,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { String get _albumLookupKey => '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); - super.dispose(); - } - @override void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -86,20 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - void _onScroll() { - final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = - _scrollController.offset > (expandedHeight - kToolbarHeight - 20); - if (shouldShow != _showTitleInAppBar) { - setState(() => _showTitleInAppBar = shouldShow); - } - } - - double _calculateExpandedHeight(BuildContext context) { - final mediaSize = MediaQuery.sizeOf(context); - return (mediaSize.height * 0.6).clamp(400.0, 580.0); - } - List _getAlbumTracks( List allItems, ) { @@ -168,90 +145,24 @@ class _DownloadedAlbumScreenState extends ConsumerState { return _sortedDiscNumbersCache ?? const []; } - void _enterSelectionMode(String itemId) { - HapticFeedback.mediumImpact(); - setState(() { - _isSelectionMode = true; - _selectedIds.add(itemId); - }); - } - - void _exitSelectionMode() { - setState(() { - _isSelectionMode = false; - _selectedIds.clear(); - }); - } - - void _toggleSelection(String itemId) { - setState(() { - if (_selectedIds.contains(itemId)) { - _selectedIds.remove(itemId); - if (_selectedIds.isEmpty) { - _isSelectionMode = false; - } - } else { - _selectedIds.add(itemId); - } - }); - } - - void _selectAll(List tracks) { - setState(() { - _selectedIds.addAll(tracks.map((e) => e.id)); - }); - } - Future _deleteSelected(List currentTracks) async { - final count = _selectedIds.length; - final confirmed = await showDialog( + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final tracksById = {for (final track in currentTracks) track.id: track}; + + await confirmAndDeleteTracks( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.downloadedAlbumDeleteSelected), - content: Text(context.l10n.downloadedAlbumDeleteMessage(count)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - ), - child: Text(context.l10n.dialogDelete), - ), - ], - ), - ); - - if (confirmed == true && mounted) { - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final idsToDelete = _selectedIds.toList(); - final tracksById = {for (final track in currentTracks) track.id: track}; - - int deletedCount = 0; - for (final id in idsToDelete) { + ids: selectedIds.toList(), + deleteItem: (id) async { final item = tracksById[id]; - if (item != null) { - try { - await deleteFile(item.filePath); - } catch (_) {} - historyNotifier.removeFromHistory(id); - deletedCount++; - } - } - - _exitSelectionMode(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarDeletedTracks(deletedCount)), - ), - ); - } - } + if (item == null) return false; + try { + await deleteFile(item.filePath); + } catch (_) {} + historyNotifier.removeFromHistory(id); + return true; + }, + onExitSelectionMode: exitSelectionMode, + ); } Future _openFile( @@ -373,52 +284,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } - final validIds = tracks.map((t) => t.id).toSet(); - _selectedIds.removeWhere((id) => !validIds.contains(id)); - if (_selectedIds.isEmpty && _isSelectionMode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _isSelectionMode = false); - }); - } + pruneSelection(tracks.map((t) => t.id).toSet()); - return PopScope( - canPop: !_isSelectionMode, - onPopInvokedWithResult: (didPop, result) { - if (!didPop && _isSelectionMode) { - _exitSelectionMode(); - } - }, - child: Scaffold( - body: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - _buildAppBar(context, colorScheme, tracks), - _buildTrackList(context, colorScheme, tracks), - SliverToBoxAdapter( - child: SizedBox(height: _isSelectionMode ? 120 : 32), - ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), - ], - ), - - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _buildSelectionBottomBar( - context, - colorScheme, - tracks, - bottomPadding, - ), - ), - ], - ), + return AlbumScaffoldBody( + scrollController: scrollController, + isSelectionMode: isSelectionMode, + onExitSelectionMode: exitSelectionMode, + appBar: _buildAppBar(context, colorScheme, tracks), + trackList: _buildTrackList(context, colorScheme, tracks), + bottomBar: _buildSelectionBottomBar( + context, + colorScheme, + tracks, + bottomPadding, ), + bottomInset: bottomInset, + bottomPadding: bottomPadding, ); } @@ -448,7 +329,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final expandedHeight = _calculateExpandedHeight(context); + final expandedHeight = calculateExpandedHeight(context); final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); final commonQuality = _getCommonQuality(tracks); @@ -483,7 +364,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { return AlbumDetailHeader( title: widget.albumName, expandedHeight: expandedHeight, - showTitleInAppBar: _showTitleInAppBar, + showTitleInAppBar: showTitleInAppBar, background: background, blurAndScrimBackground: embeddedCoverPath != null || widget.coverUrl != null, @@ -560,23 +441,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return placeholder(); } - Widget _metaWhiteItem(IconData? icon, String label) { - const textStyle = TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ); - if (icon == null) return Text(label, style: textStyle); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: Colors.white), - const SizedBox(width: 4), - Text(label, style: textStyle), - ], - ); - } - Widget _buildDownloadedHeaderMeta( BuildContext context, List tracks, @@ -588,38 +452,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); final totalMinutes = (totalSeconds / 60).round(); - final parts = []; - void add(Widget w) { - if (parts.isNotEmpty) { - parts.add( - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: Text( - '•', - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ), - ); - } - parts.add(w); - } - - add( - _metaWhiteItem( - null, - context.l10n.downloadedAlbumDownloadedCount(tracks.length), - ), - ); - if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min')); - if (commonQuality != null && commonQuality.isNotEmpty) { - add(_metaWhiteItem(Icons.graphic_eq, commonQuality)); - } - - return Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runSpacing: 4, - children: parts, + return HeaderMetaRow( + items: [ + HeaderMetaItem( + context.l10n.downloadedAlbumDownloadedCount(tracks.length), + ), + if (totalMinutes > 0) HeaderMetaItem('$totalMinutes min'), + if (commonQuality != null && commonQuality.isNotEmpty) + HeaderMetaItem(commonQuality, icon: Icons.graphic_eq), + ], ); } @@ -734,84 +575,26 @@ class _DownloadedAlbumScreenState extends ConsumerState { List navigationItems, int navigationIndex, ) { - final isSelected = _selectedIds.contains(track.id); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Card( - elevation: 0, - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - margin: const EdgeInsets.symmetric(vertical: 2), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onTap: _isSelectionMode - ? () => _toggleSelection(track.id) - : () => _navigateToMetadataScreen( - track, - navigationItems: navigationItems, - navigationIndex: navigationIndex, - ), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(track.id), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_isSelectionMode) ...[ - AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - const SizedBox(width: 12), - ], - SizedBox( - width: 24, - child: Text( - track.trackNumber?.toString() ?? '-', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - title: Text( - track.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - subtitle: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - trailing: _isSelectionMode - ? null - : IconButton( - tooltip: context.l10n.tooltipPlay, - onPressed: () => - _openFile(track, queueItems: navigationItems), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - ), - ), - ), + return AlbumTrackTile( + trackNumber: track.trackNumber, + trackName: track.trackName, + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), ), + isSelectionMode: isSelectionMode, + isSelected: selectedIds.contains(track.id), + colorScheme: colorScheme, + onToggleSelection: () => toggleSelection(track.id), + onOpen: () => _navigateToMetadataScreen( + track, + navigationItems: navigationItems, + navigationIndex: navigationIndex, + ), + onEnterSelectionMode: () => enterSelectionMode(track.id), + onPlay: () => _openFile(track, queueItems: navigationItems), ); } @@ -820,7 +603,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final safUris = []; final filesToShare = []; - for (final id in _selectedIds) { + for (final id in selectedIds) { final item = tracksById[id]; if (item == null) continue; final path = item.filePath; @@ -860,7 +643,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) { final tracksById = {for (final t in tracks) t.id: t}; return [ - for (final t in _selectedIds.map((id) => tracksById[id])) + for (final t in selectedIds.map((id) => tracksById[id])) if (t != null) UnifiedLibraryItem.fromDownloadHistory(t), ]; } @@ -871,18 +654,18 @@ class _DownloadedAlbumScreenState extends ConsumerState { List tracks, double bottomPadding, ) { - final selectedCount = _selectedIds.length; + final selectedCount = selectedIds.length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; return SelectionBottomBar( selectedCount: selectedCount, allSelected: allSelected, - onClose: _exitSelectionMode, + onClose: exitSelectionMode, onToggleSelectAll: () { if (allSelected) { - _exitSelectionMode(); + exitSelectionMode(); } else { - _selectAll(tracks); + selectAll(tracks.map((e) => e.id)); } }, bottomPadding: bottomPadding, @@ -908,7 +691,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { context, ref, _selectedUnifiedItems(tracks), - onExitSelectionMode: _exitSelectionMode, + onExitSelectionMode: exitSelectionMode, ) : null, colorScheme: colorScheme, @@ -920,7 +703,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? () => runBatchReplayGain( context, _selectedUnifiedItems(tracks), - onExitSelectionMode: _exitSelectionMode, + onExitSelectionMode: exitSelectionMode, ) : null, colorScheme: colorScheme, @@ -939,29 +722,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.downloadedAlbumDeleteCount(selectedCount) - : context.l10n.downloadedAlbumSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + DestructiveSelectionButton( + count: selectedCount, + colorScheme: colorScheme, + onPressed: () => _deleteSelected(tracks), ), ], ); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index aad6b58c..db1f34d3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -40,6 +40,9 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; part 'home_tab_helpers.dart'; part 'home_tab_widgets.dart'; +bool _looksLikeUrlOrSpotifyUri(String text) => + text.startsWith('http') || text.startsWith('spotify:'); + class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @override @@ -576,7 +579,7 @@ class _HomeTabState extends ConsumerState } if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) { - if (text.startsWith('http') || text.startsWith('spotify:')) return; + if (_looksLikeUrlOrSpotifyUri(text)) return; _liveSearchDebounce?.cancel(); _liveSearchDebounce = Timer(_liveSearchDelay, () { @@ -701,7 +704,7 @@ class _HomeTabState extends ConsumerState if (data?.text != null) { _urlController.text = data!.text!; final text = data.text!.trim(); - if (text.startsWith('http') || text.startsWith('spotify:')) { + if (_looksLikeUrlOrSpotifyUri(text)) { _fetchMetadata(); } } @@ -733,7 +736,7 @@ class _HomeTabState extends ConsumerState Future _fetchMetadata() async { final url = _urlController.text.trim(); if (url.isEmpty) return; - if (url.startsWith('http') || url.startsWith('spotify:')) { + if (_looksLikeUrlOrSpotifyUri(url)) { await ref.read(trackProvider.notifier).fetchFromUrl(url); final trackState = ref.read(trackProvider); if (trackState.error != null && mounted) { @@ -1260,7 +1263,7 @@ class _HomeTabState extends ConsumerState final text = _urlController.text.trim(); if (text.isEmpty || text.length < _minLiveSearchChars) return; - if (text.startsWith('http') || text.startsWith('spotify:')) return; + if (_looksLikeUrlOrSpotifyUri(text)) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -3496,7 +3499,7 @@ class _HomeTabState extends ConsumerState void _triggerSearchWithFilter(String? filter) { final text = _urlController.text.trim(); if (text.isEmpty || text.length < _minLiveSearchChars) return; - if (text.startsWith('http') || text.startsWith('spotify:')) return; + if (_looksLikeUrlOrSpotifyUri(text)) return; _lastSearchQuery = null; _performSearch(text, filterOverride: filter); @@ -3580,7 +3583,7 @@ class _HomeTabState extends ConsumerState final text = _urlController.text.trim(); if (text.isEmpty) return; - if (text.startsWith('http') || text.startsWith('spotify:')) { + if (_looksLikeUrlOrSpotifyUri(text)) { _fetchMetadata(); _searchFocusNode.unfocus(); return; diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index fdba9840..ee97c0cd 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -19,10 +19,20 @@ import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.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'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; + +String? _resolveRawCoverUrl(Track track) { + final rawCover = track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + return null; +} class LibraryTracksFolderScreen extends ConsumerStatefulWidget { final LibraryTracksFolderMode mode; @@ -83,20 +93,10 @@ class _LibraryTracksFolderScreenState }; } - String? _resolveRawEntryCoverUrl(CollectionTrackEntry entry) { - final rawCover = entry.track.coverUrl?.trim(); - if (rawCover != null && - rawCover.isNotEmpty && - !rawCover.startsWith('content://')) { - return rawCover; - } - return null; - } - /// Find the first available cover URL from entries. String? _firstRawCoverUrl(List entries) { for (final entry in entries) { - final cover = _resolveRawEntryCoverUrl(entry); + final cover = _resolveRawCoverUrl(entry.track); if (cover != null && cover.isNotEmpty) { return cover; } @@ -712,142 +712,25 @@ class _LibraryTracksFolderScreenState } void _confirmDownloadAll(List tracks) { - if (tracks.isEmpty) return; - showDialog( - context: context, - builder: (dialogContext) { - final colorScheme = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, - title: Text(context.l10n.dialogDownloadAllTitle), - content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - _downloadAll(tracks); - }, - child: Text(context.l10n.dialogDownload), - ), - ], - ); - }, - ); + confirmDownloadAllDialog(context, tracks.length, () => _downloadAll(tracks)); } Future _downloadAll(List tracks) async { - if (tracks.isEmpty) return; - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); - if (!mounted) return; - final settings = ref.read(settingsProvider); - final localLibState = - (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) - ? ref.read(localLibraryProvider) - : null; final playlistName = widget.mode == LibraryTracksFolderMode.playlist ? playlist?.name ?? context.l10n.collectionPlaylist : null; - final tracksToQueue = []; - var skippedCount = 0; - - for (var i = 0; i < tracks.length; i++) { - final track = tracks[i]; - final isInHistory = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); - final isInLocal = - localLibState?.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ) ?? - false; - - if (isInHistory || isInLocal) { - skippedCount++; - } else { - tracksToQueue.add(track); - } - } - - if (tracksToQueue.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.discographySkippedDownloaded(0, skippedCount), - ), - ), - ); - return; - } - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: '${tracksToQueue.length} tracks', - artistName: switch (widget.mode) { - LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, - LibraryTracksFolderMode.loved => context.l10n.collectionLoved, - LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist, - }, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - playlistName: playlistName, - ); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - skippedCount > 0 - ? context.l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedCount, - ) - : context.l10n.snackbarAddedTracksToQueue( - tracksToQueue.length, - ), - ), - ), - ); - }, - ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - settings.defaultService, - playlistName: playlistName, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - skippedCount > 0 - ? context.l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedCount, - ) - : context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length), - ), - ), - ); - } + await queueTracksSkippingDownloaded( + context, + ref, + tracks, + artistNameForPicker: switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist, + }, + playlistName: playlistName, + resolveDefaultService: false, + ); } void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { @@ -1186,16 +1069,6 @@ class _CollectionTrackTile extends ConsumerWidget { ); } - String? _resolveRawCoverUrl(Track track) { - final rawCover = track.coverUrl?.trim(); - if (rawCover != null && - rawCover.isNotEmpty && - !rawCover.startsWith('content://')) { - return rawCover; - } - return null; - } - Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { final colorScheme = Theme.of(context).colorScheme; Widget placeholder() => Container( @@ -1217,44 +1090,7 @@ class _CollectionTrackTile extends ConsumerWidget { } void _downloadTrack(BuildContext context, WidgetRef ref) { - final track = entry.track; - final settings = ref.read(settingsProvider); - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), - ); - }, - ); - } else { - 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))), - ); - } + downloadSingleTrack(context, ref, entry.track); } Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 6efb3d7e..76049d6c 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,13 +1,13 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.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/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; @@ -23,7 +23,12 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; +import 'package:spotiflac_android/widgets/album_scaffold_body.dart'; +import 'package:spotiflac_android/widgets/album_track_tile.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/disc_separator_chip.dart'; @@ -47,11 +52,10 @@ class LocalAlbumScreen extends ConsumerStatefulWidget { ConsumerState createState() => _LocalAlbumScreenState(); } -class _LocalAlbumScreenState extends ConsumerState { - bool _isSelectionMode = false; - final Set _selectedIds = {}; - bool _showTitleInAppBar = false; - final ScrollController _scrollController = ScrollController(); +class _LocalAlbumScreenState extends ConsumerState + with + SelectionModeMixin, + CollapsingHeaderScrollMixin { late List _sortedTracksCache; late Map> _discGroupsCache; @@ -68,7 +72,6 @@ class _LocalAlbumScreenState extends ConsumerState { @override void initState() { super.initState(); - _scrollController.addListener(_onScroll); _rebuildTrackCaches(); } @@ -81,27 +84,6 @@ class _LocalAlbumScreenState extends ConsumerState { } } - @override - void dispose() { - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = - _scrollController.offset > (expandedHeight - kToolbarHeight - 20); - if (shouldShow != _showTitleInAppBar) { - setState(() => _showTitleInAppBar = shouldShow); - } - } - - double _calculateExpandedHeight(BuildContext context) { - final mediaSize = MediaQuery.sizeOf(context); - return (mediaSize.height * 0.6).clamp(400.0, 580.0); - } - List _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { @@ -135,95 +117,29 @@ class _LocalAlbumScreenState extends ConsumerState { return discMap; } - void _enterSelectionMode(String itemId) { - HapticFeedback.mediumImpact(); - setState(() { - _isSelectionMode = true; - _selectedIds.add(itemId); - }); - } - - void _exitSelectionMode() { - setState(() { - _isSelectionMode = false; - _selectedIds.clear(); - }); - } - - void _toggleSelection(String itemId) { - setState(() { - if (_selectedIds.contains(itemId)) { - _selectedIds.remove(itemId); - if (_selectedIds.isEmpty) { - _isSelectionMode = false; - } - } else { - _selectedIds.add(itemId); - } - }); - } - - void _selectAll(List tracks) { - setState(() { - _selectedIds.addAll(tracks.map((e) => e.id)); - }); - } - Future _deleteSelected(List currentTracks) async { - final count = _selectedIds.length; - final confirmed = await showDialog( + final libraryNotifier = ref.read(localLibraryProvider.notifier); + final tracksById = {for (final track in currentTracks) track.id: track}; + + final deletedCount = await confirmAndDeleteTracks( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.downloadedAlbumDeleteSelected), - content: Text(context.l10n.downloadedAlbumDeleteMessage(count)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - ), - child: Text(context.l10n.dialogDelete), - ), - ], - ), + ids: selectedIds.toList(), + deleteItem: (id) async { + final item = tracksById[id]; + if (item == null) return false; + if (!isCueVirtualPath(item.filePath)) { + try { + await deleteFile(item.filePath); + } catch (_) {} + } + await libraryNotifier.removeItem(id); + return true; + }, + onExitSelectionMode: exitSelectionMode, ); - if (confirmed == true && mounted) { - final libraryNotifier = ref.read(localLibraryProvider.notifier); - final idsToDelete = _selectedIds.toList(); - final tracksById = {for (final track in currentTracks) track.id: track}; - - int deletedCount = 0; - for (final id in idsToDelete) { - final item = tracksById[id]; - if (item != null) { - if (!isCueVirtualPath(item.filePath)) { - try { - await deleteFile(item.filePath); - } catch (_) {} - } - await libraryNotifier.removeItem(id); - deletedCount++; - } - } - - _exitSelectionMode(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarDeletedTracks(deletedCount)), - ), - ); - - if (deletedCount == currentTracks.length) { - Navigator.pop(context); - } - } + if (deletedCount == currentTracks.length && mounted) { + Navigator.pop(context); } } @@ -261,57 +177,27 @@ class _LocalAlbumScreenState extends ConsumerState { ); } - final validIds = tracks.map((t) => t.id).toSet(); - _selectedIds.removeWhere((id) => !validIds.contains(id)); - if (_selectedIds.isEmpty && _isSelectionMode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _isSelectionMode = false); - }); - } + pruneSelection(tracks.map((t) => t.id).toSet()); - return PopScope( - canPop: !_isSelectionMode, - onPopInvokedWithResult: (didPop, result) { - if (!didPop && _isSelectionMode) { - _exitSelectionMode(); - } - }, - child: Scaffold( - body: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - _buildAppBar(context, colorScheme), - _buildTrackList(context, colorScheme, tracks), - SliverToBoxAdapter( - child: SizedBox(height: _isSelectionMode ? 120 : 32), - ), - SliverToBoxAdapter(child: SizedBox(height: bottomInset)), - ], - ), - - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _buildSelectionBottomBar( - context, - colorScheme, - tracks, - bottomPadding, - ), - ), - ], - ), + return AlbumScaffoldBody( + scrollController: scrollController, + isSelectionMode: isSelectionMode, + onExitSelectionMode: exitSelectionMode, + appBar: _buildAppBar(context, colorScheme), + trackList: _buildTrackList(context, colorScheme, tracks), + bottomBar: _buildSelectionBottomBar( + context, + colorScheme, + tracks, + bottomPadding, ), + bottomInset: bottomInset, + bottomPadding: bottomPadding, ); } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final expandedHeight = _calculateExpandedHeight(context); + final expandedHeight = calculateExpandedHeight(context); final cacheWidth = coverCacheWidthForViewport(context); final Widget background = widget.coverPath != null @@ -335,7 +221,7 @@ class _LocalAlbumScreenState extends ConsumerState { return AlbumDetailHeader( title: widget.albumName, expandedHeight: expandedHeight, - showTitleInAppBar: _showTitleInAppBar, + showTitleInAppBar: showTitleInAppBar, background: background, blurAndScrimBackground: widget.coverPath != null, coverBuilder: (context, coverSize) => widget.coverPath != null @@ -384,23 +270,6 @@ class _LocalAlbumScreenState extends ConsumerState { ); } - Widget _metaWhiteItem(IconData? icon, String label) { - const textStyle = TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ); - if (icon == null) return Text(label, style: textStyle); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: Colors.white), - const SizedBox(width: 4), - Text(label, style: textStyle), - ], - ); - } - Widget _buildLocalHeaderMeta(BuildContext context) { final tracks = _sortedTracksCache; final totalSeconds = tracks.fold( @@ -408,35 +277,15 @@ class _LocalAlbumScreenState extends ConsumerState { (sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0), ); final totalMinutes = (totalSeconds / 60).round(); - - final parts = []; - void add(Widget w) { - if (parts.isNotEmpty) { - parts.add( - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: Text( - '•', - style: TextStyle(color: Colors.white70, fontSize: 12), - ), - ), - ); - } - parts.add(w); - } - - add(_metaWhiteItem(null, context.l10n.queueTrackCount(tracks.length))); - if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min')); final quality = _commonQualityCache; - if (quality != null && quality.isNotEmpty) { - add(_metaWhiteItem(Icons.graphic_eq, quality)); - } - return Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runSpacing: 4, - children: parts, + return HeaderMetaRow( + items: [ + HeaderMetaItem(context.l10n.queueTrackCount(tracks.length)), + if (totalMinutes > 0) HeaderMetaItem('$totalMinutes min'), + if (quality != null && quality.isNotEmpty) + HeaderMetaItem(quality, icon: Icons.graphic_eq), + ], ); } @@ -531,101 +380,44 @@ class _LocalAlbumScreenState extends ConsumerState { ColorScheme colorScheme, LocalLibraryItem track, ) { - final isSelected = _selectedIds.contains(track.id); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Card( - elevation: 0, - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - margin: const EdgeInsets.symmetric(vertical: 2), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + return AlbumTrackTile( + trackNumber: track.trackNumber, + trackName: track.trackName, + subtitle: Row( + children: [ + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), ), - onTap: _isSelectionMode - ? () => _toggleSelection(track.id) - : () => _openFile(track), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(track.id), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_isSelectionMode) ...[ - AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - const SizedBox(width: 12), - ], - SizedBox( - width: 24, - child: Text( - track.trackNumber?.toString() ?? '-', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), + if (track.format != null) ...[ + Text( + ' • ', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, ), - ], - ), - title: Text( - track.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - subtitle: Row( - children: [ - Flexible( - child: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), + ), + Text( + track.format!.toUpperCase(), + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, ), - if (track.format != null) ...[ - Text( - ' • ', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - Text( - track.format!.toUpperCase(), - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ], - ], - ), - trailing: _isSelectionMode - ? null - : IconButton( - tooltip: context.l10n.tooltipPlay, - onPressed: () => _openFile(track), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - ), - ), - ), + ), + ], + ], ), + isSelectionMode: isSelectionMode, + isSelected: selectedIds.contains(track.id), + colorScheme: colorScheme, + onToggleSelection: () => toggleSelection(track.id), + onOpen: () => _openFile(track), + onEnterSelectionMode: () => enterSelectionMode(track.id), + onPlay: () => _openFile(track), ); } @@ -816,7 +608,7 @@ class _LocalAlbumScreenState extends ConsumerState { List allTracks, ) { final tracksById = {for (final t in allTracks) t.id: t}; - return _selectedIds + return selectedIds .map((id) => tracksById[id]) .whereType() .where(LocalTrackRedownloadService.isFlacUpgradeEligible) @@ -944,17 +736,14 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(summary))); - setState(() { - _selectedIds.clear(); - _isSelectionMode = false; - }); + exitSelectionMode(); } Future _reEnrichSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; - for (final id in _selectedIds) { + for (final id in selectedIds) { final item = tracksById[id]; if (item != null) { selected.add(item); @@ -966,7 +755,7 @@ class _LocalAlbumScreenState extends ConsumerState { } // The bar uses AnimatedPositioned (250ms), so wait for the slide-out. - setState(() => _isSelectionMode = false); + setState(() => isSelectionMode = false); await Future.delayed(const Duration(milliseconds: 300)); if (!mounted) return; @@ -977,7 +766,7 @@ class _LocalAlbumScreenState extends ConsumerState { if (selection == null || !mounted) { // Cancelled — restore selection mode (IDs are still intact). - if (mounted) setState(() => _isSelectionMode = true); + if (mounted) setState(() => isSelectionMode = true); return; } @@ -1038,7 +827,7 @@ class _LocalAlbumScreenState extends ConsumerState { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } - _exitSelectionMode(); + exitSelectionMode(); if (!mounted) { return; @@ -1066,7 +855,7 @@ class _LocalAlbumScreenState extends ConsumerState { ) { final tracksById = {for (final t in tracks) t.id: t}; return [ - for (final t in _selectedIds.map((id) => tracksById[id])) + for (final t in selectedIds.map((id) => tracksById[id])) if (t != null) UnifiedLibraryItem.fromLocalLibrary(t), ]; } @@ -1077,19 +866,19 @@ class _LocalAlbumScreenState extends ConsumerState { List tracks, double bottomPadding, ) { - final selectedCount = _selectedIds.length; + final selectedCount = selectedIds.length; final flacEligibleCount = _selectedFlacEligibleItems(tracks).length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; return SelectionBottomBar( selectedCount: selectedCount, allSelected: allSelected, - onClose: _exitSelectionMode, + onClose: exitSelectionMode, onToggleSelectAll: () { if (allSelected) { - _exitSelectionMode(); + exitSelectionMode(); } else { - _selectAll(tracks); + selectAll(tracks.map((e) => e.id)); } }, bottomPadding: bottomPadding, @@ -1131,7 +920,7 @@ class _LocalAlbumScreenState extends ConsumerState { context, ref, _selectedUnifiedItems(tracks), - onExitSelectionMode: _exitSelectionMode, + onExitSelectionMode: exitSelectionMode, ) : null, colorScheme: colorScheme, @@ -1146,7 +935,7 @@ class _LocalAlbumScreenState extends ConsumerState { ? () => runBatchReplayGain( context, _selectedUnifiedItems(tracks), - onExitSelectionMode: _exitSelectionMode, + onExitSelectionMode: exitSelectionMode, ) : null, colorScheme: colorScheme, @@ -1165,29 +954,10 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, - icon: const Icon(Icons.delete_outline), - label: Text( - selectedCount > 0 - ? context.l10n.downloadedAlbumDeleteCount(selectedCount) - : context.l10n.downloadedAlbumSelectToDelete, - ), - style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 - ? colorScheme.error - : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 - ? colorScheme.onError - : colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), + DestructiveSelectionButton( + count: selectedCount, + colorScheme: colorScheme, + onPressed: () => _deleteSelected(tracks), ), ], ); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index f77e788e..e83a1905 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -11,14 +11,13 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/provider_resource_ids.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/providers/local_library_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/animation_utils.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; +import 'package:spotiflac_android/widgets/error_card.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -295,66 +294,13 @@ class _PlaylistScreenState extends ConsumerState { ); } - String _formatReleaseDate(String date) { - if (date.length >= 10) { - final parts = date.substring(0, 10).split('-'); - if (parts.length == 3) { - return '${parts[2]}/${parts[1]}/${parts[0]}'; - } - } else if (date.length >= 7) { - final parts = date.split('-'); - if (parts.length >= 2) { - return '${parts[1]}/${parts[0]}'; - } - } - return date; - } - - Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) { - final tracks = _tracks; - if (tracks.isEmpty) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - final releaseDate = tracks.first.releaseDate; - final totalSeconds = tracks.fold( - 0, - (sum, t) => sum + (t.duration > 0 ? t.duration : 0), - ); - final totalMinutes = (totalSeconds / 60).round(); - - final lines = []; - if (releaseDate != null && - releaseDate.isNotEmpty && - !releaseDate.startsWith('1970')) { - lines.add(_formatReleaseDate(releaseDate)); - } - final countText = context.l10n.tracksCount(tracks.length); - lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final line in lines) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - line, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ); - } + Widget _buildPlaylistFooter(BuildContext context, ColorScheme colorScheme) => + buildTrackListFooter( + context, + colorScheme, + _tracks, + excludeEpochReleaseDate: true, + ); Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { @@ -370,24 +316,7 @@ class _PlaylistScreenState extends ConsumerState { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), - child: Card( - color: colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Text( - _error!, - style: TextStyle(color: colorScheme.error), - ), - ), - ], - ), - ), - ), + child: ErrorCard(error: _error!, colorScheme: colorScheme), ), ); } @@ -464,56 +393,14 @@ class _PlaylistScreenState extends ConsumerState { Track track, { int? playlistPosition, }) { - final settings = ref.read(settingsProvider); - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - recommendedService: _recommendedDownloadService(), - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addToQueue( - track, - service, - qualityOverride: quality, - playlistName: _playlistName, - playlistPosition: playlistPosition, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedToQueue(track.name)), - ), - ); - }, - ); - } else { - 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, - playlistName: _playlistName, - playlistPosition: playlistPosition, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } + downloadSingleTrack( + context, + ref, + track, + recommendedService: _recommendedDownloadService(), + playlistName: _playlistName, + playlistPosition: playlistPosition, + ); } Widget _buildCircleButton({ @@ -606,161 +493,28 @@ class _PlaylistScreenState extends ConsumerState { } void _confirmDownloadAll(BuildContext context) { - if (_tracks.isEmpty) return; - showDialog( - context: context, - builder: (dialogContext) { - final colorScheme = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, - title: Text(context.l10n.dialogDownloadAllTitle), - content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - _downloadAll(context); - }, - child: Text(context.l10n.dialogDownload), - ), - ], - ); - }, + confirmDownloadAllDialog( + context, + _tracks.length, + () => _downloadAll(context), ); } - Future _loveAll(List tracks) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final state = ref.read(libraryCollectionsProvider); - final allLoved = tracks.every((t) => state.isLoved(t)); - - if (allLoved) { - for (final track in tracks) { - final key = trackCollectionKey(track); - await notifier.removeFromLoved(key); - } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarRemovedTracksFromLoved(tracks.length), - ), - ), - ); - } - } else { - int addedCount = 0; - for (final track in tracks) { - if (!state.isLoved(track)) { - await notifier.toggleLoved(track); - addedCount++; - } - } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), - ), - ); - } - } - } + Future _loveAll(List tracks) => loveAllTracks(context, ref, tracks); void _downloadAll(BuildContext context) { _downloadTracks(context, _tracks); } Future _downloadTracks(BuildContext context, List tracks) async { - if (tracks.isEmpty) return; - - final historyLookups = tracks - .map(historyLookupForTrack) - .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); - if (!context.mounted) return; - final settings = ref.read(settingsProvider); - final localLibState = - (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) - ? ref.read(localLibraryProvider) - : null; - final tracksToQueue = []; - int skippedCount = 0; - - for (var i = 0; i < tracks.length; i++) { - final track = tracks[i]; - final isInHistory = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); - final isInLocal = - localLibState?.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ) ?? - false; - - if (isInHistory || isInLocal) { - skippedCount++; - } else { - tracksToQueue.add(track); - } - } - - if (tracksToQueue.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.discographySkippedDownloaded(0, skippedCount), - ), - ), - ); - return; - } - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - context, - trackName: '${tracksToQueue.length} tracks', - artistName: _playlistName, - recommendedService: _recommendedDownloadService(), - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - playlistName: _playlistName, - ); - _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); - }, - ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - settings.defaultService, - playlistName: _playlistName, - ); - _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); - } - } - - void _showQueuedSnackbar(BuildContext context, int added, int skipped) { - final message = skipped > 0 - ? context.l10n.discographySkippedDownloaded(added, skipped) - : context.l10n.snackbarAddedTracksToQueue(added); - ScaffoldMessenger.of( + await queueTracksSkippingDownloaded( context, - ).showSnackBar(SnackBar(content: Text(message))); + ref, + tracks, + artistNameForPicker: _playlistName, + recommendedService: _recommendedDownloadService(), + playlistName: _playlistName, + resolveDefaultService: false, + ); } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5b4e6dfb..59f7e468 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -56,9 +56,7 @@ part 'queue_tab_filter_widgets.dart'; part 'queue_tab_batch_actions.dart'; part 'queue_tab_item_widgets.dart'; -String _formatDownloadSizeMB(num bytes) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; -} +String _formatDownloadSizeMB(num bytes) => '${formatMegabytes(bytes)} MB'; String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) { final progress = item.progress.clamp(0.0, 1.0); diff --git a/lib/screens/selection_mode_mixin.dart b/lib/screens/selection_mode_mixin.dart new file mode 100644 index 00000000..436b41e1 --- /dev/null +++ b/lib/screens/selection_mode_mixin.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shared multi-select state for list screens: enter/exit selection mode, +/// toggle individual items, select all, and prune ids that fell out of the +/// current item list. Mix into a `State` alongside the screen's own +/// per-item lookups (e.g. queue_tab, library_folder, artist screens). +mixin SelectionModeMixin on State { + bool isSelectionMode = false; + final Set selectedIds = {}; + + void enterSelectionMode(String itemId) { + HapticFeedback.mediumImpact(); + setState(() { + isSelectionMode = true; + selectedIds.add(itemId); + }); + } + + void exitSelectionMode() { + setState(() { + isSelectionMode = false; + selectedIds.clear(); + }); + } + + void toggleSelection(String itemId) { + setState(() { + if (selectedIds.contains(itemId)) { + selectedIds.remove(itemId); + if (selectedIds.isEmpty) { + isSelectionMode = false; + } + } else { + selectedIds.add(itemId); + } + }); + } + + void selectAll(Iterable ids) { + setState(() { + selectedIds.addAll(ids); + }); + } + + /// Drops ids no longer present in [validIds]; call from build() with the + /// current item ids. Exits selection mode next frame if that empties it. + void pruneSelection(Set validIds) { + selectedIds.removeWhere((id) => !validIds.contains(id)); + if (selectedIds.isEmpty && isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => isSelectionMode = false); + }); + } + } +} diff --git a/lib/utils/confirm_and_delete_tracks.dart b/lib/utils/confirm_and_delete_tracks.dart new file mode 100644 index 00000000..1cca1a92 --- /dev/null +++ b/lib/utils/confirm_and_delete_tracks.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Shows a delete-confirmation dialog, deletes the given [ids] one by one via +/// [deleteItem], then shows a "deleted N tracks" snackbar. +/// [onExitSelectionMode] runs right after the delete loop, matching the +/// screens' original ordering. +/// +/// Returns the number of items [deleteItem] reported deleted, or null if the +/// user cancelled the dialog. +Future confirmAndDeleteTracks({ + required BuildContext context, + required List ids, + required Future Function(String id) deleteItem, + required VoidCallback onExitSelectionMode, +}) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.downloadedAlbumDeleteSelected), + content: Text(context.l10n.downloadedAlbumDeleteMessage(ids.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogDelete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return null; + + var deletedCount = 0; + for (final id in ids) { + if (await deleteItem(id)) deletedCount++; + } + + onExitSelectionMode(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), + ); + } + + return deletedCount; +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 4559596a..c4e61def 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -55,6 +55,11 @@ String? normalizeRemoteHttpUrl(String? value) { return null; } +/// Byte count expressed as a plain megabyte number with 1 decimal, e.g. "3.4". +String formatMegabytes(num bytes) { + return (bytes / (1024 * 1024)).toStringAsFixed(1); +} + /// Human-readable byte size: "512 B", "3.4 KB", "12.0 MB", "1.25 GB". String formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; diff --git a/lib/utils/ttl_cache.dart b/lib/utils/ttl_cache.dart new file mode 100644 index 00000000..199c8f16 --- /dev/null +++ b/lib/utils/ttl_cache.dart @@ -0,0 +1,27 @@ +/// Simple in-memory cache where each entry expires after [ttl]. +class TtlCache { + final Duration ttl; + final Map> _entries = {}; + + TtlCache(this.ttl); + + T? get(String key) { + final entry = _entries[key]; + if (entry == null) return null; + if (DateTime.now().isAfter(entry.expiresAt)) { + _entries.remove(key); + return null; + } + return entry.value; + } + + void set(String key, T value) { + _entries[key] = _TtlEntry(value, DateTime.now().add(ttl)); + } +} + +class _TtlEntry { + final T value; + final DateTime expiresAt; + _TtlEntry(this.value, this.expiresAt); +} diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index cc0646c2..32ba16d6 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -207,7 +207,8 @@ class AlbumDetailHeader extends StatelessWidget { ); }, ), - leading: leading ?? + leading: + leading ?? IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( @@ -250,7 +251,11 @@ class AlbumPlayActions extends StatelessWidget { child: FilledButton.icon( onPressed: onPlay, icon: const Icon(Icons.play_arrow, size: 20), - label: Text(playLabel, maxLines: 1, overflow: TextOverflow.ellipsis), + label: Text( + playLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -277,3 +282,63 @@ class AlbumPlayActions extends StatelessWidget { ); } } + +/// "•"-joined row of white [HeaderMetaItem]s shown under the header subtitle +/// (track count, duration, quality, ...). +class HeaderMetaRow extends StatelessWidget { + const HeaderMetaRow({super.key, required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + final parts = []; + for (final item in items) { + if (parts.isNotEmpty) { + parts.add( + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text( + '•', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + ); + } + parts.add(item); + } + + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: parts, + ); + } +} + +/// Single white meta label (optional leading icon) for [HeaderMetaRow]. +class HeaderMetaItem extends StatelessWidget { + const HeaderMetaItem(this.label, {super.key, this.icon}); + + final String label; + final IconData? icon; + + @override + Widget build(BuildContext context) { + const textStyle = TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ); + if (icon == null) return Text(label, style: textStyle); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 4), + Text(label, style: textStyle), + ], + ); + } +} diff --git a/lib/widgets/album_scaffold_body.dart b/lib/widgets/album_scaffold_body.dart new file mode 100644 index 00000000..66f90776 --- /dev/null +++ b/lib/widgets/album_scaffold_body.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +/// Shared body for album-style screens: a [PopScope] that exits selection +/// mode instead of popping, a scrollable track list, and a bottom-select bar +/// that slides in over it. +class AlbumScaffoldBody extends StatelessWidget { + const AlbumScaffoldBody({ + super.key, + required this.scrollController, + required this.isSelectionMode, + required this.onExitSelectionMode, + required this.appBar, + required this.trackList, + required this.bottomBar, + required this.bottomInset, + required this.bottomPadding, + }); + + final ScrollController scrollController; + final bool isSelectionMode; + final VoidCallback onExitSelectionMode; + final Widget appBar; + final Widget trackList; + final Widget bottomBar; + final double bottomInset; + final double bottomPadding; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && isSelectionMode) { + onExitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: scrollController, + slivers: [ + appBar, + trackList, + SliverToBoxAdapter( + child: SizedBox(height: isSelectionMode ? 120 : 32), + ), + SliverToBoxAdapter(child: SizedBox(height: bottomInset)), + ], + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: isSelectionMode ? 0 : -(200 + bottomPadding), + child: bottomBar, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/album_track_tile.dart b/lib/widgets/album_track_tile.dart new file mode 100644 index 00000000..acaaff1c --- /dev/null +++ b/lib/widgets/album_track_tile.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; + +/// Shared track row for album-style screens: track number, selection +/// checkbox, title/subtitle, and a trailing play button that's replaced by +/// the checkbox once selection mode is active. +class AlbumTrackTile extends StatelessWidget { + const AlbumTrackTile({ + super.key, + required this.trackNumber, + required this.trackName, + required this.subtitle, + required this.isSelectionMode, + required this.isSelected, + required this.colorScheme, + required this.onToggleSelection, + required this.onOpen, + required this.onEnterSelectionMode, + required this.onPlay, + }); + + final int? trackNumber; + final String trackName; + final Widget subtitle; + final bool isSelectionMode; + final bool isSelected; + final ColorScheme colorScheme; + final VoidCallback onToggleSelection; + final VoidCallback onOpen; + final VoidCallback onEnterSelectionMode; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: isSelectionMode ? onToggleSelection : onOpen, + onLongPress: isSelectionMode ? null : onEnterSelectionMode, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelectionMode) ...[ + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + const SizedBox(width: 12), + ], + SizedBox( + width: 24, + child: Text( + trackNumber?.toString() ?? '-', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + title: Text( + trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: subtitle, + trailing: isSelectionMode + ? null + : IconButton( + tooltip: context.l10n.tooltipPlay, + onPressed: onPlay, + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/destructive_selection_button.dart b/lib/widgets/destructive_selection_button.dart new file mode 100644 index 00000000..3ee2cd03 --- /dev/null +++ b/lib/widgets/destructive_selection_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Full-width delete action for selection-mode bottom bars: destructive +/// (error) styling once at least one item is selected, disabled/neutral +/// otherwise. +class DestructiveSelectionButton extends StatelessWidget { + const DestructiveSelectionButton({ + super.key, + required this.count, + required this.colorScheme, + required this.onPressed, + }); + + final int count; + final ColorScheme colorScheme; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: count > 0 ? onPressed : null, + icon: const Icon(Icons.delete_outline), + label: Text( + count > 0 + ? context.l10n.downloadedAlbumDeleteCount(count) + : context.l10n.downloadedAlbumSelectToDelete, + ), + style: FilledButton.styleFrom( + backgroundColor: count > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: count > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart new file mode 100644 index 00000000..3be26bdd --- /dev/null +++ b/lib/widgets/track_detail_actions.dart @@ -0,0 +1,345 @@ +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/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; + +/// Shared single-track "add to queue" flow for detail screens: shows the +/// quality/service picker when the user opted into it, otherwise resolves +/// the default service and enqueues directly. +void downloadSingleTrack( + BuildContext context, + WidgetRef ref, + Track track, { + String? recommendedService, + String? playlistName, + int? playlistPosition, +}) { + final settings = ref.read(settingsProvider); + + void notifyQueued() { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + recommendedService: recommendedService, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue( + track, + service, + qualityOverride: quality, + playlistName: playlistName, + playlistPosition: playlistPosition, + ); + notifyQueued(); + }, + ); + } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + ref + .read(downloadQueueProvider.notifier) + .addToQueue( + track, + service, + playlistName: playlistName, + playlistPosition: playlistPosition, + ); + notifyQueued(); + } +} + +/// Shared "download all" confirmation dialog for detail screens +/// (album/playlist/library-folder). +void confirmDownloadAllDialog( + BuildContext context, + int trackCount, + VoidCallback onConfirm, +) { + if (trackCount == 0) return; + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: Text(context.l10n.dialogDownloadAllTitle), + content: Text(context.l10n.dialogDownloadAllMessage(trackCount)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + onConfirm(); + }, + child: Text(context.l10n.dialogDownload), + ), + ], + ); + }, + ); +} + +/// Shared "added N / skipped N" snackbar shown after queueing a batch of +/// tracks from album/playlist/library-folder detail screens. +void showQueuedSnackbar(BuildContext context, int added, int skipped) { + if (!context.mounted) return; + final message = skipped > 0 + ? context.l10n.discographySkippedDownloaded(added, skipped) + : context.l10n.snackbarAddedTracksToQueue(added); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); +} + +/// Shared batch "add to queue" flow for detail screens: skips tracks already +/// present in download history or the local library, then either shows the +/// quality/service picker or resolves a default service, before enqueueing +/// the rest. +/// +/// Set [resolveDefaultService] to false to match screens that pass +/// `settings.defaultService` straight through without resolving it against +/// enabled extensions (playlist / library-folder screens do this today). +Future queueTracksSkippingDownloaded( + BuildContext context, + WidgetRef ref, + List tracks, { + required String artistNameForPicker, + String? recommendedService, + String? playlistName, + bool resolveDefaultService = true, +}) async { + if (tracks.isEmpty) return; + + final historyLookups = tracks + .map(historyLookupForTrack) + .toList(growable: false); + final existingHistoryKeys = await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ); + if (!context.mounted) return; + final settings = ref.read(settingsProvider); + final localLibState = + (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + ? ref.read(localLibraryProvider) + : null; + final tracksToQueue = []; + var skippedCount = 0; + + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + final isInHistory = existingHistoryKeys.contains( + historyLookups[i].lookupKey, + ); + final isInLocal = + localLibState?.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ) ?? + false; + + if (isInHistory || isInLocal) { + skippedCount++; + } else { + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), + ), + ); + return; + } + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracksToQueue.length} tracks', + artistName: artistNameForPicker, + recommendedService: recommendedService, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + playlistName: playlistName, + ); + showQueuedSnackbar(context, tracksToQueue.length, skippedCount); + }, + ); + return; + } + + String service; + if (resolveDefaultService) { + final extensionState = ref.read(extensionProvider); + service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + } else { + service = settings.defaultService; + } + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracksToQueue, service, playlistName: playlistName); + showQueuedSnackbar(context, tracksToQueue.length, skippedCount); +} + +/// "dd/MM/yyyy" or "MM/yyyy" formatting for a backend release date string. +String formatReleaseDate(String date) { + if (date.length >= 10) { + final parts = date.substring(0, 10).split('-'); + if (parts.length == 3) { + return '${parts[2]}/${parts[1]}/${parts[0]}'; + } + } else if (date.length >= 7) { + final parts = date.split('-'); + if (parts.length >= 2) { + return '${parts[1]}/${parts[0]}'; + } + } + return date; +} + +/// Shared release-date/track-count/duration footer for album/playlist detail +/// screens. Set [excludeEpochReleaseDate] to hide a `1970-...` placeholder +/// release date (playlists can carry one; albums never do). +Widget buildTrackListFooter( + BuildContext context, + ColorScheme colorScheme, + List tracks, { + bool excludeEpochReleaseDate = false, +}) { + if (tracks.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final releaseDate = tracks.first.releaseDate; + final totalSeconds = tracks.fold( + 0, + (sum, t) => sum + (t.duration > 0 ? t.duration : 0), + ); + final totalMinutes = (totalSeconds / 60).round(); + + final lines = []; + final hasReleaseDate = + releaseDate != null && + releaseDate.isNotEmpty && + (!excludeEpochReleaseDate || !releaseDate.startsWith('1970')); + if (hasReleaseDate) { + lines.add(formatReleaseDate(releaseDate)); + } + final countText = context.l10n.tracksCount(tracks.length); + lines.add(totalMinutes > 0 ? '$countText • $totalMinutes min' : countText); + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + line, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); +} + +/// Shared "love all / unlove all" batch toggle for album/playlist detail +/// screens. +Future loveAllTracks( + BuildContext context, + WidgetRef ref, + List tracks, +) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarRemovedTracksFromLoved(tracks.length), + ), + ), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), + ), + ); + } + } +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 0c6d8c67..0eea31f7 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/services/apk_downloader.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; class UpdateDialog extends StatefulWidget { final UpdateInfo updateInfo; @@ -63,8 +64,8 @@ class _UpdateDialogState extends State { if (mounted) { setState(() { _progress = total > 0 ? received / total : 0; - final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); - final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + final receivedMB = formatMegabytes(received); + final totalMB = formatMegabytes(total); _statusText = '$receivedMB / $totalMB MB'; }); }