diff --git a/lib/controllers/completion_bridge_tracker.dart b/lib/controllers/completion_bridge_tracker.dart new file mode 100644 index 00000000..a903fb9a --- /dev/null +++ b/lib/controllers/completion_bridge_tracker.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:spotiflac_android/models/download_history.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/providers/download_queue_state.dart'; + +/// Just-completed queue items that the Library keeps showing until their +/// History row lands in the paged Library query. +/// +/// The owning State registers the `ref.listen` callbacks in `build` and calls +/// into this tracker; `shouldRetainCompletionBridge` in +/// queue_library_refresh_policy.dart decides when a bridged item expires. +class CompletionBridgeTracker { + /// Bridged queue items by queue item id. + final Map items = {}; + + /// When each bridged item completed. + final Map addedAt = {}; + + /// Bridged items whose cover precache has already been scheduled. + final Set precacheStarted = {}; + + /// Finds the History row for a bridged [item] by file path, id, track id + /// and then ISRC. + static DownloadHistoryItem? historyItemFor( + DownloadItem item, + List historyItems, + ) { + final filePath = item.filePath?.trim(); + if (filePath != null && filePath.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.filePath == filePath) return historyItem; + } + } + + for (final historyItem in historyItems) { + if (historyItem.id == item.id) return historyItem; + } + + final trackId = item.track.id.trim(); + if (trackId.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.spotifyId == trackId) return historyItem; + } + } + + final isrc = item.track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + for (final historyItem in historyItems) { + if (historyItem.isrc == isrc) return historyItem; + } + } + + return null; + } + + /// Handles a queue lookup change: drops bridged items that left the queue + /// (rebuilding through [setState]) and bridges items that just completed, + /// refreshing their file checks through [refreshPath] first. + void onQueueLookupChanged( + DownloadQueueLookup? previous, + DownloadQueueLookup next, { + required void Function(VoidCallback fn) setState, + required void Function(String? path) refreshPath, + }) { + if (previous == null) return; + if (items.keys.any((id) => !next.byItemId.containsKey(id))) { + setState(() { + items.removeWhere((id, _) => !next.byItemId.containsKey(id)); + addedAt.removeWhere((id, _) => !next.byItemId.containsKey(id)); + precacheStarted.removeWhere((id) => !next.byItemId.containsKey(id)); + }); + } + if (identical(previous.notCompletedItemIds, next.notCompletedItemIds)) { + return; + } + for (final id in previous.notCompletedItemIds) { + final prevItem = previous.byItemId[id]; + final nextItem = next.byItemId[id]; + if (prevItem == null) continue; + final wasActive = + prevItem.status == DownloadStatus.downloading || + prevItem.status == DownloadStatus.finalizing || + prevItem.status == DownloadStatus.queued; + final nowCompleted = + nextItem != null && nextItem.status == DownloadStatus.completed; + if (wasActive && nowCompleted) { + refreshPath(nextItem.filePath); + items[id] = nextItem; + addedAt[id] = DateTime.now(); + } + } + } + + /// Refreshes the file checks of every bridged item and of its History row + /// after History reloads its index. + void refreshPaths( + List historyItems, { + required void Function(String? path) refreshPath, + }) { + for (final bridgeItem in items.values) { + refreshPath(bridgeItem.filePath); + final historyPath = historyItemFor(bridgeItem, historyItems)?.filePath; + refreshPath(historyPath); + } + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ebdfa69a..15bb65d9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -81,6 +81,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/screens/mornye_library_screen.dart'; +import 'package:spotiflac_android/controllers/completion_bridge_tracker.dart'; import 'package:spotiflac_android/controllers/queue_library_pager.dart'; import 'package:spotiflac_android/controllers/queue_library_refresh_policy.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -106,37 +107,7 @@ part 'queue_tab_download_labels.dart'; part 'queue_tab_download_dialogs.dart'; part 'queue_tab_filter_sheet.dart'; -DownloadHistoryItem? _historyItemForCompletionBridge( - DownloadItem item, - List historyItems, -) { - final filePath = item.filePath?.trim(); - if (filePath != null && filePath.isNotEmpty) { - for (final historyItem in historyItems) { - if (historyItem.filePath == filePath) return historyItem; - } - } - - for (final historyItem in historyItems) { - if (historyItem.id == item.id) return historyItem; - } - - final trackId = item.track.id.trim(); - if (trackId.isNotEmpty) { - for (final historyItem in historyItems) { - if (historyItem.spotifyId == trackId) return historyItem; - } - } - - final isrc = item.track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - for (final historyItem in historyItems) { - if (historyItem.isrc == isrc) return historyItem; - } - } - - return null; -} +const _historyItemForCompletionBridge = CompletionBridgeTracker.historyItemFor; class QueueTab extends ConsumerStatefulWidget { final String? librarySection; @@ -204,9 +175,14 @@ class _QueueTabState extends ConsumerState { final FocusNode _searchFocusNode = FocusNode(); String _searchQuery = ''; Timer? _searchDebounce; - final Map _completionBridge = {}; - final Map _completionBridgeAt = {}; - final Set _bridgePrecacheStarted = {}; + final CompletionBridgeTracker _completionBridgeTracker = + CompletionBridgeTracker(); + Map get _completionBridge => + _completionBridgeTracker.items; + Map get _completionBridgeAt => + _completionBridgeTracker.addedAt; + Set get _bridgePrecacheStarted => + _completionBridgeTracker.precacheStarted; String? _filterSource; String? _filterQuality; String? _filterFormat; @@ -468,6 +444,11 @@ class _QueueTabState extends ConsumerState { return _fileExistsCache.listenable(filePath); } + void _refreshCompletionBridgePath(String? filePath) { + _fileExistsCache.refreshForPath(filePath); + _completionBridgePlayableProbe.refreshForPath(filePath); + } + int get _activeFilterCount { int count = 0; if (_filterSource != null) count++; @@ -506,56 +487,22 @@ class _QueueTabState extends ConsumerState { ); ref.listen(downloadQueueLookupProvider, (previous, next) { - if (previous == null) return; - if (_completionBridge.keys.any((id) => !next.byItemId.containsKey(id))) { - setState(() { - _completionBridge.removeWhere( - (id, _) => !next.byItemId.containsKey(id), - ); - _completionBridgeAt.removeWhere( - (id, _) => !next.byItemId.containsKey(id), - ); - _bridgePrecacheStarted.removeWhere( - (id) => !next.byItemId.containsKey(id), - ); - }); - } - if (identical(previous.notCompletedItemIds, next.notCompletedItemIds)) { - return; - } - for (final id in previous.notCompletedItemIds) { - final prevItem = previous.byItemId[id]; - final nextItem = next.byItemId[id]; - if (prevItem == null) continue; - final wasActive = - prevItem.status == DownloadStatus.downloading || - prevItem.status == DownloadStatus.finalizing || - prevItem.status == DownloadStatus.queued; - final nowCompleted = - nextItem != null && nextItem.status == DownloadStatus.completed; - if (wasActive && nowCompleted) { - _fileExistsCache.refreshForPath(nextItem.filePath); - _completionBridgePlayableProbe.refreshForPath(nextItem.filePath); - _completionBridge[id] = nextItem; - _completionBridgeAt[id] = DateTime.now(); - } - } + _completionBridgeTracker.onQueueLookupChanged( + previous, + next, + setState: setState, + refreshPath: _refreshCompletionBridgePath, + ); }); ref.listen( downloadHistoryProvider.select((state) => state.loadedIndexVersion), (previous, next) { if (previous == null || previous == next) return; final historyItems = ref.read(downloadHistoryProvider).items; - for (final bridgeItem in _completionBridge.values) { - _fileExistsCache.refreshForPath(bridgeItem.filePath); - _completionBridgePlayableProbe.refreshForPath(bridgeItem.filePath); - final historyPath = _historyItemForCompletionBridge( - bridgeItem, - historyItems, - )?.filePath; - _fileExistsCache.refreshForPath(historyPath); - _completionBridgePlayableProbe.refreshForPath(historyPath); - } + _completionBridgeTracker.refreshPaths( + historyItems, + refreshPath: _refreshCompletionBridgePath, + ); // The family provider already reruns for the new revision. Retain its // last successful page while SQLite is loading so metadata backfills // and download completions cannot flash the Library as empty.