import 'package:spotiflac_android/models/download_item.dart'; /// Immutable queue state shared by the notifier and read-only UI consumers. /// /// Keeping this model outside the notifier implementation makes queue state /// transitions testable without importing the download orchestration pipeline. class DownloadQueueState { static const Object _noChange = Object(); final List items; final DownloadQueueLookup lookup; final DownloadItem? currentDownload; final bool isProcessing; final bool isPaused; final String outputDir; final String filenameFormat; final String singleFilenameFormat; final String audioQuality; final bool autoFallback; const DownloadQueueState({ this.items = const [], this.lookup = const DownloadQueueLookup.empty(), this.currentDownload, this.isProcessing = false, this.isPaused = false, this.outputDir = '', this.filenameFormat = '{artist} - {title}', this.singleFilenameFormat = '{title} - {artist}', this.audioQuality = 'LOSSLESS', this.autoFallback = true, }); DownloadQueueState copyWith({ List? items, DownloadQueueLookup? lookup, Object? currentDownload = _noChange, bool? isProcessing, bool? isPaused, String? outputDir, String? filenameFormat, String? singleFilenameFormat, String? audioQuality, bool? autoFallback, }) { final resolvedItems = items ?? this.items; return DownloadQueueState( items: resolvedItems, lookup: lookup ?? (items != null ? DownloadQueueLookup.fromItems(resolvedItems) : this.lookup), currentDownload: identical(currentDownload, _noChange) ? this.currentDownload : currentDownload as DownloadItem?, isProcessing: isProcessing ?? this.isProcessing, isPaused: isPaused ?? this.isPaused, outputDir: outputDir ?? this.outputDir, filenameFormat: filenameFormat ?? this.filenameFormat, singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat, audioQuality: audioQuality ?? this.audioQuality, autoFallback: autoFallback ?? this.autoFallback, ); } int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount; int get completedCount => items.isEmpty ? 0 : lookup.completedCount; int get failedCount => items.isEmpty ? 0 : lookup.failedCount; int get activeDownloadsCount => items.isEmpty ? 0 : lookup.activeDownloadsCount; } /// Precomputed queue indexes and counters. /// /// The lookup can update in O(changed items) when item identities are stable, /// avoiding full queue scans for every progress event. class DownloadQueueLookup { final Map byTrackId; final Map byItemId; final Map indexByItemId; final List itemIds; final int queuedCount; final int completedCount; final int failedCount; final int activeDownloadsCount; final int finalizingCount; const DownloadQueueLookup.empty() : byTrackId = const {}, byItemId = const {}, indexByItemId = const {}, itemIds = const [], queuedCount = 0, completedCount = 0, failedCount = 0, activeDownloadsCount = 0, finalizingCount = 0; DownloadQueueLookup._({ required this.byTrackId, required this.byItemId, required this.indexByItemId, required this.itemIds, required this.queuedCount, required this.completedCount, required this.failedCount, required this.activeDownloadsCount, required this.finalizingCount, }); factory DownloadQueueLookup.fromItems(List items) { final byTrackId = {}; final byItemId = {}; final indexByItemId = {}; final itemIds = []; var queuedCount = 0; var completedCount = 0; var failedCount = 0; var activeDownloadsCount = 0; var finalizingCount = 0; for (var index = 0; index < items.length; index++) { final item = items[index]; byTrackId.putIfAbsent(item.track.id, () => item); byItemId[item.id] = item; indexByItemId[item.id] = index; itemIds.add(item.id); if (_countsAsQueued(item.status)) queuedCount++; if (item.status == DownloadStatus.completed) completedCount++; if (item.status == DownloadStatus.failed) failedCount++; if (item.status == DownloadStatus.downloading) activeDownloadsCount++; if (item.status == DownloadStatus.finalizing) finalizingCount++; } return DownloadQueueLookup._( byTrackId: Map.unmodifiable(byTrackId), byItemId: Map.unmodifiable(byItemId), indexByItemId: Map.unmodifiable(indexByItemId), itemIds: List.unmodifiable(itemIds), queuedCount: queuedCount, completedCount: completedCount, failedCount: failedCount, activeDownloadsCount: activeDownloadsCount, finalizingCount: finalizingCount, ); } static bool _countsAsQueued(DownloadStatus status) => status == DownloadStatus.queued || status == DownloadStatus.downloading || status == DownloadStatus.finalizing; static int _deltaForStatus({ required DownloadStatus previous, required DownloadStatus next, required bool Function(DownloadStatus status) predicate, }) { final had = predicate(previous); final has = predicate(next); if (had == has) return 0; return has ? 1 : -1; } DownloadQueueLookup updatedForIndices({ required List previousItems, required List nextItems, required Iterable changedIndices, }) { if (previousItems.length != nextItems.length || itemIds.length != nextItems.length || indexByItemId.length != nextItems.length) { return DownloadQueueLookup.fromItems(nextItems); } final normalizedChanged = []; for (final index in changedIndices) { if (index < 0 || index >= nextItems.length) { return DownloadQueueLookup.fromItems(nextItems); } normalizedChanged.add(index); } if (normalizedChanged.isEmpty) return this; var nextQueuedCount = queuedCount; var nextCompletedCount = completedCount; var nextFailedCount = failedCount; var nextActiveDownloadsCount = activeDownloadsCount; var nextFinalizingCount = finalizingCount; Map? nextByItemId; Map? nextByTrackId; for (final index in normalizedChanged) { final previous = previousItems[index]; final next = nextItems[index]; if (previous.id != next.id || previous.track.id != next.track.id) { return DownloadQueueLookup.fromItems(nextItems); } nextByItemId ??= Map.from(byItemId); nextByItemId[next.id] = next; if (byTrackId[next.track.id]?.id == previous.id) { nextByTrackId ??= Map.from(byTrackId); nextByTrackId[next.track.id] = next; } nextQueuedCount += _deltaForStatus( previous: previous.status, next: next.status, predicate: _countsAsQueued, ); nextCompletedCount += _deltaForStatus( previous: previous.status, next: next.status, predicate: (status) => status == DownloadStatus.completed, ); nextFailedCount += _deltaForStatus( previous: previous.status, next: next.status, predicate: (status) => status == DownloadStatus.failed, ); nextActiveDownloadsCount += _deltaForStatus( previous: previous.status, next: next.status, predicate: (status) => status == DownloadStatus.downloading, ); nextFinalizingCount += _deltaForStatus( previous: previous.status, next: next.status, predicate: (status) => status == DownloadStatus.finalizing, ); } return DownloadQueueLookup._( byTrackId: nextByTrackId == null ? byTrackId : Map.unmodifiable(nextByTrackId), byItemId: nextByItemId == null ? byItemId : Map.unmodifiable(nextByItemId), indexByItemId: indexByItemId, itemIds: itemIds, queuedCount: nextQueuedCount, completedCount: nextCompletedCount, failedCount: nextFailedCount, activeDownloadsCount: nextActiveDownloadsCount, finalizingCount: nextFinalizingCount, ); } }