From 12fb942f1647bb3059d6b807af9c115ab82a0813 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 22:22:51 +0700 Subject: [PATCH] feat: playback queue, preview exclusivity, player bug fixes - PlaybackController with queue methods for albums/library/playlists - Library tab play builds merged queue (downloaded + local together) - Preview vs main player exclusivity - Preview stops on bottom-nav tab switch - Duration 0:00 fix, deleted track cleanup, Up Next sheet - Animation utilities improvements --- lib/providers/download_queue_provider.dart | 33 +++- lib/providers/playback_provider.dart | 167 +++++++++++++++++++++ lib/providers/preview_player_provider.dart | 13 +- lib/screens/main_shell.dart | 48 +++--- lib/screens/queue_tab.dart | 158 ++++++++++++++++--- lib/screens/queue_tab_helpers.dart | 32 +++- lib/screens/track_metadata_screen.dart | 39 +++++ lib/utils/file_access.dart | 3 + lib/widgets/animation_utils.dart | 151 ++++++++++++------- lib/widgets/preview_button.dart | 42 +++++- 10 files changed, 585 insertions(+), 101 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 7032d02a..0c02f44b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -481,6 +481,8 @@ class DownloadHistoryNotifier extends Notifier { static const _startupSafRepairCursorKey = 'history_startup_saf_repair_cursor_v1'; static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1'; + static const _startupOrphanSuspectPrefix = + 'history_startup_orphan_suspect_v1_'; static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1'; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; @@ -1541,24 +1543,39 @@ class DownloadHistoryNotifier extends Notifier { } final result = await _inspectOrphanedEntries(entries); + final confirmedOrphanIds = []; + for (final id in result.orphanedIds) { + final key = '$_startupOrphanSuspectPrefix$id'; + if (prefs.getBool(key) == true) { + confirmedOrphanIds.add(id); + await prefs.remove(key); + } else { + await prefs.setBool(key, true); + _historyLog.d( + 'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})', + ); + } + } for (final replacement in result.replacementPaths.entries) { await _db.updateFilePath(replacement.key, replacement.value); + await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}'); } - final deletedCount = result.orphanedIds.isEmpty + final deletedCount = confirmedOrphanIds.isEmpty ? 0 - : await _db.deleteByIds(result.orphanedIds); + : await _db.deleteByIds(confirmedOrphanIds); _applyHistoryPathAndDeletionChanges( - deletedIds: result.orphanedIds, + deletedIds: confirmedOrphanIds, replacementPaths: result.replacementPaths, ); if (entries.length < maxItems) { await prefs.remove(_startupOrphanCursorKey); } else { - final nextCursor = - safeCursor + entries.length - result.orphanedIds.length; + final nextCursor = result.orphanedIds.isNotEmpty + ? safeCursor + : safeCursor + entries.length; await prefs.setInt(_startupOrphanCursorKey, nextCursor); } @@ -3823,7 +3840,8 @@ class DownloadQueueNotifier extends Notifier { final newItems = tracks.asMap().entries.map((entry) { final track = entry.value; final index = entry.key; - final explicitPosition = playlistPositions != null && + final explicitPosition = + playlistPositions != null && index < playlistPositions.length && (playlistPositions[index] ?? 0) > 0 ? playlistPositions[index] @@ -3838,7 +3856,8 @@ class DownloadQueueNotifier extends Notifier { qualityOverride: qualityOverride, playlistName: playlistName, playlistPosition: - explicitPosition ?? (shouldAssignPlaylistPositions ? index + 1 : null), + explicitPosition ?? + (shouldAssignPlaylistPositions ? index + 1 : null), ); }).toList(); diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index dbe962e1..9c0f0824 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -2,7 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -16,6 +19,24 @@ class PlaybackController extends Notifier { @override PlaybackState build() => const PlaybackState(); + Future _useInternalPlayer() async { + final mode = ref.read(settingsProvider).playerMode; + if (mode != 'internal') return false; + return await ref.read(musicPlayerControllerProvider).ensureInitialized() != + null; + } + + String? _normalizeArtUri(String cover) { + final value = cover.trim(); + if (value.isEmpty) return null; + if (value.startsWith('http') || + value.startsWith('content://') || + value.startsWith('file://')) { + return value; + } + return Uri.file(value).toString(); + } + Future playLocalPath({ required String path, required String title, @@ -27,14 +48,143 @@ class PlaybackController extends Notifier { if (isCueVirtualPath(path)) { throw Exception(cueVirtualTrackRequiresSplitMessage); } + + if (await _useInternalPlayer()) { + _log.d('Playing "$title" in the internal player: $path'); + await ref + .read(musicPlayerControllerProvider) + .playSingle( + PlayableMedia( + id: path, + source: path, + title: title, + artist: artist, + album: album, + artUri: _normalizeArtUri(coverUrl), + duration: (track != null && track.duration > 0) + ? Duration(seconds: track.duration) + : null, + ), + ); + return; + } + _log.d('Opening external player for "$title" by $artist: $path'); await openFile(path); } + /// Plays a local-library album/list starting at [startItem], queuing the rest + /// so playback continues to the next track automatically. Honors player mode. + Future playLocalLibraryQueue( + List items, { + required LocalLibraryItem startItem, + }) async { + final playable = items + .where( + (i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath), + ) + .toList(); + if (playable.isEmpty) return; + var startIndex = playable.indexWhere((i) => i.id == startItem.id); + if (startIndex < 0) startIndex = 0; + + if (await _useInternalPlayer()) { + await ref + .read(musicPlayerControllerProvider) + .playLocal(playable, initialIndex: startIndex); + } else { + await openFile(playable[startIndex].filePath); + } + } + + /// Plays a downloaded-history album/list starting at [startItem], queuing the + /// rest. Honors player mode. + Future playHistoryQueue( + List items, { + required DownloadHistoryItem startItem, + }) async { + final playable = items + .where( + (i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath), + ) + .toList(); + if (playable.isEmpty) return; + var startIndex = playable.indexWhere((i) => i.id == startItem.id); + if (startIndex < 0) startIndex = 0; + + if (await _useInternalPlayer()) { + await ref + .read(musicPlayerControllerProvider) + .playHistory(playable, initialIndex: startIndex); + } else { + await openFile(playable[startIndex].filePath); + } + } + + /// Plays a prebuilt media queue starting at [startIndex]. Honors player mode + /// ([externalPath] is opened externally when the built-in player is off). + Future playMediaQueue( + Iterable queue, { + required int startIndex, + required String externalPath, + }) async { + if (await _useInternalPlayer()) { + final items = queue.toList(growable: false); + if (items.isEmpty) return; + final i = startIndex.clamp(0, items.length - 1); + await ref + .read(musicPlayerControllerProvider) + .playAll(items, initialIndex: i); + } else { + await openFile(externalPath); + } + } + Future playTrackList(List tracks, {int startIndex = 0}) async { if (tracks.isEmpty) return; final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + + if (await _useInternalPlayer()) { + final queue = []; + var skippedCueVirtualTrack = false; + final resolvedPaths = await _resolveTrackPaths(orderedTracks); + for (var index = 0; index < orderedTracks.length; index++) { + final track = orderedTracks[index]; + final resolvedPath = resolvedPaths[index]; + if (resolvedPath == null) continue; + if (isCueVirtualPath(resolvedPath)) { + skippedCueVirtualTrack = true; + continue; + } + queue.add( + PlayableMedia( + id: resolvedPath, + source: resolvedPath, + title: track.name, + artist: track.artistName, + album: track.albumName, + artUri: _normalizeArtUri(track.coverUrl ?? ''), + duration: track.duration > 0 + ? Duration(seconds: track.duration) + : null, + ), + ); + } + + if (queue.isNotEmpty) { + _log.d('Playing ${queue.length} tracks in the internal player'); + await ref.read(musicPlayerControllerProvider).playAll(queue); + return; + } + if (skippedCueVirtualTrack) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + throw Exception( + 'No local audio file is available to play. Download the track first.', + ); + } + var skippedCueVirtualTrack = false; for (final track in orderedTracks) { final resolvedPath = await _resolveTrackPath(track); @@ -98,6 +248,23 @@ class PlaybackController extends Notifier { return null; } + Future> _resolveTrackPaths(List tracks) async { + if (tracks.isEmpty) return const []; + final results = List.filled(tracks.length, null); + var next = 0; + final workerCount = tracks.length < 4 ? tracks.length : 4; + Future worker() async { + while (true) { + final index = next++; + if (index >= tracks.length) return; + results[index] = await _resolveTrackPath(tracks[index]); + } + } + + await Future.wait(List.generate(workerCount, (_) => worker())); + return results; + } + Future _findLocalLibraryItemForTrack(Track track) async { final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; if (isLocalSource) { diff --git a/lib/providers/preview_player_provider.dart b/lib/providers/preview_player_provider.dart index 9106e6c0..2e8a0b4a 100644 --- a/lib/providers/preview_player_provider.dart +++ b/lib/providers/preview_player_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PreviewPlayer'); @@ -59,7 +60,13 @@ class PreviewPlayerController extends Notifier { _lifecycleListener = AppLifecycleListener( onStateChange: _handleAppLifecycleState, ); - ref.onDispose(_disposePlayer); + musicPlayerExclusiveAudioHook = () async { + if (state.isActive) await stop(); + }; + ref.onDispose(() { + musicPlayerExclusiveAudioHook = null; + _disposePlayer(); + }); return const PreviewPlayerState(); } @@ -161,6 +168,10 @@ class PreviewPlayerController extends Notifier { final trimmed = url.trim(); if (trimmed.isEmpty) return; + try { + await musicPlayerHandler?.pause(); + } catch (_) {} + state = PreviewPlayerState( activeUrl: trimmed, status: PreviewStatus.loading, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 29cb72fa..1df93b6f 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -26,6 +26,7 @@ import 'package:spotiflac_android/widgets/app_announcement_dialog.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -300,6 +301,9 @@ class _MainShellState extends ConsumerState final previousIndex = _currentIndex; final isNonAdjacentJump = (previousIndex - index).abs() > 1; HapticFeedback.selectionClick(); + // Stop any preview snippet when leaving the current tab. (_onPageChanged + // cannot do this because _currentIndex is already updated below.) + ref.read(previewPlayerProvider.notifier).stop(); setState(() => _currentIndex = index); final showStore = ref.read( settingsProvider.select((s) => s.showExtensionStore), @@ -597,28 +601,34 @@ class _MainShellState extends ConsumerState bottomNavigationBar: ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const MiniPlayer(), + DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ), + child: NavigationBar( + selectedIndex: _currentIndex.clamp(0, maxIndex), + onDestinationSelected: _onNavTap, + animationDuration: const Duration(milliseconds: 500), + elevation: 0, + height: 64, + backgroundColor: settingsGroupColor( context, - ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ).withValues(alpha: 0.72), + destinations: destinations, ), ), - ), - child: NavigationBar( - selectedIndex: _currentIndex.clamp(0, maxIndex), - onDestinationSelected: _onNavTap, - animationDuration: const Duration(milliseconds: 500), - elevation: 0, - height: 64, - backgroundColor: settingsGroupColor( - context, - ).withValues(alpha: 0.72), - destinations: destinations, - ), + ], ), ), ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0169cf38..8d873158 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -24,6 +24,8 @@ import 'package:spotiflac_android/providers/library_collections_provider.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/providers/music_player_provider.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; @@ -233,6 +235,7 @@ class _QueueTabState extends ConsumerState { _queueLibraryCountsCache = {}; final Map<_QueueLibraryPageRequest, _QueueLibraryPageData> _queueLibraryPageDataCache = {}; + DateTime? _lastBlankLibraryRepairAt; double _effectiveTextScale() { final textScale = MediaQuery.textScalerOf(context).scale(1.0); @@ -371,6 +374,11 @@ class _QueueTabState extends ConsumerState { _QueueLibraryPageRequest request, ) { if (value != null) { + final liveData = value.asData?.value; + if (liveData != null) { + _queueLibraryPageDataCache[request] = liveData; + _trimQueueLibraryPageDataCache(protectedRequest: request); + } value.whenOrNull( data: (data) { _queueLibraryPageDataCache[request] = data; @@ -400,6 +408,40 @@ class _QueueTabState extends ConsumerState { return _QueueLibraryPageData.combine(pages); } + void _invalidateLibraryDataCaches() { + _queueLibraryCountsCache.clear(); + _queueLibraryPageDataCache.clear(); + _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); + } + + void _scheduleBlankLibraryRepair({ + required bool hasQueueItems, + required bool hasLibraryContent, + required bool hasAnyLibraryItems, + required bool isLibraryPageLoading, + }) { + if (!hasQueueItems || + hasLibraryContent || + hasAnyLibraryItems || + isLibraryPageLoading) { + return; + } + final now = DateTime.now(); + final last = _lastBlankLibraryRepairAt; + if (last != null && now.difference(last) < const Duration(seconds: 8)) { + return; + } + _lastBlankLibraryRepairAt = now; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _invalidateLibraryDataCaches(); + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + setState(() {}); + }); + } + void _trimQueueLibraryCountsCache() { const maxCountEntries = 24; while (_queueLibraryCountsCache.length > maxCountEntries) { @@ -2181,6 +2223,68 @@ class _QueueTabState extends ConsumerState { } } + /// Plays [item] and queues the rest of the merged library (downloaded + local + /// in display order) so playback continues to the next track. Honors player + /// mode and shuffle. + Future _playLibraryItem( + UnifiedLibraryItem item, + List libraryItems, + ) async { + final playableItems = libraryItems + .where( + (u) => u.filePath.trim().isNotEmpty && !isCueVirtualPath(u.filePath), + ) + .toList(); + if (playableItems.isEmpty) return; + + var start = playableItems.indexWhere((u) => u.id == item.id); + if (start < 0) start = 0; + + try { + await ref + .read(playbackProvider.notifier) + .playMediaQueue( + playableItems.map(_toPlayableMedia), + startIndex: start, + externalPath: item.filePath, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); + } + } + } + + PlayableMedia _toPlayableMedia(UnifiedLibraryItem item) { + final history = item.historyItem; + if (history != null) return playableFromHistory(history); + final local = item.localItem; + if (local != null) return playableFromLocal(local); + + final cover = item.coverUrl ?? item.localCoverPath ?? ''; + String? art; + if (cover.isNotEmpty) { + art = + (cover.startsWith('http') || + cover.startsWith('content://') || + cover.startsWith('file://')) + ? cover + : Uri.file(cover).toString(); + } + return PlayableMedia( + id: item.id, + source: item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + artUri: art, + ); + } + void _precacheCover(String? url) { if (url == null || url.isEmpty) return; if (!url.startsWith('http://') && !url.startsWith('https://')) { @@ -2682,6 +2786,24 @@ class _QueueTabState extends ConsumerState { } } }); + ref.listen( + downloadHistoryProvider.select((state) => state.loadedIndexVersion), + (previous, next) { + if (previous == null || previous == next) return; + _invalidateLibraryDataCaches(); + _resetLibraryPaging(); + if (mounted) setState(() {}); + }, + ); + ref.listen( + localLibraryProvider.select((state) => state.loadedIndexVersion), + (previous, next) { + if (previous == null || previous == next) return; + _invalidateLibraryDataCaches(); + _resetLibraryPaging(); + if (mounted) setState(() {}); + }, + ); final hasQueueItems = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty), @@ -2793,6 +2915,12 @@ class _QueueTabState extends ConsumerState { _searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty; final shouldShowLibraryControls = hasLibraryContent || hasAnyLibraryItems || hasActiveSearch; + _scheduleBlankLibraryRepair( + hasQueueItems: hasQueueItems, + hasLibraryContent: hasLibraryContent, + hasAnyLibraryItems: hasAnyLibraryItems, + isLibraryPageLoading: isLibraryPageLoading, + ); final bottomPadding = MediaQuery.paddingOf(context).bottom; final bottomInset = context.navBarBottomInset; @@ -4378,6 +4506,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ), child: _buildUnifiedGridItem( @@ -4391,6 +4520,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ), ); @@ -4444,6 +4574,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ), child: _buildUnifiedLibraryItem( @@ -4456,6 +4587,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ), ); @@ -4527,6 +4659,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ); }, childCount: leadCount + filteredUnifiedItems.length), @@ -4550,6 +4683,7 @@ class _QueueTabState extends ConsumerState { localNavigationItems: localNavigationItems, localNavigationIndex: localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, ), ); }, childCount: leadCount + filteredUnifiedItems.length), @@ -6755,6 +6889,7 @@ class _QueueTabState extends ConsumerState { required int? downloadedNavigationIndex, required List localNavigationItems, required int? localNavigationIndex, + required List libraryItems, }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); @@ -6933,14 +7068,8 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: - item.coverUrl ?? item.localCoverPath ?? '', - ), + onPressed: () => + _playLibraryItem(item, libraryItems), icon: Icon( Icons.play_arrow, color: colorScheme.primary, @@ -6977,6 +7106,7 @@ class _QueueTabState extends ConsumerState { required int? downloadedNavigationIndex, required List localNavigationItems, required int? localNavigationIndex, + required List libraryItems, }) { final fileExistsListenable = _fileExistsListenable(item.filePath); final isSelected = _selectedIds.contains(item.id); @@ -7085,16 +7215,8 @@ class _QueueTabState extends ConsumerState { item.artistName, ), child: GestureDetector( - onTap: () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: - item.coverUrl ?? - item.localCoverPath ?? - '', - ), + onTap: () => + _playLibraryItem(item, libraryItems), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( diff --git a/lib/screens/queue_tab_helpers.dart b/lib/screens/queue_tab_helpers.dart index ff81a82f..a5996117 100644 --- a/lib/screens/queue_tab_helpers.dart +++ b/lib/screens/queue_tab_helpers.dart @@ -580,6 +580,7 @@ class _FileExistsListenableCache { static const int _maxCacheSize = 500; final Map _cache = {}; + final Map _missCounts = {}; final Map> _notifiers = {}; final ValueNotifier _alwaysMissingNotifier = ValueNotifier(false); final Set _pendingChecks = {}; @@ -627,12 +628,34 @@ class _FileExistsListenableCache { _pendingChecks.add(cleanPath); Future.microtask(() async { - final exists = await fileExists(cleanPath); + bool exists; + try { + exists = await fileExists(cleanPath); + } catch (_) { + _pendingChecks.remove(cleanPath); + Timer(const Duration(milliseconds: 700), () => _startCheck(cleanPath)); + return; + } _pendingChecks.remove(cleanPath); - _cache[cleanPath] = exists; + if (exists) { + _missCounts.remove(cleanPath); + _cache[cleanPath] = true; + } else { + final misses = (_missCounts[cleanPath] ?? 0) + 1; + _missCounts[cleanPath] = misses; + if (misses < 2) { + Timer( + const Duration(milliseconds: 700), + () => _startCheck(cleanPath), + ); + return; + } + _cache[cleanPath] = false; + } final notifier = _notifiers[cleanPath]; - if (notifier != null && notifier.value != exists) { - notifier.value = exists; + final value = _cache[cleanPath] ?? true; + if (notifier != null && notifier.value != value) { + notifier.value = value; } }); } @@ -642,6 +665,7 @@ class _FileExistsListenableCache { notifier.dispose(); } _notifiers.clear(); + _missCounts.clear(); _alwaysMissingNotifier.dispose(); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 4ab24cf4..0646b197 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -1199,6 +1200,31 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + Future _enqueueThis(WidgetRef ref, {required bool playNext}) async { + final controller = ref.read(musicPlayerControllerProvider); + final item = widget.item; + final localItem = widget.localItem; + if (item != null) { + if (playNext) { + await controller.playNextHistory(item); + } else { + await controller.addToQueueHistory(item); + } + } else if (localItem != null) { + if (playNext) { + await controller.playNextLocal(localItem); + } else { + await controller.addToQueueLocal(localItem); + } + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(playNext ? 'Playing next' : 'Added to queue'), + ), + ); + } + Widget _buildAnimatedTrackContent( BuildContext context, WidgetRef ref, @@ -3325,9 +3351,22 @@ class _TrackMetadataScreenState extends ConsumerState { final l10n = sheetContext.l10n; final options = <_MetadataOption>[ + if (_fileExists) + _MetadataOption( + icon: Icons.playlist_play, + label: 'Play next', + onTap: () => _enqueueThis(ref, playNext: true), + ), + if (_fileExists) + _MetadataOption( + icon: Icons.queue_music, + label: 'Add to queue', + onTap: () => _enqueueThis(ref, playNext: false), + ), _MetadataOption( icon: Icons.copy_outlined, label: l10n.trackCopyFilePath, + dividerAbove: _fileExists, onTap: () => _copyToClipboard(screenContext, cleanFilePath), ), if (_fileExists) diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index a6a53c5b..309bfc9c 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; @@ -279,11 +280,13 @@ Future deleteFile(String? path) async { if (isCueVirtualPath(path)) return; if (isContentUri(path)) { await PlatformBridge.safDelete(path); + await musicPlayerHandler?.onSourceDeleted(path); return; } try { await File(path).delete(); } catch (_) {} + await musicPlayerHandler?.onSourceDeleted(path); } Future fileStat(String? path) async { diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 862be8aa..fcb0231a 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -274,27 +274,12 @@ class TrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( children: [ - if (showCoverHeader) ...[ - SkeletonBox( - width: screenWidth, - height: screenWidth * 0.75, - borderRadius: 0, - ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: SkeletonBox(width: 180, height: 20, borderRadius: 4), - ), - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 20), - child: SkeletonBox(width: 110, height: 14, borderRadius: 4), - ), - ], + if (showCoverHeader) const _CollectionHeaderSkeleton(), ...List.generate(itemCount, (index) { return Padding( padding: const EdgeInsets.symmetric( @@ -303,26 +288,37 @@ class TrackListSkeleton extends StatelessWidget { ), child: Row( children: [ - const SkeletonBox(width: 48, height: 48), + const SkeletonBox(width: 48, height: 48, borderRadius: 8), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBox( - width: 140 + (index % 3) * 30, - height: 14, + width: 150 + (index % 3) * 30, + height: 15, borderRadius: 4, ), - const SizedBox(height: 6), - SkeletonBox( - width: 90 + (index % 2) * 20, - height: 12, - borderRadius: 4, + const SizedBox(height: 8), + Row( + children: [ + SkeletonBox( + width: 90 + (index % 2) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(width: 8), + const SkeletonBox( + width: 38, + height: 12, + borderRadius: 6, + ), + ], ), ], ), ), + const SizedBox(width: 8), const SkeletonBox(width: 24, height: 24, borderRadius: 12), ], ), @@ -351,32 +347,17 @@ class AlbumTrackListSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; return ShimmerLoading( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( children: [ - if (showCoverHeader) ...[ - SkeletonBox( - width: screenWidth, - height: screenWidth * 0.75, - borderRadius: 0, - ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: SkeletonBox(width: 180, height: 20, borderRadius: 4), - ), - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 20), - child: SkeletonBox(width: 110, height: 14, borderRadius: 4), - ), - ], + if (showCoverHeader) const _CollectionHeaderSkeleton(), ...List.generate(itemCount, (index) { return Padding( padding: const EdgeInsets.symmetric( horizontal: 16, - vertical: 6, + vertical: 10, ), child: Row( children: [ @@ -384,32 +365,43 @@ class AlbumTrackListSkeleton extends StatelessWidget { width: 32, child: Center( child: SkeletonBox( - width: 14, + width: 16, height: 14, borderRadius: 4, ), ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBox( - width: 120 + (index % 4) * 35, - height: 14, + width: 130 + (index % 4) * 35, + height: 15, borderRadius: 4, ), - const SizedBox(height: 6), - SkeletonBox( - width: 70 + (index % 3) * 20, - height: 12, - borderRadius: 4, + const SizedBox(height: 8), + Row( + children: [ + SkeletonBox( + width: 70 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(width: 8), + const SkeletonBox( + width: 38, + height: 12, + borderRadius: 6, + ), + ], ), ], ), ), - const SkeletonBox(width: 20, height: 20, borderRadius: 10), + const SizedBox(width: 8), + const SkeletonBox(width: 24, height: 24, borderRadius: 12), ], ), ); @@ -421,6 +413,63 @@ class AlbumTrackListSkeleton extends StatelessWidget { } } +/// Header skeleton matching the redesigned album/playlist header: a blurred +/// backdrop block with a centered square cover, title/subtitle bars, a meta +/// line (year + quality badges) and the action button row. +class _CollectionHeaderSkeleton extends StatelessWidget { + const _CollectionHeaderSkeleton(); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final coverSize = (screenWidth * 0.5).clamp(150.0, 210.0).toDouble(); + + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + child: Column( + children: [ + SkeletonBox( + width: coverSize, + height: coverSize, + borderRadius: 16, + ), + const SizedBox(height: 20), + SkeletonBox(width: screenWidth * 0.6, height: 22, borderRadius: 6), + const SizedBox(height: 10), + SkeletonBox(width: screenWidth * 0.35, height: 15, borderRadius: 4), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + SkeletonBox(width: 44, height: 14, borderRadius: 6), + SizedBox(width: 10), + SkeletonBox(width: 70, height: 14, borderRadius: 6), + SizedBox(width: 10), + SkeletonBox(width: 60, height: 14, borderRadius: 6), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SkeletonBox(width: 48, height: 48, borderRadius: 24), + const SizedBox(width: 16), + SkeletonBox( + width: screenWidth * 0.45, + height: 48, + borderRadius: 24, + ), + const SizedBox(width: 16), + const SkeletonBox(width: 48, height: 48, borderRadius: 24), + ], + ), + const SizedBox(height: 16), + ], + ), + ); + } +} + class GridSkeleton extends StatelessWidget { final int itemCount; final int crossAxisCount; diff --git a/lib/widgets/preview_button.dart b/lib/widgets/preview_button.dart index 926b319d..c6a4632b 100644 --- a/lib/widgets/preview_button.dart +++ b/lib/widgets/preview_button.dart @@ -1,7 +1,9 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/preview_player_provider.dart'; class PreviewButton extends ConsumerWidget { @@ -21,11 +23,49 @@ class PreviewButton extends ConsumerWidget { } } + /// Loosely matches the built-in player's current item to this track so the + /// per-track button stays in sync with the mini player instead of showing a + /// conflicting preview state. + bool _isCurrentMainTrack(MediaItem? item) { + if (item == null) return false; + String norm(String? s) => (s ?? '').toLowerCase().trim(); + return norm(item.title) == norm(track.name) && + norm(item.artist) == norm(track.artistName); + } + @override Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + // When the built-in player is currently on this track, mirror and control + // it (consistent with the mini player) rather than the preview snippet. + final mainItem = ref.watch(currentMediaItemProvider).value; + if (_isCurrentMainTrack(mainItem)) { + final isPlaying = + ref.watch(playbackStateProvider).value?.playing ?? false; + return Transform.translate( + offset: const Offset(18, 0), + child: IconButton( + iconSize: size, + padding: EdgeInsets.zero, + alignment: Alignment.centerRight, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 24, minHeight: 36), + icon: Icon( + isPlaying + ? Icons.pause_circle_filled_rounded + : Icons.play_circle_fill_rounded, + color: colorScheme.primary, + ), + tooltip: isPlaying ? context.l10n.previewStop : context.l10n.previewPlay, + onPressed: () => + ref.read(musicPlayerControllerProvider).togglePlayPause(isPlaying), + ), + ); + } + if (!track.hasPreview) return const SizedBox.shrink(); - final colorScheme = Theme.of(context).colorScheme; final previewState = ref.watch(previewPlayerProvider); final isActive = previewState.isActiveUrl(track.previewUrl); final status = isActive ? previewState.status : PreviewStatus.idle;