From f0790b627df39f63318efc3ee782a0006bc02b57 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 8 Feb 2026 15:00:57 +0700 Subject: [PATCH] perf: optimize album, artist, and playlist screens - Scope settingsProvider watches with select() for localLibrary flags - Wrap popular track items in Consumer for scoped provider watches - Apply dart format reformatting --- lib/screens/album_screen.dart | 522 ++++++++++++----- lib/screens/artist_screen.dart | 936 ++++++++++++++++++++----------- lib/screens/playlist_screen.dart | 465 +++++++++++---- 3 files changed, 1339 insertions(+), 584 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ebfa027..65f3b9a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,5 @@ import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -14,7 +15,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; -import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; +import 'package:spotiflac_android/screens/home_tab.dart' + show ExtensionArtistScreen; class _AlbumCache { static final Map _cache = {}; @@ -76,29 +78,32 @@ class _AlbumScreenState extends ConsumerState { @override void initState() { super.initState(); - + _scrollController.addListener(_onScroll); - + WidgetsBinding.instance.addPostFrameCallback((_) { // Use extensionId if available, otherwise detect from albumId prefix - final providerId = widget.extensionId ?? + final providerId = + widget.extensionId ?? (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); - ref.read(recentAccessProvider.notifier).recordAlbumAccess( - id: widget.albumId, - name: widget.albumName, - artistName: widget.tracks?.firstOrNull?.artistName, - imageUrl: widget.coverUrl, - providerId: providerId, -); + ref + .read(recentAccessProvider.notifier) + .recordAlbumAccess( + id: widget.albumId, + name: widget.albumName, + artistName: widget.tracks?.firstOrNull?.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); }); - + if (widget.tracks != null && widget.tracks!.isNotEmpty) { _tracks = widget.tracks; } else { _tracks = _AlbumCache.get(widget.albumId); } _artistId = widget.artistId; - + if (_tracks == null || _tracks!.isEmpty) { _fetchTracks(); } @@ -133,27 +138,32 @@ class _AlbumScreenState extends ConsumerState { return date; } -Future _fetchTracks() async { + Future _fetchTracks() async { setState(() => _isLoading = true); try { Map metadata; - + if (widget.albumId.startsWith('deezer:')) { final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); - metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); + metadata = await PlatformBridge.getDeezerMetadata( + 'album', + deezerAlbumId, + ); } else { final url = 'https://open.spotify.com/album/${widget.albumId}'; metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); } - + final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); - + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final albumInfo = metadata['album_info'] as Map?; final artistId = albumInfo?['artist_id'] as String?; - + _AlbumCache.set(widget.albumId, tracks); - + if (mounted) { setState(() { _tracks = tracks; @@ -199,15 +209,19 @@ Future _fetchTracks() async { _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), if (_isLoading) - const SliverToBoxAdapter(child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - )), - if (_error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), - )), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildErrorWidget(_error!, colorScheme), + ), + ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ _buildTrackListHeader(context, colorScheme), _buildTrackList(context, colorScheme, tracks), @@ -221,7 +235,7 @@ Future _fetchTracks() async { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final screenWidth = MediaQuery.of(context).size.width; final coverSize = screenWidth * 0.5; - + return SliverAppBar( expandedHeight: 320, pinned: true, @@ -244,9 +258,10 @@ Future _fetchTracks() async { ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { - final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final showContent = collapseRatio > 0.3; - + return FlexibleSpaceBar( collapseMode: CollapseMode.none, background: Stack( @@ -258,25 +273,35 @@ Future _fetchTracks() async { imageUrl: widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => Container(color: colorScheme.surface), + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), ) else Container(color: colorScheme.surface), ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), + child: Container( + color: colorScheme.surface.withValues(alpha: 0.4), + ), ), ), Positioned( - left: 0, right: 0, bottom: 0, height: 80, + left: 0, + right: 0, + bottom: 0, + height: 80, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface], + colors: [ + colorScheme.surface.withValues(alpha: 0.0), + colorScheme.surface, + ], ), ), ), @@ -303,15 +328,19 @@ Future _fetchTracks() async { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null -? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.album, + size: 64, + color: colorScheme.onSurfaceVariant, + ), ), ), ), @@ -320,7 +349,10 @@ Future _fetchTracks() async { ), ], ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], ); }, ), @@ -328,7 +360,7 @@ Future _fetchTracks() async { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle, ), child: Icon(Icons.arrow_back, color: colorScheme.onSurface), @@ -338,18 +370,20 @@ Future _fetchTracks() async { ); } -Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { final tracks = _tracks ?? []; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; - + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: Card( elevation: 0, color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -357,7 +391,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { children: [ Text( widget.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), ), if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 4), @@ -378,27 +415,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { runSpacing: 8, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), + Icon( + Icons.music_note, + size: 14, + color: colorScheme.onSecondaryContainer, + ), const SizedBox(width: 4), - Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + Text( + context.l10n.tracksCount(tracks.length), + style: TextStyle( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), ], ), ), if (releaseDate != null && releaseDate.isNotEmpty) Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer), + Icon( + Icons.calendar_today, + size: 14, + color: colorScheme.onTertiaryContainer, + ), const SizedBox(width: 4), - Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + Text( + _formatReleaseDate(releaseDate), + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), ], ), ), @@ -412,7 +483,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { label: Text(context.l10n.downloadAllCount(tracks.length)), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ], @@ -432,28 +505,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { children: [ Icon(Icons.queue_music, size: 20, color: colorScheme.primary), const SizedBox(width: 8), - Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + Text( + context.l10n.tracksHeader, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), ], ), ), ); } - Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildTrackList( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final track = tracks[index]; - return KeyedSubtree( - key: ValueKey(track.id), - child: _AlbumTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), - ), - ); - }, - childCount: tracks.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final track = tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _AlbumTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), + ); + }, childCount: tracks.length), ); } @@ -466,13 +546,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { artistName: track.artistName, coverUrl: track.coverUrl, onSelect: (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); }, ); } else { - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); } } @@ -486,27 +576,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { trackName: '${tracks.length} tracks', artistName: widget.albumName, onSelect: (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(tracks.length), + ), + ), + ); }, ); } else { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); -ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + ), + ); } } void _navigateToArtist(BuildContext context, String artistName) { - final artistId = _artistId ?? + final artistId = + _artistId ?? (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); - - if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) { + + if (artistId == 'unknown' || + artistId == 'deezer:unknown' || + artistId.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Artist information not available')), ); return; } - + if (widget.extensionId != null) { Navigator.push( context, @@ -521,7 +628,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s ); return; } - + Navigator.push( context, MaterialPageRoute( @@ -535,10 +642,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s } Widget _buildErrorWidget(String error, ColorScheme colorScheme) { - final isRateLimit = error.contains('429') || - error.toLowerCase().contains('rate limit') || - error.toLowerCase().contains('too many requests'); - + final isRateLimit = + error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + if (isRateLimit) { return Card( elevation: 0, @@ -577,7 +685,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s ), ); } - + return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -588,7 +696,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s children: [ Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), - Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), + Expanded( + child: Text(error, style: TextStyle(color: colorScheme.error)), + ), ], ), ), @@ -605,33 +715,44 @@ class _AlbumTrackItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - + final queueItem = ref.watch( - downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + downloadQueueLookupProvider.select( + (lookup) => lookup.byTrackId[track.id], + ), ); - - final isInHistory = ref.watch(downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); - })); - - final settings = ref.watch(settingsProvider); - final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates; - final isInLocalLibrary = showLocalLibraryIndicator - ? ref.watch(localLibraryProvider.select((state) => - state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ))) + + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + return state.isDownloaded(track.id); + }), + ); + + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) : false; - + final isQueued = queueItem != null; final isDownloading = queueItem?.status == DownloadStatus.downloading; final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; + + final showAsDownloaded = + isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -639,8 +760,10 @@ class _AlbumTrackItem extends ConsumerWidget { elevation: 0, color: Colors.transparent, margin: const EdgeInsets.symmetric(vertical: 2), -child: ListTile( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), leading: SizedBox( width: 32, child: Center( @@ -653,14 +776,31 @@ child: ListTile( ), ), ), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), + title: Text( + track.name, + 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))), + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), if (isInLocalLibrary) ...[ const SizedBox(width: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(4), @@ -668,51 +808,102 @@ child: ListTile( child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), const SizedBox(width: 3), - Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), ], ), ), ], ], ), - trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), - onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), + trailing: _buildDownloadButton( + context, + ref, + colorScheme, + isQueued: isQueued, + isDownloading: isDownloading, + isFinalizing: isFinalizing, + showAsDownloaded: showAsDownloaded, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + progress: progress, + ), + onTap: () => _handleTap( + context, + ref, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), ), ), ); } - void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { + void _handleTap( + BuildContext context, + WidgetRef ref, { + required bool isQueued, + required bool isInHistory, + required bool isInLocalLibrary, + }) async { if (isQueued) return; - + if (isInLocalLibrary) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), + ), + ); } return; } - + if (isInHistory) { - final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAlreadyDownloaded(track.name), + ), + ), + ); } return; } else { - ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + ref + .read(downloadHistoryProvider.notifier) + .removeBySpotifyId(track.id); } } } - + onDownload(); } - Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { + Widget _buildDownloadButton( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, { required bool isQueued, required bool isDownloading, required bool isFinalizing, @@ -723,11 +914,29 @@ child: ListTile( }) { const double size = 44.0; const double iconSize = 20.0; - + if (showAsDownloaded) { return GestureDetector( - onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), - child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), + onTap: () => _handleTap( + context, + ref, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: iconSize, + ), + ), ); } else if (isFinalizing) { return SizedBox( @@ -736,7 +945,11 @@ child: ListTile( child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), ], ), @@ -748,17 +961,54 @@ child: ListTile( child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), - if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 3, + color: colorScheme.primary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + if (progress > 0) + Text( + '${(progress * 100).toInt()}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), ], ), ); } else if (isQueued) { - return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + Icons.hourglass_empty, + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ); } else { return GestureDetector( onTap: onDownload, - child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.download, + color: colorScheme.onSecondaryContainer, + size: iconSize, + ), + ), ); } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 9dad8b6..55cc7d9 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -15,7 +15,8 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; -import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; +import 'package:spotiflac_android/screens/home_tab.dart' + show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Simple in-memory cache for artist data @@ -33,7 +34,8 @@ class _ArtistCache { return entry; } - static void set(String artistId, { + static void set( + String artistId, { required List albums, List? topTracks, String? headerImageUrl, @@ -55,7 +57,7 @@ class _CacheEntry { final String? headerImageUrl; final int? monthlyListeners; final DateTime expiresAt; - + _CacheEntry({ required this.albums, this.topTracks, @@ -99,31 +101,38 @@ class _ArtistScreenState extends ConsumerState { String? _headerImageUrl; int? _monthlyListeners; String? _error; - + bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); bool _isSelectionMode = false; final Set _selectedAlbumIds = {}; bool _isFetchingDiscography = false; + List? _albumBucketSource; + List _albumsOnlyBucket = const []; + List _singlesBucket = const []; + List _compilationsBucket = const []; -@override + @override void initState() { super.initState(); - + _scrollController.addListener(_onScroll); - + WidgetsBinding.instance.addPostFrameCallback((_) { - final providerId = widget.extensionId ?? - (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); - ref.read(recentAccessProvider.notifier).recordArtistAccess( - id: widget.artistId, - name: widget.artistName, - imageUrl: widget.coverUrl, - providerId: providerId, - ); + final providerId = + widget.extensionId ?? + (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); + ref + .read(recentAccessProvider.notifier) + .recordArtistAccess( + id: widget.artistId, + name: widget.artistName, + imageUrl: widget.coverUrl, + providerId: providerId, + ); }); - + if (widget.extensionId != null) { _albums = widget.albums; _topTracks = widget.topTracks; @@ -131,15 +140,15 @@ class _ArtistScreenState extends ConsumerState { _monthlyListeners = widget.monthlyListeners; return; } - + final cached = _ArtistCache.get(widget.artistId); - + if (widget.albums != null) { _albums = widget.albums; _topTracks = widget.topTracks; _headerImageUrl = widget.headerImageUrl; _monthlyListeners = widget.monthlyListeners; - + if (_topTracks == null || _topTracks!.isEmpty) { _fetchDiscography(); } @@ -148,13 +157,13 @@ class _ArtistScreenState extends ConsumerState { _topTracks = cached.topTracks; _headerImageUrl = cached.headerImageUrl; _monthlyListeners = cached.monthlyListeners; - + if (_topTracks == null || _topTracks!.isEmpty) { _fetchDiscography(); } } else { _fetchDiscography(); -} + } } void _onScroll() { @@ -179,38 +188,54 @@ class _ArtistScreenState extends ConsumerState { List? topTracks; String? headerImage; int? listeners; - + if (widget.artistId.startsWith('deezer:')) { final deezerArtistId = widget.artistId.replaceFirst('deezer:', ''); - final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId); + final metadata = await PlatformBridge.getDeezerMetadata( + 'artist', + deezerArtistId, + ); final albumsList = metadata['albums'] as List; - albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); } else { final url = 'https://open.spotify.com/artist/${widget.artistId}'; final result = await PlatformBridge.handleURLWithExtension(url); - + if (result != null && result['artist'] != null) { final artistData = result['artist'] as Map; final albumsList = artistData['albums'] as List? ?? []; - albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); - - final topTracksList = artistData['top_tracks'] as List? ?? []; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + + final topTracksList = + artistData['top_tracks'] as List? ?? []; if (topTracksList.isNotEmpty) { - topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); + topTracks = topTracksList + .map((t) => _parseTrack(t as Map)) + .toList(); } - + headerImage = artistData['header_image'] as String?; listeners = artistData['listeners'] as int?; } else { - final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); + final metadata = await PlatformBridge.getSpotifyMetadataWithFallback( + url, + ); final albumsList = metadata['albums'] as List; - albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); } } - - final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl; - final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners; - + + final finalHeaderImage = + headerImage ?? _headerImageUrl ?? widget.headerImageUrl; + final finalListeners = + listeners ?? _monthlyListeners ?? widget.monthlyListeners; + _ArtistCache.set( widget.artistId, albums: albums, @@ -218,7 +243,7 @@ class _ArtistScreenState extends ConsumerState { headerImageUrl: finalHeaderImage, monthlyListeners: finalListeners, ); - + if (mounted) { setState(() { _albums = albums; @@ -246,7 +271,7 @@ class _ArtistScreenState extends ConsumerState { } else if (durationValue is double) { durationMs = durationValue.toInt(); } - + return Track( id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), @@ -276,17 +301,33 @@ class _ArtistScreenState extends ConsumerState { ); } + void _ensureAlbumBuckets(List albums) { + if (identical(albums, _albumBucketSource)) return; + _albumBucketSource = albums; + _albumsOnlyBucket = albums + .where((a) => a.albumType == 'album') + .toList(growable: false); + _singlesBucket = albums + .where((a) => a.albumType == 'single') + .toList(growable: false); + _compilationsBucket = albums + .where((a) => a.albumType == 'compilation') + .toList(growable: false); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final albums = _albums ?? []; - final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); - final singles = albums.where((a) => a.albumType == 'single').toList(); - final compilations = albums.where((a) => a.albumType == 'compilation').toList(); + _ensureAlbumBuckets(albums); + final albumsOnly = _albumsOnlyBucket; + final singles = _singlesBucket; + final compilations = _compilationsBucket; - final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty; + final hasDiscography = + !_isLoadingDiscography && _error == null && albums.isNotEmpty; -return PopScope( + return PopScope( canPop: !_isSelectionMode, onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSelectionMode) { @@ -294,39 +335,70 @@ return PopScope( } }, child: Scaffold( - body: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - _buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography), - if (_isLoadingDiscography) - const SliverToBoxAdapter(child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - )), - if (_error != null) - SliverToBoxAdapter(child: Padding( - padding: const EdgeInsets.all(16), - child: _buildErrorWidget(_error!, colorScheme), - )), - if (!_isLoadingDiscography && _error == null) ...[ - if (_topTracks != null && _topTracks!.isNotEmpty) - SliverToBoxAdapter(child: _buildPopularSection(colorScheme)), - if (albumsOnly.isNotEmpty) - SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), - if (singles.isNotEmpty) - SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)), - if (compilations.isNotEmpty) - SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + _buildHeader( + context, + colorScheme, + albums: albums, + hasDiscography: hasDiscography, + ), + if (_isLoadingDiscography) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildErrorWidget(_error!, colorScheme), + ), + ), + if (!_isLoadingDiscography && _error == null) ...[ + if (_topTracks != null && _topTracks!.isNotEmpty) + SliverToBoxAdapter( + child: _buildPopularSection(colorScheme), + ), + if (albumsOnly.isNotEmpty) + SliverToBoxAdapter( + child: _buildAlbumSection( + context.l10n.artistAlbums, + albumsOnly, + colorScheme, + ), + ), + if (singles.isNotEmpty) + SliverToBoxAdapter( + child: _buildAlbumSection( + context.l10n.artistSingles, + singles, + colorScheme, + ), + ), + if (compilations.isNotEmpty) + SliverToBoxAdapter( + child: _buildAlbumSection( + context.l10n.artistCompilations, + compilations, + colorScheme, + ), + ), + ], + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 120 : 32), + ), + ], + ), + if (_isSelectionMode) + _buildSelectionBar(context, colorScheme, albums), ], - SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), - ], - ), - if (_isSelectionMode) - _buildSelectionBar(context, colorScheme, albums), - ], - ), + ), ), ); } @@ -373,11 +445,20 @@ return PopScope( }); } - Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List allAlbums) { + Widget _buildSelectionBar( + BuildContext context, + ColorScheme colorScheme, + List allAlbums, + ) { final allSelected = _selectedAlbumIds.length == allAlbums.length; final selectedCount = _selectedAlbumIds.length; - final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList(); - final totalTracks = selectedAlbums.fold(0, (sum, a) => sum + a.totalTracks); + final selectedAlbums = allAlbums + .where((a) => _selectedAlbumIds.contains(a.id)) + .toList(); + final totalTracks = selectedAlbums.fold( + 0, + (sum, a) => sum + a.totalTracks, + ); return Positioned( left: 0, @@ -413,27 +494,33 @@ return PopScope( children: [ Text( context.l10n.discographySelectedCount(selectedCount), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), ), if (selectedCount > 0) Text( context.l10n.tracksCount(totalTracks), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), ), TextButton( - onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums), - child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), + onPressed: allSelected + ? _deselectAll + : () => _selectAll(allAlbums), + child: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), ), const SizedBox(width: 8), FilledButton.icon( - onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null, + onPressed: selectedCount > 0 + ? () => _downloadSelectedAlbums(context, selectedAlbums) + : null, icon: const Icon(Icons.download, size: 18), label: Text(context.l10n.discographyDownloadSelected), ), @@ -445,12 +532,19 @@ return PopScope( ); } - void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List albums) { + void _showDiscographyOptions( + BuildContext context, + ColorScheme colorScheme, + List albums, + ) { final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); final singles = albums.where((a) => a.albumType == 'single').toList(); final totalTracks = albums.fold(0, (sum, a) => sum + a.totalTracks); - final albumTracks = albumsOnly.fold(0, (sum, a) => sum + a.totalTracks); + final albumTracks = albumsOnly.fold( + 0, + (sum, a) => sum + a.totalTracks, + ); final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); showModalBottomSheet( @@ -495,7 +589,10 @@ return PopScope( _DiscographyOptionTile( icon: Icons.library_music, title: context.l10n.discographyDownloadAll, - subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length), + subtitle: context.l10n.discographyDownloadAllSubtitle( + totalTracks, + albums.length, + ), onTap: () { Navigator.pop(context); _downloadAlbums(context, albums); @@ -505,7 +602,10 @@ return PopScope( _DiscographyOptionTile( icon: Icons.album, title: context.l10n.discographyAlbumsOnly, - subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length), + subtitle: context.l10n.discographyAlbumsOnlySubtitle( + albumTracks, + albumsOnly.length, + ), onTap: () { Navigator.pop(context); _downloadAlbums(context, albumsOnly); @@ -515,7 +615,10 @@ return PopScope( _DiscographyOptionTile( icon: Icons.music_note, title: context.l10n.discographySinglesOnly, - subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length), + subtitle: context.l10n.discographySinglesOnlySubtitle( + singleTracks, + singles.length, + ), onTap: () { Navigator.pop(context); _downloadAlbums(context, singles); @@ -538,9 +641,12 @@ return PopScope( ); } - Future _downloadAlbums(BuildContext context, List albums) async { + Future _downloadAlbums( + BuildContext context, + List albums, + ) async { final settings = ref.read(settingsProvider); - + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -553,7 +659,10 @@ return PopScope( } } - Future _downloadSelectedAlbums(BuildContext context, List albums) async { + Future _downloadSelectedAlbums( + BuildContext context, + List albums, + ) async { _exitSelectionMode(); await _downloadAlbums(context, albums); } @@ -564,14 +673,14 @@ return PopScope( String? qualityOverride, ) async { if (_isFetchingDiscography) return; - + setState(() => _isFetchingDiscography = true); if (!mounted) { setState(() => _isFetchingDiscography = false); return; } - + showDialog( context: context, barrierDismissible: false, @@ -599,10 +708,14 @@ return PopScope( } fetchedCount++; - + // Update progress dialog if (mounted) { - _FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length); + _FetchingProgressDialog.updateProgress( + context, + fetchedCount, + albums.length, + ); } } @@ -633,9 +746,10 @@ return PopScope( int skippedCount = 0; for (final track in allTracks) { - final isDownloaded = historyState.isDownloaded(track.id) || + final isDownloaded = + historyState.isDownloaded(track.id) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null); - + if (!isDownloaded) { tracksToQueue.add(track); } else { @@ -647,24 +761,31 @@ return PopScope( if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)), + content: Text( + context.l10n.discographySkippedDownloaded(0, skippedCount), + ), ), ); } return; } - ref.read(downloadQueueProvider.notifier).addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: qualityOverride, - ); + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: qualityOverride, + ); if (mounted) { final message = skippedCount > 0 - ? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount) + ? context.l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedCount, + ) : context.l10n.discographyAddedToQueue(tracksToQueue.length); - + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), @@ -682,31 +803,45 @@ return PopScope( Future> _fetchAlbumTracks(ArtistAlbum album) async { if (album.providerId != null && album.providerId!.isNotEmpty) { - final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id); + final result = await PlatformBridge.getAlbumWithExtension( + album.providerId!, + album.id, + ); if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; - return tracksList.map((t) => _parseTrack(t as Map)).toList(); + return tracksList + .map((t) => _parseTrack(t as Map)) + .toList(); } } else if (album.id.startsWith('deezer:')) { final deezerId = album.id.replaceFirst('deezer:', ''); - final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId); + final metadata = await PlatformBridge.getDeezerMetadata( + 'album', + deezerId, + ); if (metadata['tracks'] != null) { final tracksList = metadata['tracks'] as List; - return tracksList.map((t) => _parseTrackFromDeezer(t as Map, album)).toList(); + return tracksList + .map((t) => _parseTrackFromDeezer(t as Map, album)) + .toList(); } } else { final url = 'https://open.spotify.com/album/${album.id}'; final result = await PlatformBridge.handleURLWithExtension(url); if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; - return tracksList.map((t) => _parseTrack(t as Map)).toList(); + return tracksList + .map((t) => _parseTrack(t as Map)) + .toList(); } - + // Fallback to direct Spotify metadata final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); if (metadata['tracks'] != null) { final tracksList = metadata['tracks'] as List; - return tracksList.map((t) => _parseTrack(t as Map)).toList(); + return tracksList + .map((t) => _parseTrack(t as Map)) + .toList(); } } return []; @@ -720,24 +855,29 @@ return PopScope( } else if (durationValue is double) { durationMs = (durationValue * 1000).toInt(); } - + return Track( id: 'deezer:${data['id']}', name: (data['title'] ?? data['name'] ?? '').toString(), - artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(), + artistName: + (data['artist']?['name'] ?? data['artist'] ?? widget.artistName) + .toString(), albumName: album.name, albumArtist: widget.artistName, coverUrl: album.coverUrl, isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), - trackNumber: data['track_position'] as int? ?? data['track_number'] as int?, + trackNumber: + data['track_position'] as int? ?? data['track_number'] as int?, discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, releaseDate: album.releaseDate, albumType: album.albumType, ); } - Widget _buildHeader(BuildContext context, ColorScheme colorScheme, { + Widget _buildHeader( + BuildContext context, + ColorScheme colorScheme, { required List albums, required bool hasDiscography, }) { @@ -748,19 +888,22 @@ return PopScope( if (imageUrl == null || imageUrl.isEmpty) { imageUrl = widget.coverUrl; } - - final hasValidImage = imageUrl != null && - imageUrl.isNotEmpty && - Uri.tryParse(imageUrl)?.hasAuthority == true; - + + final hasValidImage = + imageUrl != null && + imageUrl.isNotEmpty && + Uri.tryParse(imageUrl)?.hasAuthority == true; + String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { final formatter = NumberFormat.compact(); - listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners)); + listenersText = context.l10n.artistMonthlyListeners( + formatter.format(listeners), + ); } - -return SliverAppBar( + + return SliverAppBar( expandedHeight: hasDiscography ? 420 : 380, pinned: true, stretch: true, @@ -785,25 +928,32 @@ return SliverAppBar( background: Stack( fit: StackFit.expand, children: [ -if (hasValidImage) + if (hasValidImage) CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, alignment: Alignment.topCenter, // Show top of image (faces) memCacheWidth: 800, cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - color: colorScheme.surfaceContainerHighest, - ), + placeholder: (context, url) => + Container(color: colorScheme.surfaceContainerHighest), errorWidget: (context, url, error) => Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), ), ) else Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.person, + size: 80, + color: colorScheme.onSurfaceVariant, + ), ), Container( decoration: BoxDecoration( @@ -866,7 +1016,11 @@ if (hasValidImage) SizedBox( height: 40, child: FilledButton.icon( - onPressed: () => _showDiscographyOptions(context, colorScheme, albums), + onPressed: () => _showDiscographyOptions( + context, + colorScheme, + albums, + ), icon: const Icon(Icons.download, size: 18), label: Text(context.l10n.discographyDownload), style: FilledButton.styleFrom( @@ -885,7 +1039,10 @@ if (hasValidImage) ), ], ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], ), leading: IconButton( icon: Container( @@ -903,10 +1060,12 @@ if (hasValidImage) /// Build Popular tracks section like Spotify Widget _buildPopularSection(ColorScheme colorScheme) { - if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink(); - + if (_topTracks == null || _topTracks!.isEmpty) { + return const SizedBox.shrink(); + } + final tracks = _topTracks!.take(5).toList(); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -914,9 +1073,9 @@ if (hasValidImage) padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), child: Text( context.l10n.artistPopular, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), ...tracks.asMap().entries.map((entry) { @@ -928,153 +1087,198 @@ if (hasValidImage) ); } - Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { - final queueItem = ref.watch( - downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), - ); - - final isInHistory = ref.watch(downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); - })); - - // Check local library for duplicate detection - final settings = ref.watch(settingsProvider); - final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates; - final isInLocalLibrary = showLocalLibraryIndicator - ? ref.watch(localLibraryProvider.select((state) => - state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ))) - : false; - - final isQueued = queueItem != null; - final isDownloading = queueItem?.status == DownloadStatus.downloading; - final isFinalizing = queueItem?.status == DownloadStatus.finalizing; - final isCompleted = queueItem?.status == DownloadStatus.completed; - final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; - - return InkWell( - onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 24, - child: Text( - '$rank', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 12), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: track.coverUrl != null -? CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - width: 48, - height: 48, - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 48, - height: 48, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), - ), - ) - : Container( - width: 48, - height: 48, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Widget _buildPopularTrackItem( + int rank, + Track track, + ColorScheme colorScheme, + ) { + return Consumer( + builder: (context, ref, child) { + final queueItem = ref.watch( + downloadQueueLookupProvider.select( + (lookup) => lookup.byTrackId[track.id], + ), + ); + + final isInHistory = ref.watch( + downloadHistoryProvider.select( + (state) => state.isDownloaded(track.id), + ), + ); + + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, ), - if (track.albumName.isNotEmpty) - Text( - track.albumName, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + ) + : false; + + final isQueued = queueItem != null; + final isDownloading = queueItem?.status == DownloadStatus.downloading; + final isFinalizing = queueItem?.status == DownloadStatus.finalizing; + final isCompleted = queueItem?.status == DownloadStatus.completed; + final progress = queueItem?.progress ?? 0.0; + + final showAsDownloaded = + isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; + + return InkWell( + onTap: () => _handlePopularTrackTap( + track, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 24, + child: Text( + '$rank', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), - ], - ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: track.coverUrl != null + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ) + : Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (track.albumName.isNotEmpty) + Text( + track.albumName, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + _buildPopularDownloadButton( + track: track, + colorScheme: colorScheme, + isQueued: isQueued, + isDownloading: isDownloading, + isFinalizing: isFinalizing, + showAsDownloaded: showAsDownloaded, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + progress: progress, + ), + ], ), - _buildPopularDownloadButton( - track: track, - colorScheme: colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), - ], - ), - ), + ), + ); + }, ); } /// Handle tap on popular track item - void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { + void _handlePopularTrackTap( + Track track, { + required bool isQueued, + required bool isInHistory, + required bool isInLocalLibrary, + }) async { if (isQueued) return; - + if (isInLocalLibrary) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))), + SnackBar( + content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), + ), ); } return; } - + if (isInHistory) { - final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), + SnackBar( + content: Text( + context.l10n.snackbarAlreadyDownloaded(track.name), + ), + ), ); } return; } else { - ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + ref + .read(downloadHistoryProvider.notifier) + .removeBySpotifyId(track.id); } } } - + _downloadTrack(track); } @@ -1091,10 +1295,15 @@ if (hasValidImage) }) { const double size = 40.0; const double iconSize = 20.0; - + if (showAsDownloaded) { return GestureDetector( - onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), + onTap: () => _handlePopularTrackTap( + track, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), child: Container( width: size, height: size, @@ -1102,7 +1311,11 @@ if (hasValidImage) color: colorScheme.primaryContainer, shape: BoxShape.circle, ), - child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: iconSize, + ), ), ); } else if (isFinalizing) { @@ -1137,7 +1350,11 @@ if (hasValidImage) if (progress > 0) Text( '${(progress * 100).toInt()}', - style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), ), ], ), @@ -1150,7 +1367,11 @@ if (hasValidImage) color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle, ), - child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize), + child: Icon( + Icons.hourglass_empty, + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), ); } else { return GestureDetector( @@ -1162,7 +1383,11 @@ if (hasValidImage) color: colorScheme.secondaryContainer, shape: BoxShape.circle, ), - child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize), + child: Icon( + Icons.download, + color: colorScheme.onSecondaryContainer, + size: iconSize, + ), ), ); } @@ -1171,7 +1396,9 @@ if (hasValidImage) void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); ref.read(settingsProvider.notifier).setHasSearchedBefore(); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedToQueue(track.name)), @@ -1180,7 +1407,11 @@ if (hasValidImage) ); } - Widget _buildAlbumSection(String title, List albums, ColorScheme colorScheme) { + Widget _buildAlbumSection( + String title, + List albums, + ColorScheme colorScheme, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1188,9 +1419,9 @@ if (hasValidImage) padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), child: Text( '$title (${albums.length})', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), SizedBox( @@ -1214,7 +1445,7 @@ if (hasValidImage) Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { final isSelected = _selectedAlbumIds.contains(album.id); - + return GestureDetector( onTap: () { if (_isSelectionMode) { @@ -1236,35 +1467,43 @@ if (hasValidImage) children: [ Stack( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.coverUrl != null -? CachedNetworkImage( - imageUrl: album.coverUrl!, - width: 140, - height: 140, - fit: BoxFit.cover, - memCacheWidth: 280, - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - width: 140, - height: 140, - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 140, - height: 140, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), - ), - ) - : Container( - width: 140, - height: 140, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), - ), - ), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + width: 140, + height: 140, + fit: BoxFit.cover, + memCacheWidth: 280, + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 40, + ), + ), + ) + : Container( + width: 140, + height: 140, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 40, + ), + ), + ), // Selection overlay if (_isSelectionMode) Positioned.fill( @@ -1272,10 +1511,10 @@ if (hasValidImage) duration: const Duration(milliseconds: 200), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: isSelected + color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), - border: isSelected + border: isSelected ? Border.all(color: colorScheme.primary, width: 3) : null, ), @@ -1291,19 +1530,23 @@ if (hasValidImage) width: 28, height: 28, decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary + color: isSelected + ? colorScheme.primary : colorScheme.surface.withValues(alpha: 0.9), shape: BoxShape.circle, border: Border.all( - color: isSelected - ? colorScheme.primary + color: isSelected + ? colorScheme.primary : colorScheme.outline, width: 2, ), ), child: isSelected - ? Icon(Icons.check, color: colorScheme.onPrimary, size: 18) + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 18, + ) : null, ), ), @@ -1312,17 +1555,19 @@ if (hasValidImage) const SizedBox(height: 8), Text( album.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( - album.totalTracks > 0 + album.totalTracks > 0 ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' - : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, + : album.releaseDate.length >= 4 + ? album.releaseDate.substring(0, 4) + : album.releaseDate, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1337,32 +1582,39 @@ if (hasValidImage) void _navigateToAlbum(ArtistAlbum album) { ref.read(settingsProvider.notifier).setHasSearchedBefore(); - + if (album.providerId != null && album.providerId!.isNotEmpty) { - Navigator.push(context, MaterialPageRoute( - builder: (context) => ExtensionAlbumScreen( - extensionId: album.providerId!, - albumId: album.id, - albumName: album.name, - coverUrl: album.coverUrl, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: album.providerId!, + albumId: album.id, + albumName: album.name, + coverUrl: album.coverUrl, + ), ), - )); + ); } else { - Navigator.push(context, MaterialPageRoute( - builder: (context) => AlbumScreen( - albumId: album.id, - albumName: album.name, - coverUrl: album.coverUrl, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: album.id, + albumName: album.name, + coverUrl: album.coverUrl, + ), ), - )); + ); } } Widget _buildErrorWidget(String error, ColorScheme colorScheme) { - final isRateLimit = error.contains('429') || - error.toLowerCase().contains('rate limit') || - error.toLowerCase().contains('too many requests'); - + final isRateLimit = + error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + if (isRateLimit) { return Card( elevation: 0, @@ -1401,7 +1653,7 @@ if (hasValidImage) ), ); } - + return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -1412,7 +1664,9 @@ if (hasValidImage) children: [ Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), - Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), + Expanded( + child: Text(error, style: TextStyle(color: colorScheme.error)), + ), ], ), ), @@ -1449,7 +1703,7 @@ class _DiscographyOptionTile extends StatelessWidget { ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text( - subtitle, + subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), ), trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), @@ -1470,12 +1724,14 @@ class _FetchingProgressDialog extends StatefulWidget { // Static method to update progress from outside static void updateProgress(BuildContext context, int current, int total) { - final state = context.findAncestorStateOfType<_FetchingProgressDialogState>(); + final state = context + .findAncestorStateOfType<_FetchingProgressDialogState>(); state?._updateProgress(current, total); } @override - State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState(); + State<_FetchingProgressDialog> createState() => + _FetchingProgressDialogState(); } class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { @@ -1527,9 +1783,9 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { const SizedBox(height: 20), Text( context.l10n.discographyFetchingTracks, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 188e280..4824bae 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,4 +1,5 @@ import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -56,26 +57,31 @@ class _PlaylistScreenState extends ConsumerState { Future _fetchTracksIfNeeded() async { if (widget.tracks.isNotEmpty || widget.playlistId == null) return; - + setState(() { _isLoading = true; _error = null; }); - + try { // Extract numeric ID from "deezer:123" format String playlistId = widget.playlistId!; if (playlistId.startsWith('deezer:')) { playlistId = playlistId.substring(7); } - - final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); + + final result = await PlatformBridge.getDeezerMetadata( + 'playlist', + playlistId, + ); if (!mounted) return; - + // Go backend returns 'track_list' not 'tracks' final trackList = result['track_list'] as List? ?? []; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); - + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + setState(() { _fetchedTracks = tracks; _isLoading = false; @@ -97,7 +103,7 @@ class _PlaylistScreenState extends ConsumerState { } else if (durationValue is double) { durationMs = durationValue.toInt(); } - + return Track( id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), @@ -141,12 +147,13 @@ class _PlaylistScreenState extends ConsumerState { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final screenWidth = MediaQuery.of(context).size.width; final coverSize = screenWidth * 0.5; // 50% of screen width - + return SliverAppBar( expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, // Use theme color for collapsed state + backgroundColor: + colorScheme.surface, // Use theme color for collapsed state surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -164,9 +171,10 @@ class _PlaylistScreenState extends ConsumerState { ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { - final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final showContent = collapseRatio > 0.3; - + return FlexibleSpaceBar( collapseMode: CollapseMode.none, background: Stack( @@ -178,25 +186,35 @@ class _PlaylistScreenState extends ConsumerState { imageUrl: widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container(color: colorScheme.surface), - errorWidget: (_, _, _) => Container(color: colorScheme.surface), + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), ) else Container(color: colorScheme.surface), ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), + child: Container( + color: colorScheme.surface.withValues(alpha: 0.4), + ), ), ), Positioned( - left: 0, right: 0, bottom: 0, height: 80, + left: 0, + right: 0, + bottom: 0, + height: 80, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface], + colors: [ + colorScheme.surface.withValues(alpha: 0.0), + colorScheme.surface, + ], ), ), ), @@ -224,15 +242,19 @@ class _PlaylistScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null -? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), cacheManager: CoverCacheManager.instance, ) : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant), + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.playlist_play, + size: 64, + color: colorScheme.onSurfaceVariant, + ), ), ), ), @@ -241,17 +263,20 @@ class _PlaylistScreenState extends ConsumerState { ), ], ), - stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], ); }, ), leading: IconButton( icon: Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle, - ), + ), child: Icon(Icons.arrow_back, color: colorScheme.onSurface), ), onPressed: () => Navigator.pop(context), @@ -266,34 +291,63 @@ class _PlaylistScreenState extends ConsumerState { child: Card( elevation: 0, color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), + Text( + widget.playlistName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), const SizedBox(height: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), + Icon( + Icons.playlist_play, + size: 14, + color: colorScheme.onTertiaryContainer, + ), const SizedBox(width: 4), - Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + Text( + context.l10n.tracksCount(_tracks.length), + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), ], ), ), -const SizedBox(height: 16), + const SizedBox(height: 16), FilledButton.icon( - onPressed: _tracks.isEmpty ? null : () => _downloadAll(context), + onPressed: _tracks.isEmpty + ? null + : () => _downloadAll(context), icon: const Icon(Icons.download, size: 18), label: Text(context.l10n.downloadAllCount(_tracks.length)), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ], @@ -312,7 +366,13 @@ const SizedBox(height: 16), children: [ Icon(Icons.queue_music, size: 20, color: colorScheme.primary), const SizedBox(width: 8), - Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), + Text( + context.l10n.tracksHeader, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), ], ), ), @@ -328,7 +388,7 @@ const SizedBox(height: 16), ), ); } - + if (_error != null) { return SliverToBoxAdapter( child: Padding( @@ -341,7 +401,12 @@ const SizedBox(height: 16), children: [ Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), - Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))), + Expanded( + child: Text( + _error!, + style: TextStyle(color: colorScheme.error), + ), + ), ], ), ), @@ -349,7 +414,7 @@ const SizedBox(height: 16), ), ); } - + if (_tracks.isEmpty) { return SliverToBoxAdapter( child: Padding( @@ -363,21 +428,18 @@ const SizedBox(height: 16), ), ); } - + return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final track = _tracks[index]; - return KeyedSubtree( - key: ValueKey(track.id), - child: _PlaylistTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), - ), - ); - }, - childCount: _tracks.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final track = _tracks[index]; + return KeyedSubtree( + key: ValueKey(track.id), + child: _PlaylistTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), + ); + }, childCount: _tracks.length), ); } @@ -390,13 +452,23 @@ const SizedBox(height: 16), artistName: track.artistName, coverUrl: track.coverUrl, onSelect: (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); }, ); } else { - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); } } @@ -409,13 +481,29 @@ const SizedBox(height: 16), trackName: '${_tracks.length} tracks', artistName: widget.playlistName, onSelect: (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(_tracks, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(_tracks.length), + ), + ), + ); }, ); } else { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(_tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(_tracks.length), + ), + ), + ); } } } @@ -430,34 +518,45 @@ class _PlaylistTrackItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - + final queueItem = ref.watch( - downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + downloadQueueLookupProvider.select( + (lookup) => lookup.byTrackId[track.id], + ), ); - - final isInHistory = ref.watch(downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); - })); - + + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + return state.isDownloaded(track.id); + }), + ); + // Check local library for duplicate detection - final settings = ref.watch(settingsProvider); - final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates; - final isInLocalLibrary = showLocalLibraryIndicator - ? ref.watch(localLibraryProvider.select((state) => - state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ))) + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) : false; - + final isQueued = queueItem != null; final isDownloading = queueItem?.status == DownloadStatus.downloading; final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; + + final showAsDownloaded = + isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -466,18 +565,58 @@ class _PlaylistTrackItem extends ConsumerWidget { color: Colors.transparent, margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), -leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) - : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + cacheManager: CoverCacheManager.instance, + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + title: Text( + track.name, + 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))), + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), if (isInLocalLibrary) ...[ const SizedBox(width: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(4), @@ -485,51 +624,102 @@ leading: track.coverUrl != null child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), const SizedBox(width: 3), - Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), ], ), ), ], ], ), - trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), - onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), + trailing: _buildDownloadButton( + context, + ref, + colorScheme, + isQueued: isQueued, + isDownloading: isDownloading, + isFinalizing: isFinalizing, + showAsDownloaded: showAsDownloaded, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + progress: progress, + ), + onTap: () => _handleTap( + context, + ref, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), ), ), ); } - void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { + void _handleTap( + BuildContext context, + WidgetRef ref, { + required bool isQueued, + required bool isInHistory, + required bool isInLocalLibrary, + }) async { if (isQueued) return; - + if (isInLocalLibrary) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), + ), + ); } return; } - + if (isInHistory) { - final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAlreadyDownloaded(track.name), + ), + ), + ); } return; } else { - ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); + ref + .read(downloadHistoryProvider.notifier) + .removeBySpotifyId(track.id); } } } - + onDownload(); } - Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { + Widget _buildDownloadButton( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, { required bool isQueued, required bool isDownloading, required bool isFinalizing, @@ -540,11 +730,29 @@ leading: track.coverUrl != null }) { const double size = 44.0; const double iconSize = 20.0; - + if (showAsDownloaded) { return GestureDetector( - onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), - child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), + onTap: () => _handleTap( + context, + ref, + isQueued: isQueued, + isInHistory: isInHistory, + isInLocalLibrary: isInLocalLibrary, + ), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: iconSize, + ), + ), ); } else if (isFinalizing) { return SizedBox( @@ -553,7 +761,11 @@ leading: track.coverUrl != null child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), ], ), @@ -565,17 +777,54 @@ leading: track.coverUrl != null child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), - if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 3, + color: colorScheme.primary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + if (progress > 0) + Text( + '${(progress * 100).toInt()}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), ], ), ); } else if (isQueued) { - return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + Icons.hourglass_empty, + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ); } else { return GestureDetector( onTap: onDownload, - child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.download, + color: colorScheme.onSecondaryContainer, + size: iconSize, + ), + ), ); } }