fix(player): keep shuffle order stable and restore the original queue

This commit is contained in:
zarzet
2026-09-27 00:27:26 +07:00
parent 3e6bb3bba4
commit 8199e4dbba
4 changed files with 296 additions and 84 deletions
+137
View File
@@ -269,6 +269,128 @@ void main() {
);
});
test(
'shuffle plays the published queue and off restores the original order',
() async {
final tracks = [
for (var i = 0; i < 8; i++)
PlayableMedia(
id: '$i',
source: '/$i.flac',
title: '$i',
artist: 'Artist',
),
];
await handler.setQueueAndPlay(tracks, initialIndex: 3);
await handler.setShuffleMode(AudioServiceShuffleMode.all);
final planned = handler.queue.value.map((item) => item.id).toList();
expect(planned.first, '3');
expect(planned.toSet(), tracks.map((item) => item.id).toSet());
for (var i = 1; i < planned.length; i++) {
await handler.skipToNext();
expect(handler.mediaItem.value?.id, planned[i]);
expect(native.sources['music-player'], '/${planned[i]}.flac');
expect(handler.queue.value.map((item) => item.id), planned);
}
final last = handler.mediaItem.value;
final resumes = native.resumedSources.length;
await handler.setShuffleMode(AudioServiceShuffleMode.none);
expect(
handler.queue.value.map((item) => item.id),
tracks.map((item) => item.id),
);
expect(handler.mediaItem.value, last);
expect(handler.playbackState.value.queueIndex, int.parse(planned.last));
expect(native.resumedSources.length, resumes);
await handler.setShuffleMode(AudioServiceShuffleMode.all);
expect(handler.queue.value.first.id, planned.last);
expect(handler.mediaItem.value, last);
},
);
test(
'automatic completion follows shuffle order and respects repeat off',
() async {
await handler.setQueueAndPlay(_tracks);
await handler.setShuffleMode(AudioServiceShuffleMode.all);
final planned = handler.queue.value.map((item) => item.id).toList();
for (var i = 1; i < planned.length; i++) {
await native.event('music-player', 'audio.onComplete');
await _until(
() =>
handler.mediaItem.value?.id == planned[i] &&
handler.playbackState.value.processingState ==
AudioProcessingState.ready,
);
}
await native.event('music-player', 'audio.onComplete');
await _until(
() =>
handler.playbackState.value.processingState ==
AudioProcessingState.completed,
);
expect(handler.mediaItem.value?.id, planned.last);
expect(handler.queue.value.map((item) => item.id), planned);
},
);
test(
'shuffle restoration retains duplicate entries and explicit queue edits',
() async {
await handler.setQueueAndPlay([
_tracks[0],
_tracks[0],
_tracks[1],
_tracks[2],
]);
await handler.setShuffleMode(AudioServiceShuffleMode.all);
await handler.enqueue(
const PlayableMedia(
id: 'next',
source: '/next.flac',
title: 'Next',
artist: '',
),
playNext: true,
);
await handler.enqueueAll([
const PlayableMedia(
id: 'last',
source: '/last.flac',
title: 'Last',
artist: '',
),
]);
expect(handler.queue.value[1].id, 'next');
await handler.onSourceDeleted('/two.flac');
await handler.setShuffleMode(AudioServiceShuffleMode.none);
expect(handler.queue.value.map((item) => item.id), [
'one',
'next',
'one',
'three',
'last',
]);
expect(handler.playbackState.value.queueIndex, 0);
},
);
test('restored shuffle can return to the saved original order', () async {
await handler.restoreSession(
items: [_tracks[1], _tracks[2], _tracks[0]],
index: 1,
position: const Duration(seconds: 12),
shuffle: true,
originalOrder: [1, 2, 0],
);
await handler.setShuffleMode(AudioServiceShuffleMode.none);
expect(handler.queue.value.map((item) => item.id), ['one', 'two', 'three']);
expect(handler.mediaItem.value?.id, 'three');
expect(handler.playbackState.value.queueIndex, 2);
expect(handler.playbackState.value.position.inSeconds, 12);
expect(native.resumedSources, isEmpty);
});
test(
'notification favorite retains clicked track and ignores double taps',
() async {
@@ -311,6 +433,21 @@ void main() {
return incoming;
}
test(
'AutoMix prepares and plays the next entry in the shuffled queue',
() async {
await handler.setShuffleMode(AudioServiceShuffleMode.all);
await prepare();
final planned = handler.queue.value.map((item) => item.id).toList();
final incoming = native.prepared;
expect(native.sources[incoming], '/${planned[1]}.flac');
native.positions['music-player'] = 55000;
await _until(() => handler.mediaItem.value?.id == planned[1]);
expect(handler.queue.value.map((item) => item.id), planned);
expect(handler.playbackState.value.queueIndex, 1);
},
);
test(
'disabled AutoMix uses only the ordinary player and no analysis',
() async {
+42 -17
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
@@ -164,25 +166,48 @@ void main() {
expect(metadata['title'], 'Instrumental');
});
test('shuffle candidate selection excludes recent tracks', () {
test('shuffle plans the whole queue once with the current song first', () {
final order = buildShuffledQueueOrder(
mediaCount: 12,
currentIndex: 4,
random: Random(42),
);
expect(order.first, 4);
expect(order.toSet(), {for (var i = 0; i < 12; i++) i});
expect(order.length, 12);
expect(
buildShuffleCandidatePool(
mediaCount: 6,
currentIndex: 2,
recentIndices: const [0, 1, 3],
),
[4, 5],
order.skip(1),
isNot(orderedEquals([0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11])),
);
});
test('shuffle candidate selection resets after exhausting the pool', () {
expect(
buildShuffleCandidatePool(
mediaCount: 4,
currentIndex: 2,
recentIndices: const [0, 1, 3],
),
[0, 1, 3],
);
});
test(
'new shuffle draws a new permutation and handles empty/single queues',
() {
final random = Random(42);
final first = buildShuffledQueueOrder(
mediaCount: 12,
currentIndex: 4,
random: random,
);
final second = buildShuffledQueueOrder(
mediaCount: 12,
currentIndex: 4,
random: random,
);
expect(first, isNot(orderedEquals(second)));
expect(
buildShuffledQueueOrder(
mediaCount: 0,
currentIndex: -1,
random: random,
),
isEmpty,
);
expect(
buildShuffledQueueOrder(mediaCount: 1, currentIndex: 0, random: random),
[0],
);
},
);
}