feat(player): continue playback with Library autoplay

This commit is contained in:
zarzet
2026-09-27 11:53:40 +07:00
parent 9e4f24ba24
commit 5f0463a7a4
14 changed files with 643 additions and 12 deletions
+10
View File
@@ -34,6 +34,9 @@ class _Settings extends SettingsNotifier {
@override
void setAutoMix(bool enabled) => state = state.copyWith(autoMix: enabled);
@override
void setAutoplay(bool enabled) => state = state.copyWith(autoplay: enabled);
}
void main() {
@@ -76,6 +79,13 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Previous'), findsNothing);
expect(find.text('Current'), findsNothing);
await tester.tap(find.byTooltip('Autoplay off'));
await tester.pumpAndSettle();
expect(find.byTooltip('Autoplay on'), findsOneWidget);
expect(find.text('Autoplay from your Library'), findsOneWidget);
await tester.tap(find.byTooltip('Autoplay on'));
await tester.pumpAndSettle();
expect(find.byTooltip('Autoplay off'), findsOneWidget);
await tester.tap(find.byTooltip('AutoMix off'));
await tester.pumpAndSettle();
expect(find.byTooltip('AutoMix on'), findsOneWidget);
+154 -1
View File
@@ -140,12 +140,18 @@ void main() {
late _AudioNative native;
late _Analyzer analyzer;
late MusicPlayerHandler handler;
late AutoplayLibraryLoader autoplayLoader;
setUp(() {
setAutoMixEnabled(false);
setAutoplayEnabled(false);
native = _AudioNative()..install();
analyzer = _Analyzer();
handler = MusicPlayerHandler(autoMixAnalyzer: analyzer);
autoplayLoader = (_) async => _tracks;
handler = MusicPlayerHandler(
autoMixAnalyzer: analyzer,
autoplayLibraryLoader: (seed) => autoplayLoader(seed),
);
});
tearDown(() async {
@@ -156,11 +162,158 @@ void main() {
);
analyzer.pending?.complete(null);
setAutoMixEnabled(false);
setAutoplayEnabled(false);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(native.live, isEmpty);
expect(native.playing, isEmpty);
});
test(
'Autoplay fills a single-track queue and follows its visible order',
() async {
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await _until(() => handler.queue.value.length == 3);
final planned = handler.queue.value.toList();
expect(planned.first.id, 'one');
expect(
planned.skip(1).every((item) => item.extras?['autoplay'] == true),
isTrue,
);
await native.event('music-player', 'audio.onComplete');
await _until(() => handler.mediaItem.value?.id == planned[1].id);
expect(native.sources['music-player'], planned[1].extras?['source']);
setAutoplayEnabled(false);
expect(handler.queue.value.map((item) => item.id), [
'one',
planned[1].id,
]);
expect(handler.playbackState.value.playing, isTrue);
},
);
test(
'manual additions precede Autoplay and survive disabling or repeat',
() async {
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await _until(() => handler.queue.value.length == 3);
const manual = PlayableMedia(
id: 'manual',
source: '/manual.flac',
title: 'Manual',
artist: 'Other',
);
await handler.enqueue(manual);
expect(handler.queue.value[1].id, 'manual');
await handler.setShuffleMode(AudioServiceShuffleMode.all);
expect(handler.queue.value[1].id, 'manual');
await handler.setShuffleMode(AudioServiceShuffleMode.none);
expect(handler.queue.value[1].id, 'manual');
await handler.setRepeatMode(AudioServiceRepeatMode.all);
expect(handler.queue.value.map((item) => item.id), ['one', 'manual']);
await handler.setRepeatMode(AudioServiceRepeatMode.none);
await _until(() => handler.queue.value.length == 4);
setAutoplayEnabled(false);
expect(handler.queue.value.map((item) => item.id), ['one', 'manual']);
},
);
test(
'pending Autoplay is discarded after disabling or replacing the queue',
() async {
final pending = Completer<List<PlayableMedia>>();
autoplayLoader = (_) => pending.future;
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
setAutoplayEnabled(false);
await handler.setQueueAndPlay([_tracks.last]);
pending.complete(_tracks);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.queue.value.map((item) => item.id), ['three']);
expect(native.sources['music-player'], '/three.flac');
},
);
test('pause during an Autoplay refill prevents automatic resume', () async {
final pending = Completer<List<PlayableMedia>>();
autoplayLoader = (_) => pending.future;
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await native.event('music-player', 'audio.onComplete');
await handler.pause();
pending.complete(_tracks);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.mediaItem.value?.id, 'one');
expect(handler.playbackState.value.playing, isFalse);
expect(native.resumedSources, ['/one.flac']);
});
test(
'replacing a queue invalidates an in-flight recommendation batch',
() async {
final pending = Completer<List<PlayableMedia>>();
autoplayLoader = (seed) =>
seed.id == 'one' ? pending.future : Future.value([]);
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await handler.setQueueAndPlay([_tracks.last]);
pending.complete(_tracks);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.queue.value.map((item) => item.id), ['three']);
expect(native.sources['music-player'], '/three.flac');
},
);
test(
'duplicate completion while recommendations load advances only once',
() async {
final pending = Completer<List<PlayableMedia>>();
autoplayLoader = (_) => pending.future;
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await native.event('music-player', 'audio.onComplete');
await native.event('music-player', 'audio.onComplete');
pending.complete(_tracks);
await _until(() => handler.playbackState.value.queueIndex == 1);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.playbackState.value.queueIndex, 1);
expect(handler.mediaItem.value?.id, handler.queue.value[1].id);
},
);
test(
'Autoplay never adds a song dismissed from the current queue again',
() async {
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await _until(() => handler.queue.value.length == 3);
final removed = handler.queue.value[1];
handler.removeQueuedItem(removed);
await handler.skipToNext();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.queue.value.any((item) => item.id == removed.id), isFalse);
},
);
test('empty Library ends playback and does not repeatedly refill', () async {
var loads = 0;
autoplayLoader = (_) async {
loads++;
return [];
};
setAutoplayEnabled(true);
await handler.setQueueAndPlay([_tracks.first]);
await native.event('music-player', 'audio.onComplete');
await _until(
() =>
handler.playbackState.value.processingState ==
AudioProcessingState.completed,
);
expect(handler.queue.value, hasLength(1));
expect(loads, 1);
});
test(
'rapid Next keeps the final audible source and displayed title together',
() async {
+74
View File
@@ -0,0 +1,74 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
PlayableMedia track(
String id, {
String artist = 'Artist',
String? genre,
String? source,
}) => PlayableMedia(
id: id,
source: source ?? '/$id.flac',
title: id,
artist: artist,
genre: genre,
);
void main() {
test('Autoplay prefers related unplayed Library tracks and deduplicates', () {
final seed = track('seed', genre: 'Rock');
final related = track('related', genre: 'Rock');
final recent = track('recent', genre: 'Rock');
final selected = selectAutoplayTracks(
seed: seed,
candidates: [
seed,
related,
related,
recent,
track('unrelated', artist: 'Other'),
track('queued'),
track('dismissed'),
track('remote', source: 'https://example.com/audio.flac'),
track('related', source: '/copy.flac', genre: 'Rock'),
],
upcoming: [track('queued')],
recent: [recent],
dismissedSources: {'/dismissed.flac'},
random: Random(1),
);
expect(selected.map((item) => item.id), ['related', 'unrelated', 'recent']);
});
test('a small Library never recommends the current song to itself', () {
final seed = track('only');
expect(
selectAutoplayTracks(
seed: seed,
candidates: [seed],
upcoming: [],
recent: [seed],
dismissedSources: {},
random: Random(1),
),
isEmpty,
);
});
test('Autoplay preference and recommendation provenance survive restore', () {
expect(AppSettings.fromJson({}).autoplay, isFalse);
final settings = const AppSettings().copyWith(autoplay: true);
expect(AppSettings.fromJson(settings.toJson()).autoplay, isTrue);
final media = PlayableMedia.fromJson({
...track('one', genre: 'Rock').toJson(),
'autoplay': true,
})!;
final restored = PlayableMedia.fromJson(media.toJson())!;
expect(restored.autoplay, isTrue);
expect(restored.genre, 'Rock');
expect(restored.toMediaItem().extras?['autoplay'], isTrue);
});
}