Files
plezy/test/services/music/music_playback_service_test.dart
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

1324 lines
43 KiB
Dart

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_display_criteria.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/audio_rendering_mode.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_state.dart';
import 'package:plezy/mpv/player/player_streams.dart';
import 'package:plezy/services/media_controls_manager.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_playback_service_impl.dart';
import 'package:plezy/services/music/music_source_resolver.dart';
import 'package:plezy/services/playback_coordinator.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/playback_report_fakes.dart';
const _trackDuration = Duration(minutes: 3);
MediaItem _track(String id) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track $id',
parentTitle: 'Album',
grandparentTitle: 'Artist',
durationMs: _trackDuration.inMilliseconds,
serverId: 'srv',
);
String _urlFor(MediaItem track) => 'fake://${track.id}';
class _CallGate {
final Completer<void> _entered = Completer<void>();
final Completer<void> _released = Completer<void>();
Future<void> get entered => _entered.future;
Future<void> block() {
if (!_entered.isCompleted) _entered.complete();
return _released.future;
}
void release() {
if (!_released.isCompleted) _released.complete();
}
}
/// In-memory audio player: records calls, exposes manual stream controllers
/// so tests drive transitions/completion/errors deterministically.
class FakePlayer implements Player {
final playingCtrl = StreamController<bool>.broadcast(sync: true);
final completedCtrl = StreamController<bool>.broadcast(sync: true);
final bufferingCtrl = StreamController<bool>.broadcast(sync: true);
final positionCtrl = StreamController<Duration>.broadcast(sync: true);
final durationCtrl = StreamController<Duration>.broadcast(sync: true);
final seekableCtrl = StreamController<bool>.broadcast(sync: true);
final bufferCtrl = StreamController<Duration>.broadcast(sync: true);
final volumeCtrl = StreamController<double>.broadcast(sync: true);
final rateCtrl = StreamController<double>.broadcast(sync: true);
final tracksCtrl = StreamController<Tracks>.broadcast(sync: true);
final trackCtrl = StreamController<TrackSelection>.broadcast(sync: true);
final logCtrl = StreamController<PlayerLog>.broadcast(sync: true);
final errorCtrl = StreamController<PlayerError>.broadcast(sync: true);
final audioDeviceCtrl = StreamController<AudioDevice>.broadcast(sync: true);
final audioDevicesCtrl = StreamController<List<AudioDevice>>.broadcast(sync: true);
final bufferRangesCtrl = StreamController<List<BufferRange>>.broadcast(sync: true);
final playbackRestartCtrl = StreamController<void>.broadcast(sync: true);
final fileLoadedCtrl = StreamController<void>.broadcast(sync: true);
final backendSwitchedCtrl = StreamController<void>.broadcast(sync: true);
final trackTransitionCtrl = StreamController<String>.broadcast(sync: true);
late final PlayerStreams _streams = PlayerStreams(
playing: playingCtrl.stream,
completed: completedCtrl.stream,
buffering: bufferingCtrl.stream,
position: positionCtrl.stream,
duration: durationCtrl.stream,
seekable: seekableCtrl.stream,
buffer: bufferCtrl.stream,
volume: volumeCtrl.stream,
rate: rateCtrl.stream,
tracks: tracksCtrl.stream,
track: trackCtrl.stream,
log: logCtrl.stream,
error: errorCtrl.stream,
audioDevice: audioDeviceCtrl.stream,
audioDevices: audioDevicesCtrl.stream,
bufferRanges: bufferRangesCtrl.stream,
playbackRestart: playbackRestartCtrl.stream,
fileLoaded: fileLoadedCtrl.stream,
backendSwitched: backendSwitchedCtrl.stream,
trackTransition: trackTransitionCtrl.stream,
);
PlayerState _state = const PlayerState();
final List<String> openedUris = [];
final List<Media?> setNextCalls = [];
final List<Duration> seeks = [];
final List<double> volumes = [];
Completer<void>? playGate;
Completer<void>? pauseGate;
final List<_CallGate> _openGates = [];
/// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {};
int playCalls = 0;
int pauseCalls = 0;
int stopCalls = 0;
bool _disposed = false;
Media? _armedMedia;
/// Effective armed item, mirroring the native playlist: set by [setNext],
/// consumed by an auto-advance ([emitTransition]).
Media? get armed => _armedMedia;
_CallGate _gateNextOpen() {
final gate = _CallGate();
_openGates.add(gate);
return gate;
}
void emitTransition(String uri) {
_armedMedia = null; // the backend advanced into the armed entry
_state = _state.copyWith(completed: false, position: Duration.zero, duration: _trackDuration);
trackTransitionCtrl.add(uri);
}
void emitCompleted() {
_state = _state.copyWith(completed: true, position: _trackDuration);
completedCtrl.add(true);
}
/// Emits the real boundary contract: completed always pulses, but a
/// transition can follow only when the native playlist still has an arm.
bool emitNaturalBoundary() {
final armed = _armedMedia;
emitCompleted();
if (armed == null) return false;
emitTransition(armed.uri);
return true;
}
void emitError(String message) => errorCtrl.add(PlayerError(message));
void setPosition(Duration position) {
_state = _state.copyWith(position: position);
positionCtrl.add(position);
}
void closeControllers() {
playingCtrl.close();
completedCtrl.close();
bufferingCtrl.close();
positionCtrl.close();
durationCtrl.close();
seekableCtrl.close();
bufferCtrl.close();
volumeCtrl.close();
rateCtrl.close();
tracksCtrl.close();
trackCtrl.close();
logCtrl.close();
errorCtrl.close();
audioDeviceCtrl.close();
audioDevicesCtrl.close();
bufferRangesCtrl.close();
playbackRestartCtrl.close();
fileLoadedCtrl.close();
backendSwitchedCtrl.close();
trackTransitionCtrl.close();
}
@override
PlayerState get state => _state;
@override
PlayerStreams get streams => _streams;
@override
Duration get currentPosition => _state.position;
@override
bool get audioPassthroughActive => false;
@override
int? get textureId => null;
@override
String get playerType => 'fake';
@override
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
final gate = _openGates.isEmpty ? null : _openGates.removeAt(0);
if (gate != null) await gate.block();
// PlayerNative.open uses loadfile replace, which drops any armed entry.
// Clear before recording the committed replacement open.
_armedMedia = null;
openedUris.add(media.uri);
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
if (play) playingCtrl.add(true);
}
@override
Future<void> play() async {
playCalls++;
await playGate?.future;
_state = _state.copyWith(playing: true, completed: false);
playingCtrl.add(true);
}
@override
Future<void> pause() async {
pauseCalls++;
await pauseGate?.future;
_state = _state.copyWith(playing: false);
playingCtrl.add(false);
}
@override
Future<void> playOrPause() => _state.playing ? pause() : play();
@override
Future<void> stop() async {
stopCalls++;
_state = _state.copyWith(playing: false, position: Duration.zero);
}
@override
Future<void> seek(Duration position) async {
seeks.add(position);
_state = _state.copyWith(position: position, completed: false);
}
@override
Future<void> setNext(Media? media) async {
setNextCalls.add(media);
if (media != null && failingSetNextUris.contains(media.uri)) {
throw StateError('setNext failed for ${media.uri}');
}
_armedMedia = media;
}
@override
bool get disposed => _disposed;
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
_disposed = true;
}
// Inert surface below — never exercised by the music engine.
@override
Future<void> selectAudioTrack(AudioTrack track) async {}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {}
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@override
bool get supportsSecondarySubtitles => false;
@override
bool get attachesExternalSubtitlesAtOpen => true;
@override
bool get detectsFpsAfterRender => false;
@override
bool get needsDecoderRefreshAfterDisplaySwitch => false;
@override
bool get providesNativeStats => false;
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {}
@override
Future<void> setVolume(double volume) async => volumes.add(volume);
@override
Future<void> setRate(double rate) async {}
@override
Future<void> setAudioDevice(AudioDevice device) async {}
@override
Future<void> setProperty(String name, String value) async {}
@override
Future<String?> getProperty(String name) async => null;
@override
Future<void> setLogLevel(String level) async {}
@override
Future<void> command(List<String> args) async {}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<void> configureSubtitleFonts() async {}
@override
Future<void> setAudioPassthrough(bool enabled) async {}
@override
Future<AudioRenderingMode?> getAudioRenderingMode() async => null;
@override
Future<void> setAudioNormalization(bool enabled) async {}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async => true;
@override
Future<void> updateFrame() async {}
@override
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
}) async => false;
@override
Future<void> clearVideoFrameRate() async {}
@override
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
}) async {}
@override
Future<void> setBoxFitMode(int mode) async {}
@override
Future<void> setVideoZoom(double scale) async {}
@override
Future<Map<String, dynamic>> getStats() async => {};
@override
Future<String> runtimePlayerType() async => 'fake';
@override
Future<bool> requestAudioFocus() async => true;
@override
Future<void> abandonAudioFocus() async {}
}
class RecordedReport {
final String state;
final String itemId;
final Duration position;
const RecordedReport(this.state, this.itemId, this.position);
@override
String toString() => '$state($itemId @ ${position.inSeconds}s)';
}
/// Records the progress-report surface; everything else is unimplemented
/// (the engine and tracker never touch it in these tests).
class FakeMediaServerClient extends Fake with PlaybackReportRecorder implements MediaServerClient {
final List<RecordedReport> reports = [];
final List<String> markedWatched = [];
Completer<List<MediaItem>>? instantMixGate;
Iterable<RecordedReport> reportsFor(String state) => reports.where((r) => r.state == state);
@override
ServerId get serverId => ServerId('srv');
@override
double get watchedThreshold => 0.9;
@override
bool get marksWatchedOnPlaybackStopped => false;
@override
void close() {}
@override
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) {
return instantMixGate?.future ?? Future.value(const []);
}
@override
Future<void> markWatched(MediaItem item) async {
markedWatched.add(item.id);
}
@override
Future<void> onPlaybackReport(PlaybackReportCall call) async {
final state = switch (call.kind) {
PlaybackReportKind.started => 'started',
PlaybackReportKind.progress => call.isPaused ? 'paused' : 'progress',
PlaybackReportKind.stopped => 'stopped',
};
reports.add(RecordedReport(state, call.itemId, call.position));
}
}
class FakeMusicSourceResolver implements MusicSourceResolver {
FakeMusicSourceResolver({this.client});
final MediaServerClient? client;
final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {};
final Map<String, List<_CallGate>> _resolveGates = {};
/// Per-track URL overrides (e.g. content:// shapes for offline tracks).
final Map<String, String> urlOverrides = {};
_CallGate _gateNextResolve(String trackId) {
final gate = _CallGate();
(_resolveGates[trackId] ??= []).add(gate);
return gate;
}
@override
Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
final gates = _resolveGates[track.id];
final gate = gates == null || gates.isEmpty ? null : gates.removeAt(0);
if (gates?.isEmpty ?? false) _resolveGates.remove(track.id);
if (gate != null) await gate.block();
if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}');
}
return MusicSource(
url: urlOverrides[track.id] ?? _urlFor(track),
playSessionId: 'ps-${track.id}',
playMethod: 'DirectPlay',
reportingClient: client,
);
}
}
/// Keeps the OS media session out of the tests: overrides every platform
/// touchpoint and feeds control events from a local controller.
class FakeMediaControlsManager extends MediaControlsManager {
final eventsCtrl = StreamController<MediaControlEvent>.broadcast(sync: true);
final List<String> metadataTitles = [];
bool cleared = false;
void closeControllers() {
eventsCtrl.close();
}
@override
Stream<MediaControlEvent> get controlEvents => eventsCtrl.stream;
@override
Future<void> updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async {
metadataTitles.add(metadata.title ?? '');
}
@override
Future<void> updatePlaybackState({
required bool isPlaying,
required Duration position,
required double speed,
bool force = false,
}) async {}
final List<({bool canPlayPause, bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = [];
@override
Future<void> setControlsEnabled({
bool canPlayPause = false,
bool canGoNext = false,
bool canGoPrevious = false,
bool canSeek = false,
bool canStop = false,
bool canSkip = false,
bool canSetSpeed = false,
}) async {
controlSyncs.add((
canPlayPause: canPlayPause,
canGoNext: canGoNext,
canStop: canStop,
canSkip: canSkip,
canSetSpeed: canSetSpeed,
));
}
@override
Future<void> clear() async {
cleared = true;
}
}
class _GatedVolumeWriter {
final writes = <double>[];
final gates = <Completer<void>>[];
final _writeWaiters = <int, Completer<void>>{};
var inFlight = 0;
var maxInFlight = 0;
Future<void> write(double volume) {
writes.add(volume);
inFlight++;
if (inFlight > maxInFlight) maxInFlight = inFlight;
final gate = Completer<void>();
gates.add(gate);
_writeWaiters.remove(writes.length)?.complete();
return gate.future.whenComplete(() => inFlight--);
}
Future<void> waitForWrites(int count) {
if (writes.length >= count) return Future<void>.value();
return (_writeWaiters[count] ??= Completer<void>()).future;
}
}
class _Harness {
_Harness._(this.service, this.resolver, this.client, this.controls, this.players, this.serverManager);
final MusicPlaybackServiceImpl service;
final FakeMusicSourceResolver resolver;
final FakeMediaServerClient client;
final FakeMediaControlsManager controls;
final List<FakePlayer> players;
final MultiServerManager serverManager;
/// Seeded into every created FakePlayer — lets a test configure arm
/// failures before the first player exists.
final Set<String> failingSetNextUris = {};
FakePlayer get player => players.last;
factory _Harness.create({Future<void> Function(double)? volumePersistenceWriter}) {
final client = FakeMediaServerClient();
final resolver = FakeMusicSourceResolver(client: client);
final controls = FakeMediaControlsManager();
final players = <FakePlayer>[];
final serverManager = MultiServerManager()..debugRegisterClientForTesting(client);
late final _Harness harness;
final service = MusicPlaybackServiceImpl(
serverManager: serverManager,
resolver: resolver,
audioPlayerFactory: () {
final player = FakePlayer();
player.failingSetNextUris.addAll(harness.failingSetNextUris);
players.add(player);
return player;
},
mediaControlsFactory: () => controls,
// Collapse the boundary-pulse confirmation window so completed-driven
// paths resolve within pumpEventQueue.
completedConfirmDelay: Duration.zero,
volumePersistenceWriter: volumePersistenceWriter,
);
harness = _Harness._(service, resolver, client, controls, players, serverManager);
return harness;
}
Future<void> playTracks(List<MediaItem> tracks, {MediaItem? startTrack, bool shuffle = false}) async {
await service.playFromList(
tracks: tracks,
startTrack: startTrack,
playContext: const MusicPlayContext(title: 'Test', kind: MusicPlayContextKind.album),
shuffle: shuffle,
);
await pumpEventQueue();
}
}
void main() {
final t1 = _track('t1');
final t2 = _track('t2');
final t3 = _track('t3');
late _Harness h;
late Future<void> Function(double) persistenceWriter;
setUp(() {
persistenceWriter = (_) async {};
h = _Harness.create(volumePersistenceWriter: (volume) => persistenceWriter(volume));
});
tearDown(() {
h.service.dispose();
for (final player in h.players) {
player.closeControllers();
}
h.controls.closeControllers();
h.serverManager.dispose();
});
test('volume updates notify only the dedicated volume listenable', () async {
await h.playTracks([t1]);
var serviceNotifications = 0;
var volumeNotifications = 0;
h.service.addListener(() => serviceNotifications++);
h.service.volumeListenable.addListener(() => volumeNotifications++);
await h.service.setVolume(42, persist: false);
expect(h.service.volume, 42);
expect(h.player.volumes, [42]);
expect(volumeNotifications, 1);
expect(serviceNotifications, 0);
});
test('volume persistence keeps one write in flight and coalesces a burst to the latest value', () async {
final writer = _GatedVolumeWriter();
persistenceWriter = writer.write;
var settled = 0;
final callers = [
h.service.setVolume(10).whenComplete(() => settled++),
h.service.setVolume(20).whenComplete(() => settled++),
h.service.setVolume(30).whenComplete(() => settled++),
];
await writer.waitForWrites(1);
expect(writer.writes, [10]);
expect(writer.inFlight, 1);
expect(writer.maxInFlight, 1);
expect(settled, 0);
writer.gates[0].complete();
await writer.waitForWrites(2);
expect(writer.writes, [10, 30]);
expect(writer.inFlight, 1);
expect(writer.maxInFlight, 1);
expect(settled, 0);
writer.gates[1].complete();
await Future.wait(callers);
expect(writer.inFlight, 0);
expect(settled, 3);
});
test('volume persistence propagates a drain error and accepts a later write', () async {
final writer = _GatedVolumeWriter();
persistenceWriter = writer.write;
final persistenceError = StateError('persistence failed');
var firstSettled = false;
var secondSettled = false;
final first = h.service
.setVolume(40)
.then<void>(
(_) => fail('first caller unexpectedly succeeded'),
onError: (Object error, StackTrace stackTrace) {
firstSettled = true;
expect(error, same(persistenceError));
},
);
final second = h.service
.setVolume(50)
.then<void>(
(_) => fail('second caller unexpectedly succeeded'),
onError: (Object error, StackTrace stackTrace) {
secondSettled = true;
expect(error, same(persistenceError));
},
);
await writer.waitForWrites(1);
writer.gates[0].completeError(persistenceError);
await writer.waitForWrites(2);
expect(writer.writes, [40, 50]);
expect(firstSettled, isFalse);
expect(secondSettled, isFalse);
writer.gates[1].complete();
await Future.wait([first, second]);
expect(firstSettled, isTrue);
expect(secondSettled, isTrue);
expect(writer.inFlight, 0);
final recovered = h.service.setVolume(60);
await writer.waitForWrites(3);
expect(writer.writes, [40, 50, 60]);
writer.gates[2].complete();
await recovered;
expect(writer.inFlight, 0);
});
test('playFromList opens the first track and arms the second', () async {
await h.playTracks([t1, t2, t3]);
expect(h.player.openedUris, [_urlFor(t1)]);
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.service.currentTrack?.id, 't1');
expect(h.service.currentIndex, 0);
expect(h.player.armed?.uri, _urlFor(t2));
// Track services bound: session started + OS metadata pushed.
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1']);
expect(h.controls.metadataTitles, ['Track t1']);
});
test('a superseded slow gapless resolve cannot overwrite the newly requested arm', () async {
final oldArmGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await oldArmGate.entered;
expect(h.player.armed, isNull);
h.service.addNext([t3]);
oldArmGate.release();
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t3));
});
test('end-of-track sleep invalidates a slow gapless resolve', () async {
final armGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await armGate.entered;
h.service.setSleepTimer(null, endOfTrack: true);
armGate.release();
await pumpEventQueue();
expect(h.service.sleepTimerEndOfTrack, isTrue);
expect(h.player.armed, isNull);
});
test('manual advance cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
expect(h.player.openedUris, [_urlFor(t1)]);
replacementOpenGate.release();
await advance;
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.resolver.resolveCounts[t3.id], 1);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.service.currentIndex, 2);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)], reason: 'C must advance from the native arm');
});
test('queue edits during a replacement open coalesce into the latest post-open arm', () async {
final t4 = _track('t4');
final t5 = _track('t5');
await h.playTracks([t1, t2, t3]);
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementOpenGate.entered;
final latestArmGate = h.resolver._gateNextResolve(t5.id);
h.service.addNext([t4]);
h.service.addNext([t5]);
await pumpEventQueue();
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await advance;
await latestArmGate.entered;
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], 1);
expect(h.player.armed, isNull);
latestArmGate.release();
await pumpEventQueue();
expect(h.service.queue.map((track) => track.id), [t1.id, t2.id, t5.id, t4.id, t3.id]);
expect(h.player.armed?.uri, _urlFor(t5));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t5.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('completed fallback cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
h.player.emitCompleted();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('a slow instant mix cannot replace a newer explicit queue', () async {
final mixGate = Completer<List<MediaItem>>();
h.client.instantMixGate = mixGate;
final mix = h.service.playInstantMix(t1);
await h.playTracks([t3]);
mixGate.complete([t1, t2]);
await mix;
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.service.queue.map((track) => track.id), [t3.id]);
});
test('trackTransition advances the cursor, re-arms, and reports the previous track stopped at duration', () async {
await h.playTracks([t1, t2, t3]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.currentIndex, 1);
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.player.armed?.uri, _urlFor(t3));
// No second open — the backend advanced gaplessly.
expect(h.player.openedUris, [_urlFor(t1)]);
final stopped = h.client.reportsFor('stopped').toList();
expect(stopped, hasLength(1));
expect(stopped.single.itemId, 't1');
expect(stopped.single.position, _trackDuration);
// Full playout crossed the watched threshold.
expect(h.client.markedWatched, ['t1']);
// New session started for the new track.
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
});
test('completed with nothing armed parks paused at the end and keeps the track', () async {
await h.playTracks([t1, t2]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.player.armed, isNull); // last track, repeat off
h.player.emitCompleted();
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.service.currentTrack?.id, 't2');
expect(h.service.queue, hasLength(2));
final stopped = h.client.reportsFor('stopped').toList();
expect(stopped.map((r) => r.itemId), ['t1', 't2']);
expect(stopped.last.position, _trackDuration);
});
test('completed with a failed arm falls back to opening the next track', () async {
h.resolver.failingIds.add('t2'); // arming t2 fails silently
await h.playTracks([t1, t2]);
expect(h.player.armed, isNull);
h.resolver.failingIds.clear(); // the explicit open retries the resolve
h.player.emitCompleted();
await pumpEventQueue();
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.service.currentTrack?.id, 't2');
expect(h.service.status, MusicPlaybackStatus.playing);
});
test('player error surfaces and auto-skips to the next track', () async {
await h.playTracks([t1, t2, t3]);
final errors = <Object>[];
final sub = h.service.errors.listen(errors.add);
h.player.emitError('boom');
await pumpEventQueue();
expect(errors, hasLength(1));
expect(h.service.currentTrack?.id, 't2');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.service.status, MusicPlaybackStatus.playing);
await sub.cancel();
});
test('three consecutive failures stop the session with an error status', () async {
await h.playTracks([t1, t2, t3]);
h.resolver.failingIds.addAll(['t2', 't3']);
final errors = <Object>[];
final sub = h.service.errors.listen(errors.add);
// Strike 1: player error on t1 -> skip to t2; strikes 2 and 3: t2/t3
// resolves fail -> stop as error.
h.player.emitError('boom');
await pumpEventQueue();
expect(errors, hasLength(3));
expect(h.service.status, MusicPlaybackStatus.error);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.players.single.disposed, isTrue);
await sub.cancel();
});
test('playback progress after an error resets the strike counter', () async {
await h.playTracks([t1, t2, t3, _track('t4')]);
h.player.emitError('boom'); // strike 1 -> skips to t2
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 5)); // t2 actually plays -> reset
h.player.emitError('boom'); // strike 1 again (not 2) -> skips to t3
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 5));
h.player.emitError('boom'); // still an isolated strike -> skips to t4
await pumpEventQueue();
// Without the reset this would have been the third strike (error stop).
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.service.currentTrack?.id, 't4');
});
test('claimVideo stops the session and disposes the audio core', () async {
await h.playTracks([t1, t2]);
final player = h.player;
await PlaybackCoordinator.instance.claimVideo();
expect(player.disposed, isTrue);
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.controls.cleared, isTrue);
// The played track's session was closed on the way out.
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
// A new playback after the claim recreates the player.
await h.playTracks([t3]);
expect(h.players, hasLength(2));
expect(h.player.openedUris, [_urlFor(t3)]);
expect(h.service.status, MusicPlaybackStatus.playing);
});
test('repeat-one arms the same uri without a new resolve and repeats on transition', () async {
await h.playTracks([t1, t2]);
expect(h.player.armed?.uri, _urlFor(t2));
h.service.setRepeatMode(MusicRepeatMode.one);
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t1));
expect(h.resolver.resolveCounts['t1'], 1); // reused the current source
h.player.emitTransition(_urlFor(t1));
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't1');
expect(h.service.currentIndex, 0);
expect(h.player.armed?.uri, _urlFor(t1)); // re-armed for the next loop
expect(h.resolver.resolveCounts['t1'], 1);
});
test('queue edits that keep the same next track do not re-arm or re-resolve', () async {
await h.playTracks([t1, t2, t3]);
final armCallsBefore = h.player.setNextCalls.length;
h.service.addToEnd([_track('t4')]);
await pumpEventQueue();
expect(h.player.setNextCalls.length, armCallsBefore);
expect(h.resolver.resolveCounts['t2'], 1);
});
test('previous restarts the track past 3s and steps back before that', () async {
await h.playTracks([t1, t2]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 10));
await h.service.previous();
expect(h.player.seeks, [Duration.zero]);
expect(h.service.currentTrack?.id, 't2');
h.player.setPosition(const Duration(seconds: 1));
await h.service.previous();
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't1');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t1)]);
});
test('stop clears the session and notifies', () async {
await h.playTracks([t1, t2]);
var notified = 0;
h.service.addListener(() => notified++);
await h.service.stop();
await pumpEventQueue();
expect(notified, greaterThan(0));
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.service.playContext, isNull);
expect(h.player.disposed, isTrue);
expect(h.controls.cleared, isTrue);
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('failed explicit resume does not publish playing status', () async {
await h.playTracks([t1]);
await h.service.pause();
final gate = Completer<void>();
final denied = StateError('audio focus denied');
h.player.playGate = gate;
final resume = h.service.play();
expect(h.service.status, MusicPlaybackStatus.paused);
final failureExpectation = expectLater(resume, throwsA(same(denied)));
gate.completeError(denied, StackTrace.current);
await failureExpectation;
expect(h.player.playCalls, 1);
expect(h.service.status, MusicPlaybackStatus.paused);
});
test('a late play completion cannot revive a stopped session', () async {
await h.playTracks([t1]);
await h.service.pause();
final gate = Completer<void>();
h.player.playGate = gate;
final play = h.service.play();
await h.service.stop();
gate.complete();
await play;
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
});
test('a late pause completion cannot change a stopped session', () async {
await h.playTracks([t1]);
final gate = Completer<void>();
h.player.pauseGate = gate;
final pause = h.service.pause();
await h.service.stop();
gate.complete();
await pause;
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
});
test('interruption pauses and resumes when the system says shouldResume', () async {
await h.playTracks([t1, t2]);
h.controls.eventsCtrl.add(const AudioInterruptionBeganEvent());
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.player.pauseCalls, 1);
h.controls.eventsCtrl.add(const AudioInterruptionEndedEvent(shouldResume: true));
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.player.playCalls, 1);
});
test('OS stop command stops the session', () async {
await h.playTracks([t1, t2]);
final player = h.player;
h.controls.eventsCtrl.add(const StopEvent());
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(player.disposed, isTrue);
});
test('OS skip commands seek within the track, clamped to its bounds', () async {
await h.playTracks([t1, t2]);
h.player.setPosition(const Duration(seconds: 30));
h.controls.eventsCtrl.add(const SkipForwardEvent(Duration(seconds: 15)));
await pumpEventQueue();
expect(h.player.seeks, [const Duration(seconds: 45)]);
h.controls.eventsCtrl.add(const SkipBackwardEvent(null)); // default interval
await pumpEventQueue();
expect(h.player.seeks.last, const Duration(seconds: 30));
h.player.setPosition(const Duration(seconds: 5));
h.controls.eventsCtrl.add(const SkipBackwardEvent(Duration(seconds: 15)));
await pumpEventQueue();
expect(h.player.seeks.last, Duration.zero);
});
test('music advertises play, pause, stop, and skip but never a speed control', () async {
await h.playTracks([t1, t2]);
expect(h.controls.controlSyncs, isNotEmpty);
final last = h.controls.controlSyncs.last;
expect(last.canPlayPause, isTrue);
expect(last.canStop, isTrue);
expect(last.canSkip, isTrue);
expect(last.canSetSpeed, isFalse);
});
test('interruption without shouldResume stays paused', () async {
await h.playTracks([t1]);
h.controls.eventsCtrl.add(const AudioInterruptionBeganEvent());
await pumpEventQueue();
h.controls.eventsCtrl.add(const AudioInterruptionEndedEvent(shouldResume: false));
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.player.playCalls, 0);
});
test('removing the current track opens the next one', () async {
await h.playTracks([t1, t2, t3]);
h.service.removeAt(0);
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.queue.map((t) => t.id), ['t2', 't3']);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
group('gapless boundary races', () {
// The sync stream controllers make the race drivable deterministically:
// a transition emitted between the queue edit (which un-arms the entry
// synchronously) and the event pump lands exactly like mpv rolling into
// the armed entry as it is being cleared.
test('transition raced by a reorder is adopted via the stale-arm memo', () async {
await h.playTracks([t1, t2, t3]);
expect(h.player.armed?.uri, _urlFor(t2));
h.service.reorder(1, 2); // queue [t1, t3, t2] — un-arms t2
h.player.emitTransition(_urlFor(t2)); // ...but mpv already rolled into it
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.currentIndex, 2);
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.player.openedUris, [_urlFor(t1)], reason: 'adopted gaplessly, no re-open');
final stopped = h.client.reportsFor('stopped').toList();
expect(stopped.single.itemId, 't1');
expect(stopped.single.position, _trackDuration);
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
});
test('transition into a track removed at the boundary advances to the real next', () async {
final t4 = _track('t4');
await h.playTracks([t1, t2, t3, t4]);
h.service.removeAt(1); // queue [t1, t3, t4] — un-arms t2
h.player.emitTransition(_urlFor(t2)); // mpv rolled into the removed track
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't3');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t3)]);
expect(h.player.armed?.uri, _urlFor(t4));
expect(h.client.reportsFor('stopped').single.itemId, 't1');
// A stale boundary completed pulse must not double-advance past t3.
h.player.emitCompleted();
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't3');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t3)]);
});
test('removed-at-boundary with no next parks paused', () async {
await h.playTracks([t1, t2]);
h.service.removeAt(1); // queue [t1] — un-arms t2
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.service.currentTrack?.id, 't1');
expect(h.player.pauseCalls, 1, reason: 'the removed track is audibly playing — silence it');
expect(h.player.openedUris, [_urlFor(t1)]);
});
test('stale transition after a manual advance is dropped', () async {
await h.playTracks([t1, t2, t3]);
h.service.removeAt(1); // memo t2
await h.service.next(); // manual advance clears the memo
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't3');
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't3');
expect(h.service.currentIndex, 1);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t3)]);
});
test('failed native arm falls back to an explicit open at completion', () async {
h.failingSetNextUris.add(_urlFor(t2));
await h.playTracks([t1, t2]);
expect(h.player.armed, isNull);
h.player.emitCompleted();
await pumpEventQueue();
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.service.currentTrack?.id, 't2');
expect(h.service.status, MusicPlaybackStatus.playing);
});
test('transition matching is URL-shape agnostic (offline content://)', () async {
h.resolver.urlOverrides['t2'] = 'content://downloads/t2';
await h.playTracks([t1, t2]);
expect(h.player.armed?.uri, 'content://downloads/t2');
h.player.emitTransition('content://downloads/t2');
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.status, MusicPlaybackStatus.playing);
});
});
test('timed sleep timer exposes its armed duration for the preset menu', () async {
await h.playTracks([t1]);
h.service.setSleepTimer(const Duration(minutes: 30));
expect(h.service.sleepTimerActive, isTrue);
expect(h.service.sleepTimerDuration, const Duration(minutes: 30));
expect(h.service.sleepTimerEndOfTrack, isFalse);
h.service.setSleepTimer(null, endOfTrack: true);
expect(h.service.sleepTimerDuration, isNull);
expect(h.service.sleepTimerActive, isTrue);
h.service.setSleepTimer(null);
expect(h.service.sleepTimerActive, isFalse);
expect(h.service.sleepTimerDuration, isNull);
});
test('end-of-track sleep timer suppresses arming and pauses at completion', () async {
await h.playTracks([t1, t2]);
expect(h.player.armed?.uri, _urlFor(t2));
h.service.setSleepTimer(null, endOfTrack: true);
await pumpEventQueue();
expect(h.service.sleepTimerActive, isTrue);
expect(h.player.armed, isNull);
h.player.emitCompleted();
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.service.currentTrack?.id, 't1');
expect(h.service.sleepTimerActive, isFalse);
});
}