From 5b3a2b9575d6dc85cb2f5433e96dbb2254907fda Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:07:47 +0700 Subject: [PATCH] refactor(queue): move the completion bridge into CompletionBridgeTracker The completion bridge maps, the precache set, the History row matcher and the bodies of the queue lookup and History index listeners lived on the Library tab State. CompletionBridgeTracker in lib/controllers now owns them. The two ref.listen registrations stay in build at the same positions and call into the tracker; the State forwards the old field names as getters so the part files read the same collections. Guards, the setState that drops removed items, the file check refresh order (file cache, then playable probe), the offset reset and the mounted rebuild after a History index change are unchanged. --- .../completion_bridge_tracker.dart | 106 ++++++++++++++++++ lib/screens/queue_tab.dart | 103 +++++------------ 2 files changed, 131 insertions(+), 78 deletions(-) create mode 100644 lib/controllers/completion_bridge_tracker.dart 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.