import 'package:flutter/material.dart'; import 'dart:ui' show ImageFilter; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/services/platform_bridge.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/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; final List tracks; final String? playlistId; final String? recommendedService; const PlaylistScreen({ super.key, required this.playlistName, this.coverUrl, required this.tracks, this.playlistId, this.recommendedService, }); @override ConsumerState createState() => _PlaylistScreenState(); } class _PlaylistScreenState extends ConsumerState { bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); List? _fetchedTracks; bool _isLoading = false; String? _error; String? _resolvedPlaylistName; String? _resolvedCoverUrl; List get _tracks => _fetchedTracks ?? widget.tracks; String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; String? _legacyProviderIdFromResourceId(String value) { if (value.startsWith('deezer:')) return 'deezer'; if (value.startsWith('qobuz:')) return 'qobuz'; if (value.startsWith('tidal:')) return 'tidal'; return null; } String _stripPrefixedResourceId(String value) { final colonIndex = value.indexOf(':'); if (colonIndex <= 0 || colonIndex == value.length - 1) { return value; } return value.substring(colonIndex + 1); } String? _metadataProviderId(String playlistId) { final providerId = _legacyProviderIdFromResourceId(playlistId); if (providerId == null) return null; final effective = resolveEffectiveMetadataProvider( providerId, ref.read(extensionProvider), ); return effective.isEmpty ? null : effective; } String _metadataResourceId(String providerId, String playlistId) { return _stripPrefixedResourceId(playlistId); } String? _recommendedDownloadService() { final explicit = widget.recommendedService; if (explicit != null && explicit.isNotEmpty) { return explicit; } final playlistId = widget.playlistId; if (playlistId != null) { final providerId = _metadataProviderId(playlistId); if (providerId != null && providerId.isNotEmpty) { return resolveEffectiveDownloadService( providerId, ref.read(extensionProvider), ); } } final source = _tracks.firstOrNull?.source; if (source != null && source.isNotEmpty) { return source; } final trackId = _tracks.firstOrNull?.id ?? ''; final trackProviderId = _legacyProviderIdFromResourceId(trackId); if (trackProviderId != null) { return resolveEffectiveDownloadService( trackProviderId, ref.read(extensionProvider), ); } return null; } @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _fetchTracksIfNeeded(); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } Future _fetchTracksIfNeeded() async { if (widget.tracks.isNotEmpty || widget.playlistId == null) return; setState(() { _isLoading = true; _error = null; }); try { String playlistId = widget.playlistId!; late final Map result; final providerId = _metadataProviderId(playlistId); if (providerId != null) { result = await PlatformBridge.getProviderMetadata( providerId, 'playlist', _metadataResourceId(providerId, playlistId), ); } else { result = await PlatformBridge.getProviderMetadata( 'deezer', 'playlist', playlistId, ); } if (!mounted) return; final playlistInfo = result['playlist_info'] as Map?; final owner = playlistInfo?['owner'] as Map?; final trackList = result['track_list'] as List? ?? []; final tracks = trackList .map((t) => _parseTrack(t as Map)) .toList(); setState(() { _fetchedTracks = tracks; _resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name']) ?.toString(); _resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images']) ?.toString(); _isLoading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _isLoading = false; }); } } Track _parseTrack(Map data) { int durationMs = 0; final durationValue = data['duration_ms']; if (durationValue is int) { durationMs = durationValue; } else if (durationValue is double) { durationMs = durationValue.toInt(); } return Track( id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(), albumId: data['album_id']?.toString(), coverUrl: normalizeCoverReference( (data['cover_url'] ?? data['images'])?.toString(), ), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), totalTracks: data['total_tracks'] as int?, composer: data['composer']?.toString(), audioQuality: data['audio_quality']?.toString(), audioModes: data['audio_modes']?.toString(), ); } 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.of(context).size; return (mediaSize.height * 0.55).clamp(360.0, 520.0); } String? _highResCoverUrl(String? url) { if (url == null) return null; if (url.contains('ab67616d00001e02')) { return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); } final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { return url.replaceAllMapped( deezerRegex, (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', ); } return url; } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: CustomScrollView( controller: _scrollController, slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), _buildTrackList(context, colorScheme), SliverToBoxAdapter( child: SizedBox(height: 32 + context.navBarBottomInset), ), ], ), ); } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final expandedHeight = _calculateExpandedHeight(context); return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( _playlistName, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; final cacheWidth = coverCacheWidthForViewport(context); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ if (_coverUrl != null) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), child: CachedCoverImage( imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), ), ) else Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.playlist_play, size: 80, color: colorScheme.onSurfaceVariant, ), ), Container(color: Colors.black.withValues(alpha: 0.35)), if (_coverUrl != null) Positioned( left: 0, right: 0, bottom: 0, height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.6), ], ), ), ), ), Positioned.fill( child: AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: showContent ? 1.0 : 0.0, child: Padding( padding: const EdgeInsets.fromLTRB( 20, kToolbarHeight + 8, 20, 28, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (_coverUrl != null) ...[ Builder( builder: (context) { final coverSize = (constraints.maxWidth * 0.5).clamp( 140.0, 220.0, ); return Container( width: coverSize, height: coverSize, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues( alpha: 0.45, ), blurRadius: 24, offset: const Offset(0, 8), ), ], ), child: CachedCoverImage( imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, borderRadius: BorderRadius.circular(16), placeholder: (_, _) => Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), ), errorWidget: (_, _, _) => Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), ), ), ); }, ), const SizedBox(height: 20), ], Text( _playlistName, style: const TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, height: 1.2, ), textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, ), if (_tracks.isNotEmpty) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.playlist_play, size: 14, color: Colors.white, ), const SizedBox(width: 4), Text( context.l10n.tracksCount(_tracks.length), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12, ), ), ], ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLoveAllButton(), const SizedBox(width: 12), _buildDownloadAllCenterButton(context), const SizedBox(width: 12), _buildAddToPlaylistButton(context), ], ), ], ], ), ), ), ), ], ), stretchModes: const [StretchMode.zoomBackground], ); }, ), leading: IconButton( tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), ); } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { if (_isLoading) { return const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(16), child: TrackListSkeleton(itemCount: 8), ), ); } if (_error != null) { 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), ), ), ], ), ), ), ), ); } if (_tracks.isEmpty) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(32), child: Center( child: Text( context.l10n.errorNoTracksFound, style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), ), ); } final historyLookups = _tracks .map(historyLookupForTrack) .toList(growable: false); final existingHistoryKeys = ref .watch( downloadHistoryBatchExistsProvider( HistoryBatchLookupRequest(historyLookups), ), ) .maybeWhen(data: (keys) => keys, orElse: () => const {}); return SliverList( delegate: SliverChildBuilderDelegate((context, index) { final track = _tracks[index]; final isInHistory = existingHistoryKeys.contains( historyLookups[index].lookupKey, ); return KeyedSubtree( key: ValueKey(track.id), child: StaggeredListItem( index: index, child: _PlaylistTrackItem( track: track, isInHistory: isInHistory, onDownload: () => _downloadTrack(context, track), ), ), ); }, childCount: _tracks.length), ); } 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, playlistName: _playlistName, ); 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); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); } } Widget _buildCircleButton({ required IconData icon, required String tooltip, required VoidCallback? onPressed, }) { return Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.15), border: Border.all( color: Colors.white.withValues(alpha: 0.3), width: 1, ), ), child: IconButton( onPressed: onPressed, icon: Icon(icon, size: 22, color: Colors.white), tooltip: tooltip, padding: EdgeInsets.zero, ), ); } Widget _buildLoveAllButton() { final collectionsState = ref.watch(libraryCollectionsProvider); final allLoved = _tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t)); return Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.15), border: Border.all( color: Colors.white.withValues(alpha: 0.3), width: 1, ), ), child: IconButton( onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks), icon: Icon( allLoved ? Icons.favorite : Icons.favorite_border, size: 22, color: allLoved ? Colors.redAccent : Colors.white, ), tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.tooltipLoveAll, padding: EdgeInsets.zero, ), ); } Widget _buildDownloadAllCenterButton(BuildContext context) { return FilledButton.icon( onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), icon: const Icon(Icons.download_rounded, size: 18), label: Text(context.l10n.downloadAllCount(_tracks.length)), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, minimumSize: const Size(0, 48), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), ), ); } Widget _buildAddToPlaylistButton(BuildContext context) { return _buildCircleButton( icon: Icons.playlist_add, tooltip: context.l10n.tooltipAddToPlaylist, onPressed: _tracks.isEmpty ? null : () => showAddTracksToPlaylistSheet( context, ref, _tracks, playlistNamePrefill: widget.playlistName, ), ); } 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), ), ], ); }, ); } 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)), ), ); } } } 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( context, ).showSnackBar(SnackBar(content: Text(message))); } } class _PlaylistTrackItem extends ConsumerWidget { final Track track; final bool isInHistory; final VoidCallback onDownload; const _PlaylistTrackItem({ required this.track, required this.isInHistory, required this.onDownload, }); @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; final queueItem = ref.watch( downloadQueueLookupProvider.select( (lookup) => lookup.byTrackId[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; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Card( elevation: 0, color: Colors.transparent, margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), leading: track.coverUrl != null ? CachedCoverImage( imageUrl: track.coverUrl!, width: 48, height: 48, borderRadius: BorderRadius.circular(8), ) : Container( width: 48, height: 48, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.music_note, color: colorScheme.onSurfaceVariant, ), ), 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), ), ), ...buildQualityBadges( audioQuality: track.audioQuality, audioModes: track.audioModes, colorScheme: colorScheme, ), if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.folder_outlined, size: 10, color: colorScheme.onPrimaryContainer, ), const SizedBox(width: 3), Text( context.l10n.libraryInLibrary, style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onPrimaryContainer, ), ), ], ), ), ], ], ), trailing: TrackCollectionQuickActions(track: track), onTap: () => _handleTap(context, ref, isQueued: isQueued), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, track, ), ), ), ); } void _handleTap( BuildContext context, WidgetRef ref, { required bool isQueued, }) async { if (isQueued) return; final playedLocal = await _playLocalIfAvailable(context, ref); if (playedLocal) { return; } onDownload(); } Future _playLocalIfAvailable( BuildContext context, WidgetRef ref, ) async { final historyNotifier = ref.read(downloadHistoryProvider.notifier); try { DownloadHistoryItem? historyItem = await historyNotifier .getBySpotifyIdAsync(track.id); final isrc = track.isrc?.trim(); historyItem ??= (isrc != null && isrc.isNotEmpty) ? await historyNotifier.getByIsrcAsync(isrc) : null; historyItem ??= await historyNotifier.findByTrackAndArtistAsync( track.name, track.artistName, ); if (historyItem != null) { final exists = await fileExists(historyItem.filePath); if (exists) { await ref .read(playbackProvider.notifier) .playLocalPath( path: historyItem.filePath, title: track.name, artist: track.artistName, album: track.albumName, coverUrl: track.coverUrl ?? '', ); return true; } historyNotifier.removeFromHistory(historyItem.id); } final localItem = await ref .read(localLibraryProvider.notifier) .findExistingAsync( isrc: isrc, trackName: track.name, artistName: track.artistName, ); if (localItem != null && await fileExists(localItem.filePath)) { await ref .read(playbackProvider.notifier) .playLocalPath( path: localItem.filePath, title: localItem.trackName, artist: localItem.artistName, album: localItem.albumName, coverUrl: localItem.coverPath ?? track.coverUrl ?? '', ); return true; } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), ); } return true; } return false; } }