Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry HDR at all, and which forced a whole-window Flutter recomposite for every video frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with mpv rendering into an EGL window surface on it through the libmpv render API. The subsurface is desynchronized, so video and UI now present independently. With the plane in place HDR follows: the surface is described to the compositor through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG, BT.2020 - carrying whatever HDR10 static metadata the stream actually declares. The description and the buffer it describes land on the same commit, staged and validated before mpv is switched, so a PQ frame is never presented labelled sRGB. A five-second watchdog bounds the one wait a compositor could otherwise leave hanging. A session that cannot host the plane - X11, or a compositor without wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the reason: the texture path is gone, and refusing by name beats degrading to something the user cannot see. An SDR output, a missing capability or an 8-bit config keep the plane and simply leave it undescribed. The output's colour state is trusted only when it has been earned. Every landed property step records itself as it lands; a reset or sequence that cannot finish downgrades its result to unknown and marks the applied-output cache untrusted until a clean apply earns it back. A plane whose output state cannot be named is quarantined - hidden, its description withdrawn - and the quarantine is recorded state: an unrelated visibility change cannot put a mislabelled plane back on screen, and only a commit that resolves to a nameable outcome lifts it. A rect collapsing to zero detaches the buffer exactly as hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the next layout pass retries for free, and a refused tone-mapping pick tells the user instead of dying in a log. NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window configs, so the plane takes half-float as the tier between 10-bit unorm and 8-bit, declares the whole surface opaque so the compositor never reads the alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie. Whether the output is in HDR is read from luminance headroom above its own reference white rather than from the preferred transfer function, which current KWin no longer answers PQ for; the margin is half a stop, because KWin reports an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090 (driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs. Who tone-maps is a user choice. The default is the compositor: photographed on a 400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and separated where the player leg flattens them, because the player path drives mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the renderer, not the wiring. The decision itself - what the source carries, what the output supports, what to tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of Wayland and GTK so its luminance validation can be tested without a display server. Sending an incoherent luminance set is a protocol error that disconnects the client, so the rules are worth a unit test. The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL: the plane links them directly and bundle-libs.sh deliberately never bundles them, since they are coupled to the running compositor and GPU driver. lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware - it drives one clip with scripted mpv properties and reports the colour state mpv actually settled on. Nothing imports it, so it is tree-shaken out of the app. Verified on a Steam Deck against an external 400-nit HDR display: the compositor reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall - closer to the reference HDR player than to its own SDR fallback.
1497 lines
49 KiB
Dart
1497 lines
49 KiB
Dart
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<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 playheadJumpCtrl = 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,
|
|
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<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;
|
|
}
|
|
|
|
/// 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;
|
|
|
|
// Audio only; there is no video output to carry HDR.
|
|
@override
|
|
Future<bool> isHdrOutputSupported() async => false;
|
|
|
|
@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,
|
|
bool anchorToScreen = 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;
|
|
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<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, call.duration));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// 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<FakePlayer> 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<String> failingSetNextUris = {};
|
|
|
|
FakePlayer get player => players.last;
|
|
|
|
factory _Harness.create({Future<void> Function(double)? volumePersistenceWriter}) {
|
|
final queueRandom = _ScriptedRandom();
|
|
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,
|
|
queueRandom: queueRandom,
|
|
);
|
|
harness = _Harness._(service, resolver, client, controls, players, serverManager, queueRandom);
|
|
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();
|
|
});
|
|
|
|
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<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('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 = <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);
|
|
});
|
|
|
|
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 = <Duration?>[];
|
|
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]);
|
|
});
|
|
}
|