Files
SpotiFLAC-Mobile/lib/providers/download_queue_state.dart
T

302 lines
10 KiB
Dart

import 'dart:collection';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/utils/chunked_list.dart';
/// Shares the stable key-to-position index across progress snapshots. Values
/// come from the current immutable items, so old lookups keep their old values.
class _IndexedQueueMap extends MapBase<String, DownloadItem> {
_IndexedQueueMap(this.index, this.items);
final Map<String, int> index;
final List<DownloadItem> items;
@override
DownloadItem? operator [](Object? key) {
final position = index[key];
return position == null ? null : items[position];
}
@override
Iterable<String> get keys => index.keys;
@override
int get length => index.length;
@override
bool containsKey(Object? key) => index.containsKey(key);
@override
void operator []=(String key, DownloadItem value) =>
throw UnsupportedError('Immutable queue lookup');
@override
void clear() => throw UnsupportedError('Immutable queue lookup');
@override
DownloadItem? remove(Object? key) =>
throw UnsupportedError('Immutable queue lookup');
}
/// 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<DownloadItem> 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<DownloadItem>? items,
DownloadQueueLookup? lookup,
Object? currentDownload = _noChange,
bool? isProcessing,
bool? isPaused,
String? outputDir,
String? filenameFormat,
String? singleFilenameFormat,
String? audioQuality,
bool? autoFallback,
}) {
final resolvedItems = items == null
? this.items
: ChunkedList<DownloadItem>.from(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.
///
/// Stable identity indexes are shared across progress snapshots. Sparse item
/// changes copy only affected storage chunks rather than two entire maps.
class DownloadQueueLookup {
final Map<String, DownloadItem> byTrackId;
final Map<String, DownloadItem> byItemId;
final Map<String, int> indexByItemId;
final List<String> itemIds;
final List<String> notCompletedItemIds;
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 [],
notCompletedItemIds = 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.notCompletedItemIds,
required this.queuedCount,
required this.completedCount,
required this.failedCount,
required this.activeDownloadsCount,
required this.finalizingCount,
});
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
final byTrackIndex = <String, int>{};
final indexByItemId = <String, int>{};
final itemIds = <String>[];
final notCompletedItemIds = <String>[];
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];
byTrackIndex.putIfAbsent(item.track.id, () => index);
indexByItemId[item.id] = index;
itemIds.add(item.id);
if (item.status != DownloadStatus.completed) {
notCompletedItemIds.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++;
}
final snapshot = ChunkedList<DownloadItem>.from(items);
final itemIndex = Map<String, int>.unmodifiable(indexByItemId);
return DownloadQueueLookup._(
byTrackId: _IndexedQueueMap(Map.unmodifiable(byTrackIndex), snapshot),
byItemId: _IndexedQueueMap(itemIndex, snapshot),
indexByItemId: itemIndex,
itemIds: List.unmodifiable(itemIds),
notCompletedItemIds: List.unmodifiable(notCompletedItemIds),
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<DownloadItem> previousItems,
required List<DownloadItem> nextItems,
required Iterable<int> changedIndices,
}) {
if (previousItems.length != nextItems.length ||
itemIds.length != nextItems.length ||
indexByItemId.length != nextItems.length) {
return DownloadQueueLookup.fromItems(nextItems);
}
final normalizedChanged = <int>{};
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;
var notCompletedMembershipChanged = false;
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);
}
if ((previous.status == DownloadStatus.completed) !=
(next.status == DownloadStatus.completed)) {
notCompletedMembershipChanged = true;
}
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,
);
}
if (byTrackId is! _IndexedQueueMap) {
return DownloadQueueLookup.fromItems(nextItems);
}
final snapshot = ChunkedList<DownloadItem>.from(nextItems);
return DownloadQueueLookup._(
byTrackId: _IndexedQueueMap(
(byTrackId as _IndexedQueueMap).index,
snapshot,
),
byItemId: _IndexedQueueMap(indexByItemId, snapshot),
indexByItemId: indexByItemId,
itemIds: itemIds,
notCompletedItemIds: notCompletedMembershipChanged
? List.unmodifiable([
for (final item in nextItems)
if (item.status != DownloadStatus.completed) item.id,
])
: notCompletedItemIds,
queuedCount: nextQueuedCount,
completedCount: nextCompletedCount,
failedCount: nextFailedCount,
activeDownloadsCount: nextActiveDownloadsCount,
finalizingCount: nextFinalizingCount,
);
}
}