import 'dart:async'; import 'dart:math'; 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 _entered = Completer(); final Completer _released = Completer(); Future get entered => _entered.future; Future 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.broadcast(sync: true); final completedCtrl = StreamController.broadcast(sync: true); final bufferingCtrl = StreamController.broadcast(sync: true); final positionCtrl = StreamController.broadcast(sync: true); final playheadJumpCtrl = StreamController.broadcast(sync: true); final durationCtrl = StreamController.broadcast(sync: true); final seekableCtrl = StreamController.broadcast(sync: true); final bufferCtrl = StreamController.broadcast(sync: true); final volumeCtrl = StreamController.broadcast(sync: true); final rateCtrl = StreamController.broadcast(sync: true); final tracksCtrl = StreamController.broadcast(sync: true); final trackCtrl = StreamController.broadcast(sync: true); final logCtrl = StreamController.broadcast(sync: true); final errorCtrl = StreamController.broadcast(sync: true); final audioDeviceCtrl = StreamController.broadcast(sync: true); final audioDevicesCtrl = StreamController>.broadcast(sync: true); final bufferRangesCtrl = StreamController>.broadcast(sync: true); final playbackRestartCtrl = StreamController.broadcast(sync: true); final fileLoadedCtrl = StreamController.broadcast(sync: true); final backendSwitchedCtrl = StreamController.broadcast(sync: true); final trackTransitionCtrl = StreamController.broadcast(sync: true); late final PlayerStreams _streams = PlayerStreams( playing: playingCtrl.stream, completed: completedCtrl.stream, buffering: bufferingCtrl.stream, position: positionCtrl.stream, playheadJump: playheadJumpCtrl.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 openedUris = []; final List setNextCalls = []; final List seeks = []; final List volumes = []; Completer? playGate; Completer? pauseGate; final List<_CallGate> _openGates = []; /// Arming these URIs throws, simulating a native setNext failure. final Set 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; } /// Emits a gapless advance. Deliberately leaves `state.position`/`duration` /// untouched: the real player announces the transition on the event flow, /// which is not ordered against the property flow — at that instant the live /// state can still carry the *finished* track's playhead (#1849). Tests /// model that handover with [setOutgoingPlayhead] before emitting. void emitTransition(String uri) { _armedMedia = null; // the backend advanced into the armed entry _state = _state.copyWith(completed: false); trackTransitionCtrl.add(uri); } /// Backdates the live state to the finished track's playhead, as the real /// player still reads when a gapless transition is announced. void setOutgoingPlayhead({required Duration position, required Duration duration}) { _state = _state.copyWith(position: position, duration: duration); } 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(); playheadJumpCtrl.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; /// Set by tests that drive a gapless transition; the real player records this /// as the outgoing source hands over. @override Duration? outgoingSourcePosition; @override bool get audioPassthroughActive => false; @override int? get textureId => null; @override String get playerType => 'fake'; @override Future open( Media media, { bool play = true, bool isLive = false, List? 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 play() async { playCalls++; await playGate?.future; _state = _state.copyWith(playing: true, completed: false); playingCtrl.add(true); } @override Future pause() async { pauseCalls++; await pauseGate?.future; _state = _state.copyWith(playing: false); playingCtrl.add(false); } @override Future playOrPause() => _state.playing ? pause() : play(); @override Future stop() async { stopCalls++; _state = _state.copyWith(playing: false, position: Duration.zero); } @override Future seek(Duration position) async { seeks.add(position); _state = _state.copyWith(position: position, completed: false); } @override Future 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 dispose({bool preserveDisplayMode = false}) async { _disposed = true; } // Inert surface below — never exercised by the music engine. @override Future selectAudioTrack(AudioTrack track) async {} @override Future selectSubtitleTrack(SubtitleTrack track) async {} @override Future 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 addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {} @override Future setVolume(double volume) async => volumes.add(volume); @override Future setRate(double rate) async {} @override Future setAudioDevice(AudioDevice device) async {} @override Future setProperty(String name, String value) async {} @override Future getProperty(String name) async => null; @override Future setLogLevel(String level) async {} @override Future command(List args) async {} @override Future setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {} @override Future configureSubtitleFonts() async {} @override Future setAudioPassthrough(bool enabled) async {} @override Future getAudioRenderingMode() async => null; @override Future setAudioNormalization(bool enabled) async {} @override Future setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {} @override Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async => true; @override Future updateFrame() async {} @override Future setVideoFrameRate( double fps, int durationMs, { int extraDelayMs = 0, int videoWidth = 0, int videoHeight = 0, }) async => false; @override Future clearVideoFrameRate() async {} @override Future 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, bool anchorToScreen = false, }) async {} @override Future setBoxFitMode(int mode) async {} @override Future setVideoZoom(double scale) async {} @override Future> getStats() async => {}; @override Future runtimePlayerType() async => 'fake'; @override Future requestAudioFocus() async => true; @override Future abandonAudioFocus() async {} } class RecordedReport { final String state; final String itemId; final Duration position; final Duration? duration; const RecordedReport(this.state, this.itemId, this.position, this.duration); @override String toString() => '$state($itemId @ ${position.inSeconds}s/${duration?.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 reports = []; final List markedWatched = []; Completer>? instantMixGate; Iterable 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> fetchInstantMix(String itemId, {int limit = 100}) { return instantMixGate?.future ?? Future.value(const []); } @override Future markWatched(MediaItem item) async { markedWatched.add(item.id); } @override Future 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, call.duration)); } } class FakeMusicSourceResolver implements MusicSourceResolver { FakeMusicSourceResolver({this.client}); final MediaServerClient? client; final Set failingIds = {}; final Map resolveCounts = {}; final Map> _resolveGates = {}; /// Per-track URL overrides (e.g. content:// shapes for offline tracks). final Map urlOverrides = {}; _CallGate _gateNextResolve(String trackId) { final gate = _CallGate(); (_resolveGates[trackId] ??= []).add(gate); return gate; } @override Future 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.broadcast(sync: true); final List metadataTitles = []; bool cleared = false; void closeControllers() { eventsCtrl.close(); } @override Stream get controlEvents => eventsCtrl.stream; @override Future updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async { metadataTitles.add(metadata.title ?? ''); } @override Future 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 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 clear() async { cleared = true; } } class _GatedVolumeWriter { final writes = []; final gates = >[]; final _writeWaiters = >{}; var inFlight = 0; var maxInFlight = 0; Future write(double volume) { writes.add(volume); inFlight++; if (inFlight > maxInFlight) maxInFlight = inFlight; final gate = Completer(); gates.add(gate); _writeWaiters.remove(writes.length)?.complete(); return gate.future.whenComplete(() => inFlight--); } Future waitForWrites(int count) { if (writes.length >= count) return Future.value(); return (_writeWaiters[count] ??= Completer()).future; } } /// Queue [Random] the tests can script. Real randomness by default; /// [lowestDraw] makes a shuffle deterministic without depending on the SDK's /// seeded-PRNG sequence (which carries no cross-release guarantee). class _ScriptedRandom implements Random { final Random _real = Random(); late int Function(int max) _draw = _real.nextInt; /// Every draw picks the lowest candidate index. void lowestDraw() => _draw = (_) => 0; @override int nextInt(int max) => _draw(max); @override bool nextBool() => _real.nextBool(); @override double nextDouble() => _real.nextDouble(); } class _Harness { _Harness._( this.service, this.resolver, this.client, this.controls, this.players, this.serverManager, this.queueRandom, ); final MusicPlaybackServiceImpl service; final FakeMusicSourceResolver resolver; final FakeMediaServerClient client; final FakeMediaControlsManager controls; final List players; final MultiServerManager serverManager; /// Drives the queue's shuffle; script it before starting a shuffled queue. final _ScriptedRandom queueRandom; /// Seeded into every created FakePlayer — lets a test configure arm /// failures before the first player exists. final Set failingSetNextUris = {}; FakePlayer get player => players.last; factory _Harness.create({Future Function(double)? volumePersistenceWriter}) { final queueRandom = _ScriptedRandom(); final client = FakeMediaServerClient(); final resolver = FakeMusicSourceResolver(client: client); final controls = FakeMediaControlsManager(); final players = []; 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, queueRandom: queueRandom, ); harness = _Harness._(service, resolver, client, controls, players, serverManager, queueRandom); return harness; } Future playTracks(List 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 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(); }); group('shuffled session start (#1811)', () { final playlist = [for (var i = 0; i < 6; i++) _track('p$i')]; test('no start track shuffles the head too instead of pinning the first track', () async { h.queueRandom.lowestDraw(); await h.playTracks(playlist, shuffle: true); final opened = h.service.currentTrack!; expect(opened.id, isNot('p0'), reason: 'shuffle opened on the list head'); expect(h.service.queue.first.id, opened.id); expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet()); expect(h.service.shuffled, isTrue); expect(h.player.openedUris, [_urlFor(opened)]); }); test('an explicit start track still anchors the shuffled queue', () async { h.queueRandom.lowestDraw(); await h.playTracks(playlist, startTrack: playlist[3], shuffle: true); expect(h.service.currentTrack!.id, 'p3'); expect(h.service.queue.first.id, 'p3'); expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet()); }); test('a start track absent from the list drops the anchor rather than pinning the first track', () async { h.queueRandom.lowestDraw(); await h.playTracks(playlist, startTrack: _track('not-in-list'), shuffle: true); expect(h.service.currentTrack!.id, isNot('p0')); expect(h.service.queue.map((t) => t.id).toSet(), playlist.map((t) => t.id).toSet()); }); }); 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( (_) => fail('first caller unexpectedly succeeded'), onError: (Object error, StackTrace stackTrace) { firstSettled = true; expect(error, same(persistenceError)); }, ); final second = h.service .setVolume(50) .then( (_) => 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>(); 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('a gaplessly advanced track reports its own start, not the finished track\'s playhead (#1849)', () async { final long = testMediaItem( id: 'long', backend: MediaBackend.plex, kind: MediaKind.track, title: 'Long opener', parentTitle: 'Album', grandparentTitle: 'Artist', durationMs: const Duration(minutes: 7).inMilliseconds, serverId: 'srv', ); await h.playTracks([long, t2]); // When the advance is announced, the live player state still carries the // finished track's playhead — the new file has not reported yet. Reporting // that as the new track's first sample told Plex it was already at ~100%, // which recorded a play (and a Last.fm scrobble) at track start on top of // the real one (#1849). h.player.setOutgoingPlayhead(position: const Duration(minutes: 7), duration: const Duration(minutes: 7)); h.player.emitTransition(_urlFor(t2)); await pumpEventQueue(); final started = h.client.reportsFor('started').toList(); expect(started.map((r) => r.itemId), ['long', 't2']); expect(started.last.position, Duration.zero); expect(started.last.duration, _trackDuration, reason: 'the initial report carries t2\'s own duration'); // The finished track is the only one whose watch settles; t2 must not be // latched watched off the stale ~100% sample. expect(h.client.markedWatched, ['long']); }); test('a track with no metadata duration is reported stopped where the source actually got to', () async { // Nothing supplies a duration to report at, so the outgoing position is the // only truth — and by the time the transition is handled the player's live // position already belongs to the track that replaced it. final undated = testMediaItem( id: 'nd', backend: MediaBackend.plex, kind: MediaKind.track, title: 'Unknown length', parentTitle: 'Album', grandparentTitle: 'Artist', serverId: 'srv', ); await h.playTracks([undated, t2]); // The gapless advance: the new source is at its start, and the player has // recorded where the old one handed over. h.player.outgoingSourcePosition = const Duration(minutes: 2, seconds: 12); h.player.setPosition(Duration.zero); h.player.emitTransition(_urlFor(t2)); await pumpEventQueue(); final stopped = h.client.reportsFor('stopped').toList(); expect(stopped, hasLength(1)); expect(stopped.single.itemId, 'nd'); expect( stopped.single.position, const Duration(minutes: 2, seconds: 12), reason: 'the new source has reset the live position; the outgoing track played to 2:12', ); }); 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 = []; 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 = []; 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(); 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(); 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(); 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); }); test('the service republishes the player playhead jumps the now-playing bar listens to', () async { // OS media controls, a headset and the lock screen seek straight through // the service, so this stream is the only way the now-playing seek bar can // learn that its pending keyboard target was superseded (#1819). What the // bar then does with a jump is covered in test/media/stepped_seek_test.dart. await h.playTracks([t1]); final jumps = []; final subscription = h.service.playheadJumpStream.listen(jumps.add); addTearDown(subscription.cancel); h.player.playheadJumpCtrl.add(const Duration(minutes: 2)); h.player.playheadJumpCtrl.add(null); await pumpEventQueue(); expect(jumps, [const Duration(minutes: 2), isNull]); }); }