perf(downloads): enqueue selected playlists in one update

This commit is contained in:
zarzet committed 2026-10-08 09:27:26 +07:00
1 parent 2ded1cff68
commit e01ec99c5e
6 files changed
+586 -47

No files matched your search

+175 -3
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -76,9 +78,10 @@ class _Queue extends DownloadQueueNotifier {
}
}
Map<String, dynamic> _stableItem(DownloadItem item) => item.toJson()
..remove('id')
..remove('createdAt');
Map<String, dynamic> _stableItem(DownloadItem item) =>
(jsonDecode(jsonEncode(item.toJson())) as Map<String, dynamic>)
..remove('id')
..remove('createdAt');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -210,4 +213,173 @@ void main() {
}
},
);
for (final processing in [false, true]) {
test(
'multiple batches keep independent metadata (processing: $processing)',
() {
final existing = DownloadItem(
id: 'existing',
track: _tracks.first,
service: 'example',
createdAt: DateTime.utc(2026),
);
final initial = const DownloadQueueState(outputDir: '/old').copyWith(
items: [existing],
isPaused: true,
isProcessing: processing,
currentDownload: processing ? existing : null,
);
final baseline = scope(initial);
final bulk = scope(initial);
final mutable = _tracks.toList();
final batches = [
DownloadQueueBatch(
tracks: mutable,
playlistName: ' One ',
playlistPositions: [7, null],
),
DownloadQueueBatch(
tracks: [_tracks.first],
playlistName: 'Second',
playlistPositions: [0],
),
DownloadQueueBatch(
tracks: [_tracks.first, _tracks.first],
playlistName: ' ',
playlistPositions: [-1, 9, 99],
),
const DownloadQueueBatch(tracks: [], playlistName: 'Empty'),
DownloadQueueBatch(tracks: [_tracks.last], playlistPositions: [4]),
];
try {
final loop = baseline.read(downloadQueueProvider.notifier);
for (final batch in batches) {
loop.addMultipleToQueue(
batch.tracks,
' example ',
qualityOverride: 'HIGH',
playlistName: batch.playlistName,
playlistPositions: batch.playlistPositions,
);
}
final queue = bulk.read(downloadQueueProvider.notifier);
var publications = 0;
bulk.listen(downloadQueueProvider, (_, _) => publications++);
queue.addBatchesToQueue(
batches,
' example ',
qualityOverride: 'HIGH',
);
final state = bulk.read(downloadQueueProvider);
final expected = baseline.read(downloadQueueProvider);
expect(state.items.map(_stableItem), expected.items.map(_stableItem));
expect(publications, 1);
expect(state.items.first, same(existing));
expect(state.items.map((item) => item.id).toSet(), hasLength(7));
expect(state.isPaused, true);
expect(state.isProcessing, processing);
expect(state.currentDownload, same(initial.currentDownload));
expect(state.outputDir, _settings.downloadDirectory);
expect(state.filenameFormat, _settings.filenameFormat);
expect(state.audioQuality, _settings.audioQuality);
expect(state.autoFallback, _settings.autoFallback);
final added = state.items.skip(1).toList();
expect(added.map((item) => item.track.albumArtist), [
'Artist',
'Artist',
'Artist & Guest',
'Artist & Guest',
'Artist & Guest',
_tracks.last.albumArtist,
]);
expect(added.map((item) => item.fromBatch), [
true,
true,
false,
true,
true,
false,
]);
expect(added.map((item) => item.playlistPosition), [
7,
2,
1,
null,
9,
4,
]);
expect(added.map((item) => item.playlistName), [
' One ',
' One ',
'Second',
' ',
' ',
null,
]);
expect(
added.map((item) => item.service),
everyElement('replacement'),
);
expect(
added.map((item) => item.qualityOverride),
everyElement('HIGH'),
);
expect(
added.map((item) => item.preserveQualityVariant),
everyElement(true),
);
expect(
added.map((item) => item.networkDownloadFolder),
everyElement(_settings.networkDownloadFolder),
);
mutable.clear();
batches.clear();
expect(state.items, hasLength(7));
queue.addBatchesToQueue([
DownloadQueueBatch(tracks: [_tracks.first]),
], 'example');
expect(
bulk
.read(downloadQueueProvider)
.items
.map((item) => item.id)
.toSet(),
hasLength(8),
);
} finally {
bulk.dispose();
baseline.dispose();
}
},
);
}
test(
'no batches is a no-op, an explicit empty batch still applies settings',
() {
const initial = DownloadQueueState(outputDir: '/old', isPaused: true);
final container = scope(initial);
try {
final queue = container.read(downloadQueueProvider.notifier);
var publications = 0;
container.listen(downloadQueueProvider, (_, _) => publications++);
queue.addBatchesToQueue([], 'example');
expect(container.read(downloadQueueProvider), same(initial));
expect(publications, 0);
queue.addBatchesToQueue([
const DownloadQueueBatch(tracks: []),
], 'example');
expect(publications, 1);
expect(container.read(downloadQueueProvider).items, isEmpty);
expect(container.exists(extensionProvider), false);
expect(
container.read(downloadQueueProvider).outputDir,
_settings.downloadDirectory,
);
} finally {
container.dispose();
}
},
);
}
+139 -2
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_browse_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/library_search_provider.dart';
@@ -73,9 +74,49 @@ class _Queue extends DownloadQueueNotifier {
DownloadQueueState build() => DownloadQueueState();
}
class _DownloadSettings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings(
defaultLibraryView: 'all',
defaultService: 'example',
askQualityBeforeDownload: false,
);
}
class _DownloadExtensions extends ExtensionNotifier {
@override
ExtensionState build() => const ExtensionState(
extensions: [
Extension(
id: 'example',
name: 'example',
displayName: 'Example',
version: '1',
description: '',
enabled: true,
status: 'loaded',
hasDownloadProvider: true,
),
],
);
}
class _BatchQueue extends _Queue {
_BatchQueue(this._record);
final void Function(List<DownloadQueueBatch>, String, String?) _record;
@override
void addBatchesToQueue(
List<DownloadQueueBatch> batches,
String service, {
String? qualityOverride,
}) => _record(batches, service, qualityOverride);
}
class _SelectionCollections extends LibraryCollectionsNotifier {
_SelectionCollections(this._record);
_SelectionCollections(this._record, {bool withTracks = false})
: _withTracks = withTracks;
final void Function(List<String>) _record;
final bool _withTracks;
@override
LibraryCollectionsState build() => LibraryCollectionsState(
@@ -87,11 +128,29 @@ class _SelectionCollections extends LibraryCollectionsNotifier {
name: id,
createdAt: DateTime.utc(2026),
updatedAt: DateTime.utc(2026),
tracks: const [],
tracks: _withTracks
? [
for (var i = 0; i < (id == 'Second' ? 2 : 1); i++)
CollectionTrackEntry(
key: '$id-$i',
addedAt: DateTime.utc(2026),
track: Track(
id: '$id-$i',
name: '$id-$i',
artistName: 'Artist',
albumName: 'Album',
duration: 180,
),
),
]
: const [],
),
],
);
@override
Future<void> ensurePlaylistsLoaded(Iterable<String> ids) async {}
@override
Future<int> deletePlaylists(Iterable<String> ids) async {
final selected = ids.toSet();
@@ -155,6 +214,84 @@ void main() {
});
for (final mornye in [false, true]) {
testWidgets(
'playlist download confirms one grouped enqueue (Mornye: $mornye)',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final calls = <List<DownloadQueueBatch>>[];
final services = <String>[];
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_DownloadSettings.new),
extensionProvider.overrideWith(_DownloadExtensions.new),
downloadHistoryProvider.overrideWith(() => history),
localLibraryProvider.overrideWith(() => local),
libraryCollectionsProvider.overrideWith(
() => _SelectionCollections((_) {}, withTracks: true),
),
downloadQueueProvider.overrideWith(
() => _BatchQueue((batches, service, quality) {
calls.add(batches);
services.add(service);
expect(quality, isNull);
}),
),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: QueueTab(librarySection: 'playlists')),
),
),
);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.byType(QueueTab)));
await tester.ensureVisible(find.text('First'));
await tester.longPress(find.text('First'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Second'));
await tester.tap(find.text('Second'));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.bulkDownloadPlaylistsButton(2)));
await tester.pumpAndSettle();
expect(
find.text(l10n.dialogDownloadPlaylistsMessage(3, 2)),
findsOneWidget,
);
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(calls, isEmpty);
await tester.tap(find.text(l10n.bulkDownloadPlaylistsButton(2)));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.dialogDownload));
await tester.pumpAndSettle();
expect(calls, hasLength(1));
expect(services, ['example']);
expect(calls.single.map((batch) => batch.playlistName), [
'First',
'Second',
]);
expect(
calls.single.map(
(batch) => batch.tracks.map((track) => track.id).toList(),
),
[
['First-0'],
['Second-0', 'Second-1'],
],
);
expect(find.text(l10n.snackbarAddedTracksToQueue(3)), findsOneWidget);
expect(find.text(l10n.bulkDownloadPlaylistsButton(2)), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('playlist selection confirms one deletion (Mornye: $mornye)', (
tester,
) async {