mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 13:59:11 +02:00
302 lines
10 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|