Files
plezy/lib/mpv/player/player_base.dart
T
edde746 e6be5f9fef fix(player): surface a persistent HTTP 503 at open instead of retrying forever
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a
server that keeps refusing the stream at open time left a silent black screen:
ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached
the screen. A new open-phase watchdog arms on the first 503 seen before any
frame renders and, after 20s without one, synthesizes a server-http-503 error
that shows an actionable dialog. Mid-stream 503s and live TV keep their
existing ride-out paths.

close #1830
2026-08-08 12:06:32 +02:00

1462 lines
57 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
import '../../utils/app_logger.dart';
import '../../utils/track_label_builder.dart';
import '../font_loader.dart';
import '../models.dart';
import 'mpv_node_decoder.dart';
import 'audio_rendering_mode.dart';
import 'player.dart';
import 'player_state.dart';
import 'player_stream_controllers.dart';
import 'player_streams.dart';
/// Abstract base class for player implementations.
///
/// This class contains shared logic for both [PlayerAndroid] (ExoPlayer)
/// and [PlayerNative] (MPV) implementations, including:
/// - State management
/// - Stream controller setup
/// - Event handling infrastructure
/// - Property change handlers
/// - Track parsing and selection
/// - Common lifecycle methods
abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
PlayerState _state = const PlayerState();
@override
PlayerState get state => _state;
@override
Duration get currentPosition => Duration(milliseconds: _positionMs);
@override
bool get audioPassthroughActive => false;
/// Gapless-audio arming — meaningful only on the audio players, which
/// override this. Video backends ignore it.
@override
Future<void> setNext(Media? media) async {}
late final PlayerStreams _streams;
@override
PlayerStreams get streams => _streams;
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
@override
int? get textureId => _textureId.value;
ValueListenable<int?> get textureIdListenable => _textureId;
@protected
void setTextureId(int? value) {
if (!_disposed) _textureId.value = value;
}
StreamSubscription? _eventSubscription;
StreamSubscription? _logSubscription;
bool _disposed = false;
late final Future<void>? _nativeOwnershipReady;
final Completer<void> _nativeRelease = Completer<void>();
final _throttleSw = Stopwatch()..start();
int _lastEmitMs = 0;
int _lastCacheStateMs = 0;
int _positionMs = 0;
/// Overlapping-seek bookkeeping. Each [runSeek] optimistically writes its own
/// target, so only the last of a group to settle can tell where the backend
/// actually left the playhead — and because the backend applies commands in
/// the order they were issued, "which one landed" is decided by request
/// order, not by which reply came back first.
///
/// A playhead move from outside [runSeek] detaches the active group: it is
/// newer information than any of that group's outcomes, and seeks starting
/// after it belong to a fresh group anchored where it left the playhead.
/// Issue-time id for anything that asks the playhead to move — a seek
/// request, a relocation claim, a source install. Monotonic, so it also
/// orders [runSeek] calls inside a group.
int _playheadOperations = 0;
/// The newest operation the backend actually accepted. A completion may only
/// speak for the playhead while nothing newer has been accepted; asking is
/// not owning, so a rejected request never silences an accepted one.
int _acceptedOperation = 0;
/// A relocation whose destination arrived while a newer seek was still in
/// flight. Held rather than published: that seek will define the playhead if
/// it lands, and this is still the truth if it does not.
({int token, Duration? position})? _deferredRelocation;
/// Which operation last wrote a position into state. Compared by identity,
/// not by value: a successor seeking to the same timestamp is still a
/// different write and must not be repaired away by its predecessor.
int _lastPositionWriter = 0;
/// Stands in for the backend in [_lastPositionWriter]: a reported position is
/// authoritative and beats anything Dart wrote optimistically, whatever its
/// value happens to be.
static const int _backendReportedWriter = -1;
_SeekGroup? _activeSeekGroup;
@override
Duration? get outgoingSourcePosition => _outgoingSourcePosition;
Duration? _outgoingSourcePosition;
/// The last position the backend reported *for the source now playing*, as
/// distinct from [_positionMs], which also carries Dart's optimistic writes.
/// Only an observation can say where a source actually got to, and an
/// observation of one source says nothing about the next — so installing a
/// source clears this rather than letting it carry over.
int _lastReportedPositionMs = 0;
/// Set by [freezeOutgoingSourcePosition] when a source boundary is seen on
/// the same flow that carries position reports. Preferred by
/// [takeSourceOwnership], which cannot make that ordering guarantee itself.
int? _frozenOutgoingPositionMs;
/// Wall-clock slack added to a seek's flight time before converting to media
/// time, absorbing tick granularity and clock jitter.
static const _landedProgressSlack = Duration(milliseconds: 250);
/// Claim the playhead for a relocation whose destination the backend has not
/// reported yet, and take the token that says so.
///
/// Claiming is what makes two overlapping relocations distinguishable:
/// reading a shared revision would let them both think they still speak for
/// the playhead.
///
/// Claiming alone changes nothing else. A command that is then rejected never
/// moved the playhead, so an in-flight seek group stays authoritative and
/// reconciles its own outcome. A command that is accepted hands the token to
/// [commitPlayheadRelocation], which takes ownership even when the
/// destination cannot be read back, and then to [publishPlayheadRelocation]
/// when there is a destination to report.
@protected
int beginPlayheadRelocation() => ++_playheadOperations;
/// Record that a claimed relocation actually moved the playhead, even though
/// its destination could not be read back.
///
/// Unknown movement is still movement: an in-flight seek group settling
/// afterwards must not roll back across it, so ownership passes here while
/// the position itself waits for the backend's next tick.
@protected
void commitPlayheadRelocation(int token) {
if (_disposed || token < _acceptedOperation) return;
_takeOperationOwnership(token);
// A newer seek is still in flight: keep its group so it can arbitrate
// against this relocation when it resolves. Recorded without a destination,
// because an accepted command moved the playhead whether or not its
// position can be read — the group must not roll back across it.
if (_hasUnresolvedSeekNewerThan(token)) {
_deferredRelocation = (token: token, position: null);
return;
}
// The claim already made this token the owner; taking the playhead from the
// seek group is all that is left. The token stays valid so the same
// relocation can still publish a destination once it reads one back.
_activeSeekGroup = null;
}
Duration? _timelineDuration;
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
Map<String, List<SubtitleTrack>> _externalSubtitleMetadataByUri = const {};
bool _primaryMediaLoadStarted = false;
bool _primaryMediaReadyEmitted = false;
@visibleForTesting
static Duration debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3);
static const _maximumDurationMilliseconds = 9223372036854775;
static double? _finiteDouble(Object? value) {
if (value is! num) return null;
final result = value.toDouble();
return result.isFinite ? result : null;
}
static int? _millisecondsFromSeconds(Object? value, {bool round = false}) {
final seconds = _finiteDouble(value);
if (seconds == null) return null;
final milliseconds = seconds * Duration.millisecondsPerSecond;
if (!milliseconds.isFinite ||
milliseconds < -_maximumDurationMilliseconds ||
milliseconds > _maximumDurationMilliseconds) {
return null;
}
return round ? milliseconds.round() : milliseconds.toInt();
}
static int? _finiteInt(Object? value) {
if (value is int) return value;
final result = _finiteDouble(value);
if (result == null || result < -9007199254740991 || result > 9007199254740991) return null;
return result.toInt();
}
@protected
bool initialized = false;
@override
bool get disposed => _disposed;
MethodChannel get methodChannel;
EventChannel get eventChannel;
String get logPrefix;
PlayerBase() {
_nativeOwnershipReady = _eventChannelOwners[eventChannel.name]?._nativeRelease.future;
_streams = createStreams();
_setupEventListener();
_logSubscription = logController.stream.listen(_forwardToAppLogger);
}
void _forwardToAppLogger(PlayerLog log) {
final message = '[$logPrefix:${log.prefix}] ${log.text}'.trimRight();
switch (log.level) {
case PlayerLogLevel.fatal:
case PlayerLogLevel.error:
appLogger.e(message);
case PlayerLogLevel.warn:
appLogger.w(message);
case PlayerLogLevel.info:
case PlayerLogLevel.verbose:
appLogger.i(message);
case PlayerLogLevel.debug:
case PlayerLogLevel.trace:
appLogger.d(message);
case PlayerLogLevel.none:
break;
}
}
/// Backends expose static per-backend [EventChannel]s, so two overlapping
/// instances (episode handoff, quick exit/reopen) share one channel name.
/// The engine allows a single active stream per channel: a newer instance's
/// listen displaces the older sink, and the older instance's late cancel
/// would then tear down the *newer* stream — leaving it eventless — while
/// the final cancel gets an engine "No active stream to cancel" error.
/// Only the instance recorded here may send the native cancel.
static final Map<String, PlayerBase> _eventChannelOwners = {};
void _setupEventListener() {
_eventChannelOwners[eventChannel.name] = this;
_eventSubscription = eventChannel.receiveBroadcastStream().listen(
_handleEvent,
onError: (error) {
if (_disposed) return;
errorController.add(PlayerError(error.toString()));
},
);
}
/// The (name, format) registrations every backend makes at init — the
/// properties [handlePropertyChange] needs for core [PlayerState].
/// `track-list` is registered separately because mpv uses node format on
/// Apple platforms; backend-specific extras (mpv: secondary-sid /
/// demuxer-cache-state / audio-device*; ExoPlayer: demuxer-cache-time)
/// are appended by the subclasses.
static const List<(String, String)> corePropertyObservations = [
('time-pos', 'double'),
('duration', 'double'),
('seekable', 'flag'),
('pause', 'flag'),
('paused-for-cache', 'flag'),
('eof-reached', 'flag'),
('volume', 'double'),
('speed', 'double'),
('aid', 'string'),
('sid', 'string'),
];
/// Register [corePropertyObservations] plus `track-list` in the
/// backend's preferred format. Called from each subclass's initialize.
@protected
Future<void> observeCoreProperties({required String trackListFormat}) async {
for (final (name, format) in corePropertyObservations) {
await observeProperty(name, format);
}
await observeProperty('track-list', trackListFormat);
}
@protected
Future<void> observeProperty(String name, String format) async {
final propId = _nextPropId++;
_propIdToName[propId] = name;
await invoke('observeProperty', {'name': name, 'format': format, 'id': propId});
}
void _handleEvent(dynamic event) {
if (_disposed) return;
if (event is List && event.length == 2) {
final propertyId = event.first;
if (propertyId is! int) return;
final name = _propIdToName[propertyId];
if (name != null) {
handlePropertyChange(name, event[1]);
}
} else if (event is Map) {
final type = event['type'];
final name = event['name'];
if (type == 'event' && name is String) {
final rawData = event['data'];
handlePlayerEvent(name, rawData is Map ? rawData : null);
}
}
}
void handlePropertyChange(String name, dynamic value) {
if (_disposed) return;
switch (name) {
case 'pause':
final playing = value == false;
_state = _state.copyWith(playing: playing);
playingController.add(playing);
break;
case 'eof-reached':
final completed = value == true;
_state = _state.copyWith(completed: completed);
completedController.add(completed);
break;
case 'paused-for-cache':
final buffering = value == true;
_state = _state.copyWith(buffering: buffering);
bufferingController.add(buffering);
break;
case 'time-pos':
final positionMs = _millisecondsFromSeconds(value, round: true);
if (positionMs != null) {
final pos = Duration(milliseconds: positionMs);
_positionMs = positionMs;
// The backend has spoken, so no Dart-side optimistic write is on top
// any more — whatever the value happens to be.
_lastPositionWriter = _backendReportedWriter;
_lastReportedPositionMs = positionMs;
// Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer
// remains current for synchronous position reads on every tick.
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastEmitMs >= 250) {
_lastEmitMs = nowMs;
_state = _state.copyWith(position: pos);
positionController.add(pos);
}
}
break;
case 'duration':
final durationMs = _millisecondsFromSeconds(value);
if (durationMs != null) {
final duration = _timelineDuration ?? Duration(milliseconds: durationMs);
_state = _state.copyWith(duration: duration);
durationController.add(duration);
}
break;
case 'seekable':
if (value is bool) {
setSeekable(value);
}
break;
case 'demuxer-cache-time':
final bufferMs = _millisecondsFromSeconds(value);
if (bufferMs != null) {
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) break;
_lastCacheStateMs = nowMs;
final buffer = Duration(milliseconds: bufferMs);
_state = _state.copyWith(buffer: buffer);
bufferController.add(buffer);
// Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
// ExoPlayer only buffers ahead of the current position, so use position as start.
final ranges = [BufferRange(start: _state.position, end: buffer)];
_state = _state.copyWith(bufferRanges: ranges);
bufferRangesController.add(ranges);
}
break;
case 'demuxer-cache-state':
_handleDemuxerCacheState(value);
break;
case 'volume':
final volume = _finiteDouble(value);
if (volume != null) {
setVolumeState(volume);
}
break;
case 'speed':
final rate = _finiteDouble(value);
if (rate != null) {
_state = _state.copyWith(rate: rate);
rateController.add(rate);
}
break;
case 'track-list':
final trackList = MpvNodeDecoder.decodeList(value);
if (trackList != null) {
if (_primaryMediaLoadStarted && !_primaryMediaReadyEmitted && _hasPrimaryMediaTrack(trackList)) {
_primaryMediaReadyEmitted = true;
primaryMediaReadyController.add(null);
}
final result = parseTrackList(trackList);
_state = _state.copyWith(tracks: result.tracks);
tracksController.add(result.tracks);
// Derive selection from mpv's "selected" field in the track data.
// This is the source of truth and handles cases where aid/sid
// values don't match track IDs (e.g. "auto", "0", "no").
if (result.selectedAudioId != null) {
updateSelectedAudioTrack(result.selectedAudioId);
}
if (result.selectedSubtitleId != null) {
updateSelectedSubtitleTrack(result.selectedSubtitleId);
}
}
break;
case 'aid':
updateSelectedAudioTrack(value);
break;
case 'sid':
updateSelectedSubtitleTrack(value);
break;
case 'secondary-sid':
updateSelectedSecondarySubtitleTrack(value);
break;
case 'audio-device-list':
final deviceList = MpvNodeDecoder.decodeList(value);
if (deviceList != null) {
final devices = <AudioDevice>[];
for (final entry in deviceList) {
if (entry is! Map) continue;
final name = entry['name'];
final description = entry['description'];
if (name is! String) continue;
devices.add(AudioDevice(name: name, description: description is String ? description : ''));
}
_state = _state.copyWith(audioDevices: devices);
audioDevicesController.add(devices);
}
break;
case 'audio-device':
if (value is String && value.isNotEmpty) {
final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value);
_state = _state.copyWith(audioDevice: device);
audioDeviceController.add(device);
}
break;
}
}
/// Parse demuxer-cache-state property to extract seekable ranges and buffer end.
void _handleDemuxerCacheState(dynamic value) {
if (value is String && value.isNotEmpty) {
// Throttle JSON parsing to avoid ANR on low-end devices
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) return;
_lastCacheStateMs = nowMs;
}
final cacheState = MpvNodeDecoder.decodeMap(value);
if (cacheState == null) return;
// Extract cache-end for the single buffer duration (replaces demuxer-cache-time)
final cacheEndMs = _millisecondsFromSeconds(cacheState['cache-end']);
if (cacheEndMs != null) {
final buffer = Duration(milliseconds: cacheEndMs);
_state = _state.copyWith(buffer: buffer);
bufferController.add(buffer);
}
// Extract seekable-ranges array
final seekableRanges = cacheState['seekable-ranges'];
if (seekableRanges is List) {
final ranges = <BufferRange>[];
for (final range in seekableRanges) {
if (range is! Map) continue;
final startMs = _millisecondsFromSeconds(range['start']);
final endMs = _millisecondsFromSeconds(range['end']);
if (startMs != null && endMs != null) {
ranges.add(
BufferRange(
start: Duration(milliseconds: startMs),
end: Duration(milliseconds: endMs),
),
);
}
}
_state = _state.copyWith(bufferRanges: ranges);
bufferRangesController.add(ranges);
}
}
void handlePlayerEvent(String name, Map? data) {
if (_disposed) return;
switch (name) {
case 'start-file':
_primaryMediaLoadStarted = true;
_primaryMediaReadyEmitted = false;
fileStartedController.add(null);
break;
case 'end-file':
_primaryMediaLoadStarted = false;
setSeekable(false);
final rawReason = data?['reason'];
final reason = switch (rawReason) {
0 => 'eof',
2 => 'stop',
3 => 'quit',
4 => 'error',
5 => 'redirect',
final String s => s,
_ => null,
};
if (reason == 'eof') {
_state = _state.copyWith(completed: true);
completedController.add(true);
} else if (reason == 'error') {
fileLoadFailedController.add(null);
final rawMessage = data?['message'];
final rawCause = data?['cause'];
errorController.add(
PlayerError(
rawMessage is String ? rawMessage : 'Playback error',
cause: rawCause is String ? rawCause : null,
),
);
}
break;
case 'file-loaded':
_state = _state.copyWith(completed: false);
completedController.add(false);
fileLoadedController.add(null);
break;
case 'playback-restart':
playbackRestartController.add(null);
break;
case 'log-message':
final rawPrefix = data?['prefix'];
final rawLevel = data?['level'];
final rawText = data?['text'];
final prefix = rawPrefix is String ? rawPrefix : '';
final level = parseLogLevel(rawLevel is String ? rawLevel : 'info');
final text = rawText is String ? rawText : '';
logController.add(PlayerLog(level: level, prefix: prefix, text: text));
break;
}
}
bool _hasPrimaryMediaTrack(List trackList) {
for (final track in trackList) {
if (track is! Map || track['external'] == true) continue;
final type = track['type'];
if (type == 'audio') return true;
// mpv exposes embedded/external cover art as a video track. It is not
// evidence that the primary audio file has finished discovery, so it
// must not start the external-sidecar timeout on its own.
if (type == 'video' && track['albumart'] != true) return true;
}
return false;
}
PlayerLogLevel parseLogLevel(String level) {
return switch (level) {
'fatal' => PlayerLogLevel.fatal,
'error' => PlayerLogLevel.error,
'warn' => PlayerLogLevel.warn,
'info' => PlayerLogLevel.info,
'v' || 'verbose' => PlayerLogLevel.verbose,
'debug' => PlayerLogLevel.debug,
'trace' => PlayerLogLevel.trace,
_ => PlayerLogLevel.info,
};
}
({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) {
final audioTracks = <AudioTrack>[];
final subtitleTracks = <SubtitleTrack>[];
String? selectedAudioId;
String? selectedSubtitleId;
final containerMetadataIndexes = <String, int>{};
for (final track in trackList) {
if (track is! Map) continue;
final rawType = track['type'];
if (rawType is! String) continue;
final type = rawType;
final rawId = track['id'];
final id = rawId is String || rawId is num ? rawId.toString() : '';
final selected = track['selected'] == true;
if (type == 'audio') {
final rawExternalFilename = track['external-filename'];
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
// Container sidecars are opened only to expose their subtitle tracks.
// Do not let their audio streams participate in normal track matching.
if (externalMetadata?.any((metadata) => metadata.isContainer) == true) continue;
if (selected) selectedAudioId = id;
audioTracks.add(
AudioTrack(
id: id,
title: cleanTrackMetadataValue(track['title'] is String ? track['title'] as String : null),
language: cleanTrackMetadataValue(track['lang'] is String ? track['lang'] as String : null),
codec: track['codec'] is String ? track['codec'] as String : null,
channels: _finiteInt(track['demux-channel-count']),
sampleRate: _finiteInt(track['demux-samplerate']),
isDefault: track['default'] == true,
),
);
} else if (type == 'sub') {
if (selected) selectedSubtitleId = id;
final rawCodec = track['codec'];
final codec = rawCodec is String ? rawCodec : null;
final rawTitle = track['title'];
final rawLanguage = track['lang'];
final rawExternalFilename = track['external-filename'];
final externalFilename = rawExternalFilename is String ? rawExternalFilename : null;
final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename];
final isContainer =
track['container'] == true || externalMetadata?.any((metadata) => metadata.isContainer) == true;
SubtitleTrack? matchedMetadata;
if (externalMetadata != null && externalMetadata.isNotEmpty) {
if (isContainer && externalFilename != null) {
final metadataIndex = containerMetadataIndexes[externalFilename] ?? 0;
containerMetadataIndexes[externalFilename] = metadataIndex + 1;
if (metadataIndex < externalMetadata.length && externalMetadata[metadataIndex].isContainer) {
matchedMetadata = externalMetadata[metadataIndex];
}
} else {
matchedMetadata = externalMetadata.first;
}
}
subtitleTracks.add(
SubtitleTrack(
id: id,
// mpv may synthesize a container track title from the signed
// source filename. Source-catalog metadata is both safer and more
// accurate there, including on builds that drop disposition flags.
// Ordinary sidecars still fall back to metadata reported by mpv.
title: isContainer
? matchedMetadata?.title
: matchedMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec),
language: isContainer
? matchedMetadata?.language
: matchedMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null),
codec: matchedMetadata?.codec ?? codec,
isDefault: matchedMetadata?.isDefault ?? (track['default'] == true),
isForced: matchedMetadata?.isForced ?? (track['forced'] == true),
isExternal: track['external'] == true,
isContainer: isContainer,
uri: externalFilename,
),
);
}
}
return (
tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks),
selectedAudioId: selectedAudioId,
selectedSubtitleId: selectedSubtitleId,
);
}
void updateSelectedAudioTrack(dynamic trackId) {
final id = trackId?.toString();
final selectedTrack = (id == null || id == 'no')
? null
: _state.tracks.audio.firstWhereOrNull((track) => track.id == id);
if (id != null && id != 'no' && selectedTrack == null) return;
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
trackController.add(_state.track);
}
void updateSelectedSubtitleTrack(dynamic trackId) {
final id = trackId?.toString();
final selectedTrack = (id == null || id == 'no')
? SubtitleTrack.off
: _state.tracks.subtitle.firstWhereOrNull((track) => track.id == id);
if (selectedTrack == null) return;
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
trackController.add(_state.track);
}
void updateSelectedSecondarySubtitleTrack(dynamic trackId) {
final id = trackId?.toString();
SubtitleTrack? selectedTrack;
if (id == null || id == 'no') {
selectedTrack = null;
} else {
selectedTrack = _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);
}
_state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack));
trackController.add(_state.track);
}
@protected
void clearTracks() {
const empty = Tracks();
_state = _state.copyWith(tracks: empty, track: const TrackSelection());
tracksController.add(empty);
}
@protected
void setExternalSubtitleMetadata(List<SubtitleTrack>? externalSubtitles) {
final metadataByUri = <String, List<SubtitleTrack>>{};
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
final uri = subtitle.uri;
if (uri != null && uri.isNotEmpty) {
(metadataByUri[uri] ??= <SubtitleTrack>[]).add(subtitle);
}
}
_externalSubtitleMetadataByUri = metadataByUri;
}
@protected
Map<String, List<SubtitleTrack>> snapshotExternalSubtitleMetadata() =>
Map<String, List<SubtitleTrack>>.of(_externalSubtitleMetadataByUri);
@protected
void restoreExternalSubtitleMetadata(Map<String, List<SubtitleTrack>> snapshot) {
_externalSubtitleMetadataByUri = Map<String, List<SubtitleTrack>>.of(snapshot);
}
@protected
void setVolumeState(double volume) {
if (_state.volume == volume) return;
_state = _state.copyWith(volume: volume);
volumeController.add(volume);
}
@protected
void setSeekable(bool seekable) {
if (_state.seekable == seekable) return;
_state = _state.copyWith(seekable: seekable);
seekableController.add(seekable);
}
@protected
void configureTimeline({Duration? duration}) {
_timelineDuration = duration;
}
/// Report that something is moving the playhead discontinuously, to [target]
/// — or somewhere only the backend knows, when [target] is null. Announced
/// when the move is requested, so it is intent rather than an observed
/// landing; see `PlayerStreams.playheadJump`.
///
/// Guards disposal: several callers announce after an await, by which point
/// the controllers may already be closed.
@protected
void announcePlayheadJump(Duration? target) {
if (_disposed) return;
playheadJumpController.add(target);
}
/// Publish a playhead position the backend chose for itself, after a command
/// that relocates it without going through [runSeek].
///
/// Writes state as well as announcing: `PlayerState.position` is what
/// consumers rebase relative seeks from, and its tick updates are throttled,
/// so announcing alone would leave them working off the pre-command position.
///
/// Arbitration is by acceptance, not by request: a seek that has only been
/// asked for does not invalidate a relocation the backend already took, and a
/// destination arriving while a newer seek is still unresolved is held rather
/// than published — publishing would read as a foreign jump to whoever is
/// coalescing that seek, and discarding would lose the cue if it is rejected.
@protected
void publishPlayheadRelocation(Duration position, {int? token}) {
if (_disposed) return;
// Something newer has claimed or moved the playhead since this relocation
// started, so its answer is stale.
if (token != null && token < _acceptedOperation) return;
// A newer seek is still in flight, so it — not this answer about the past —
// will define where the playhead ends up if it lands. Publishing now would
// read as a foreign jump to whoever is coalescing that seek and cost them
// the burst; discarding would lose the cue if that seek is then rejected.
// Hold it until the group resolves.
if (token != null && _hasUnresolvedSeekNewerThan(token)) {
_deferredRelocation = (token: token, position: position);
return;
}
final writer = token ?? ++_playheadOperations;
_takeOperationOwnership(writer);
_lastPositionWriter = writer;
_activeSeekGroup = null;
_setPlaybackPosition(position);
announcePlayheadJump(position);
}
/// Where the playhead is, given [target] was accepted [since] ago.
///
/// A reported position at or shortly past the target is playback running on
/// from it and is fresher than the target itself; anything else is a stale
/// observation from before the seek. "Shortly" is the media time the elapsed
/// wall clock could actually cover at the current rate — a fixed window would
/// rewind real progress at 8x and preserve stale ticks in slow motion — and a
/// paused player covers none at all.
Duration _progressedFrom(Duration target, Stopwatch? since) {
// Only the backend observes playback. `_positionMs` also carries Dart's own
// optimistic writes, and a rejected request's target sitting a few
// milliseconds past an accepted one is not progress — reading it as such
// would keep the position the backend refused.
if (_lastPositionWriter != _backendReportedWriter) return target;
final observed = Duration(milliseconds: _positionMs);
final drift = observed - target;
if (drift.isNegative) return target;
final rate = _state.rate.isFinite && _state.rate > 0 ? _state.rate : 1.0;
final elapsed = since?.elapsedMicroseconds ?? 0;
final covered = _state.playing
? Duration(microseconds: ((elapsed + _landedProgressSlack.inMicroseconds) * rate).round())
: Duration.zero;
return drift <= covered ? observed : target;
}
/// Mark [operation] as the newest thing the backend accepted, retiring any
/// relocation that was waiting to see whether it would land.
void _takeOperationOwnership(int operation) {
_acceptedOperation = operation;
if ((_deferredRelocation?.token ?? operation) < operation) _deferredRelocation = null;
}
/// Settle a held relocation now that the group arbitrating it has drained.
///
/// It wins when it is newer than anything that group landed; otherwise the
/// group's own outcome stands and the cue is history either way.
bool _resolveDeferredRelocation(_SeekGroup group) {
final deferred = _deferredRelocation;
_deferredRelocation = null;
if (deferred == null || _disposed || deferred.token <= group.landedRequest) return false;
_takeOperationOwnership(deferred.token);
// Winning without a destination still means the group must not undo itself
// across this relocation; there is simply nothing new to publish, and the
// backend's next tick supplies the position.
final position = deferred.position;
if (position != null) {
_setPlaybackPosition(position);
announcePlayheadJump(position);
return true;
}
// Destination unknown, and suppressing the group's rollback would leave a
// rejected request's optimistic target on top of state — certainly not
// where an accepted relocation put the playhead.
_repairRejectedWrite(group);
// The null this relocation sent before dispatch predates the seek it was
// waiting on, so that seek's own echo has since re-armed any coalescing
// consumer. Say again that the playhead is somewhere they did not put it.
announcePlayheadJump(null);
return true;
}
/// Record where the playing source got to, before its successor can report
/// anything.
///
/// [takeSourceOwnership] runs from the backend's event flow, which is not
/// ordered against the property flow carrying position reports, so by then an
/// incoming report may already have replaced the outgoing one. Callers that
/// observe the boundary *on the property flow* can close that window by
/// calling this; whoever finalises the outgoing item then gets its real last
/// position instead of its successor's first.
@protected
void freezeOutgoingSourcePosition() {
if (_disposed) return;
_frozenOutgoingPositionMs = _lastReportedPositionMs;
}
/// The handover this froze a position for is not going to happen. Drop it, or
/// a later advance whose boundary edge is dropped would prefer this snapshot
/// over the position actually reported since.
@protected
void discardFrozenOutgoingPosition() => _frozenOutgoingPositionMs = null;
/// The backend rolled into a different source on its own — a gapless
/// advance. Nothing that was in flight against the old one may speak for the
/// playhead any more.
@protected
void takeSourceOwnership() {
if (_disposed) return;
// Recorded before anything below overwrites it: whoever finalises the
// outgoing item needs where it got to, and every position reachable from
// here on belongs to the new source.
_outgoingSourcePosition = Duration(milliseconds: _frozenOutgoingPositionMs ?? _lastReportedPositionMs);
_frozenOutgoingPositionMs = null;
_lastReportedPositionMs = 0;
_takeOperationOwnership(++_playheadOperations);
_activeSeekGroup = null;
// A gapless advance starts the new source at its beginning, which is known
// rather than guessed. Published unconditionally: the alternative is to
// preserve whatever position was last reported, and at this boundary that
// is far more likely to be the outgoing track's last tick than an early
// one from the incoming track. If an early new-source tick really did
// arrive, its successor corrects this within one tick.
_lastPositionWriter = _playheadOperations;
_setPlaybackPosition(Duration.zero);
announcePlayheadJump(Duration.zero);
}
/// Lift a rejected request's optimistic target off state, if it is still the
/// thing on top.
///
/// What replaces it is the closest position actually known: what the backend
/// last reported, else the newest target this group had accepted, else where
/// it started. Nothing is announced — a rejected request's abandonment is
/// announced by whoever rejected it.
void _repairRejectedWrite(_SeekGroup group) {
if (_disposed) return;
// A later group is running: its optimistic write owns state, and even a
// backend report arriving now belongs inside its window, not this one's.
if (_activeSeekGroup != null && !identical(_activeSeekGroup, group)) return;
if (_lastPositionWriter == _backendReportedWriter) {
// The backend has reported since, so it is authoritative whatever the
// value — this must be checked before anything that infers ownership from
// the value itself. `PlayerState.position` is throttled and can still be
// showing an abandoned target, so bring it into line.
final ticked = Duration(milliseconds: _positionMs);
if (_state.position != ticked) _setPlaybackPosition(ticked);
return;
}
if (group.landedRequest == group.newestRequest) return;
// Someone else wrote since; their value stands even if it happens to match
// this group's target.
if (_lastPositionWriter != group.newestRequest) return;
_setPlaybackPosition(group.landedTarget ?? group.anchor);
}
bool _hasUnresolvedSeekNewerThan(int token) {
// Asked of the requests still outstanding, not of the group's newest: that
// one may already have settled while an older sibling holds the group open.
final group = _activeSeekGroup;
return group != null && group.unsettled.any((request) => request > token);
}
@protected
Duration? get configuredTimelineDuration => _timelineDuration;
/// Install a freshly opened source at [sourcePosition].
///
/// An in-place reload — dead-stream recovery, a quality/version switch, a
/// background-suspend resume — places the playhead here rather than through
/// [runSeek], so this is the second way it can move discontinuously.
@protected
void resetPlaybackProgress(Duration sourcePosition) {
final position = sourcePosition;
_positionMs = position.inMilliseconds;
// A source is being installed at this position; nothing has been reported
// about it yet, and its predecessor's position says nothing about it.
_lastReportedPositionMs = position.inMilliseconds;
_state = _state.copyWith(
completed: false,
position: position,
duration: _timelineDuration ?? Duration.zero,
buffer: Duration.zero,
bufferRanges: const [],
);
_takeOperationOwnership(++_playheadOperations);
_lastPositionWriter = _playheadOperations;
_activeSeekGroup = null;
completedController.add(false);
positionController.add(position);
announcePlayheadJump(position);
durationController.add(_timelineDuration ?? Duration.zero);
bufferController.add(Duration.zero);
bufferRangesController.add(const []);
}
@protected
void restoreTracks(PlayerState snapshot) {
_state = _state.copyWith(tracks: snapshot.tracks, track: snapshot.track);
tracksController.add(snapshot.tracks);
trackController.add(snapshot.track);
}
/// Put back the state a failed open tore down.
///
/// [resetPlaybackProgress] already announced the start position the open was
/// aiming for, so undoing it has to be announced too — otherwise a consumer
/// that pinned the abandoned resume target keeps building on it.
@protected
void restorePlaybackProgress(PlayerState snapshot, {Duration? position}) {
final restoredPosition = position ?? snapshot.position;
_positionMs = restoredPosition.inMilliseconds;
_state = _state.copyWith(
completed: snapshot.completed,
position: restoredPosition,
duration: snapshot.duration,
buffer: snapshot.buffer,
bufferRanges: snapshot.bufferRanges,
);
_takeOperationOwnership(++_playheadOperations);
_lastPositionWriter = _playheadOperations;
_activeSeekGroup = null;
completedController.add(snapshot.completed);
positionController.add(restoredPosition);
announcePlayheadJump(restoredPosition);
durationController.add(snapshot.duration);
bufferController.add(snapshot.buffer);
bufferRangesController.add(snapshot.bufferRanges);
}
@protected
Future<T?> invoke<T>(String method, [dynamic args]) async {
if (_disposed) return null;
if (_nativeOwnershipReady case final ready?) {
try {
await ready.timeout(debugNativeOwnershipDisposeTimeout);
} on TimeoutException {
return null;
}
}
if (_disposed) return null;
return methodChannel.invokeMethod<T>(method, args);
}
@override
Future<void> playOrPause() async {
if (_disposed) return;
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
if (_disposed) return false;
try {
await invoke('setVisible', {'visible': visible, 'restoreOnWindowVisible': restoreOnWindowVisible});
return true;
} catch (e) {
errorController.add(PlayerError('Failed to set visibility: $e'));
return false;
}
}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> updateFrame() async {}
@override
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
}) async => false;
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> clearVideoFrameRate() async {}
@override
// ignore: no-empty-block - base no-op, ExoPlayer styles subtitles natively
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
}) async {}
@override
// ignore: no-empty-block - base no-op, mpv scales via panscan/aspect-override
Future<void> setBoxFitMode(int mode) async {}
@override
// ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property
Future<void> setVideoZoom(double scale) async {}
@override
Future<Map<String, dynamic>> getStats() async => const {};
@override
Future<String> runtimePlayerType() async => playerType;
@override
Future<bool> requestAudioFocus() async {
// Default returns true, overridden by Android
return true;
}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> abandonAudioFocus() async {}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setAudioDevice(AudioDevice device) async {}
@override
bool get supportsSecondarySubtitles => true;
@override
bool get attachesExternalSubtitlesAtOpen => false;
@override
bool get detectsFpsAfterRender => false;
@override
bool get needsDecoderRefreshAfterDisplaySwitch => false;
@override
bool get providesNativeStats => false;
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setAudioPassthrough(bool enabled) async {}
@override
Future<AudioRenderingMode?> getAudioRenderingMode() async => null;
/// mpv loudnorm targeting streaming-style loudness; mirrored by the
/// Android ExoPlayer effect parameters in AudioNormalizationEffect.kt.
static const _loudnormFilter = 'loudnorm=I=-14:TP=-3:LRA=4';
@override
Future<void> setAudioNormalization(bool enabled) async {
await setProperty('af', enabled ? _loudnormFilter : '');
}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {
if (enabled) {
// Kodi's mechanism: center coefficient = 10^((-3 + boost)/20); the
// surround (-3 dB) and LFE (dropped) swresample defaults already match.
final c = math.pow(10, (-3 + centerBoostDb.clamp(0, 12)) / 20).toStringAsFixed(4);
// Swresample AVOptions are read once at audio-filter creation, so they
// must land before audio-channels triggers the chain (re)build.
await setProperty('audio-swresample-o', 'center_mix_level=$c');
await setProperty('audio-normalize-downmix', normalize ? 'yes' : 'no');
// Bounce through auto-safe so boost/normalize changes re-apply while
// downmix is already active (same-value option sets are no-ops in mpv).
await setProperty('audio-channels', 'auto-safe');
await setProperty('audio-channels', 'stereo');
} else {
await setProperty('audio-channels', 'auto-safe');
await setProperty('audio-swresample-o', '');
await setProperty('audio-normalize-downmix', 'no');
}
}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setLogLevel(String level) async {}
@override
Future<void> configureSubtitleFonts() async {
try {
final fontDir = await SubtitleFontLoader.loadSubtitleFont();
if (fontDir != null) {
await setProperty('sub-fonts-dir', fontDir);
await setProperty('sub-font', SubtitleFontLoader.fontName);
}
} catch (e) {
// Font configuration is not critical - continue without it
logController.add(
PlayerLog(prefix: 'fonts', level: PlayerLogLevel.warn, text: 'Failed to configure subtitle fonts: $e'),
);
}
}
void _setPlaybackPosition(Duration position) {
_positionMs = position.inMilliseconds;
_state = _state.copyWith(position: position);
positionController.add(position);
}
/// Run a backend-specific seek call, swallowing the common "not ready" errors
/// the native channel throws when the engine was torn down mid-seek.
///
/// Seeks can overlap, and each one optimistically writes its own target, so
/// no single call knows where the playhead really ended up. The last of an
/// overlapping group to settle owns the correction: if any of them landed the
/// backend is at that target, and if none did, nothing moved and the position
/// from before the group is the truth.
@protected
Future<void> runSeek(Duration position, Future<void> Function() seekFn) async {
if (_disposed) return;
final request = ++_playheadOperations;
// Measures how much media time playback could legitimately have covered
// while the command was in flight; a fixed media-time window cannot tell a
// fast-rate advance from a stale pre-seek tick.
final elapsedInFlight = Stopwatch()..start();
final group = _activeSeekGroup ??= _SeekGroup(Duration(milliseconds: _positionMs));
group.unsettled.add(request);
group.newestRequest = request;
_lastPositionWriter = request;
_setPlaybackPosition(position);
// Announce the request, not its completion: consumers coalescing their own
// seeks need to know the playhead moved out from under them while the
// backend is still working, which is exactly the window a late signal would
// miss.
announcePlayheadJump(position);
void settle({required bool landed}) {
// A newer request in this group has already displaced the playhead
// optimistically, so nothing here can read the backend's position: record
// the exact target and let the group reconcile once that newer request
// resolves.
if (landed && request != group.newestRequest) {
if (request > group.landedRequest) {
group.landedRequest = request;
group.landedTarget = position;
group.landedSince = elapsedInFlight;
}
if (request > _acceptedOperation) _takeOperationOwnership(request);
} else if (landed) {
final authoritative = _progressedFrom(position, elapsedInFlight);
if (request > group.landedRequest) {
// The backend applies commands in issue order, so the newest request
// it accepted is where it ends up — whichever reply came back first.
group.landedRequest = request;
group.landedTarget = authoritative;
group.landedSince = elapsedInFlight;
}
if (request > _acceptedOperation) {
// Newest thing the backend has accepted, so the playhead is here.
// Applied now rather than at group drain: the group may be detached
// by a relocation or still waiting on an older member, and neither
// changes the fact that nothing newer has been accepted.
_takeOperationOwnership(request);
if (_positionMs != authoritative.inMilliseconds || _state.position != authoritative) {
_setPlaybackPosition(authoritative);
}
}
}
group.unsettled.remove(request);
if (group.unsettled.isNotEmpty) return;
if (!identical(_activeSeekGroup, group)) {
// Detached: something outside the group took the playhead after it
// started — a relocation, or a different source starting. A deferred
// relocation is deliberately left alone here — it is held against
// whichever group is active now, which may well be a later one than
// this, and resolving it from here would publish into that group's
// window or restore an anchor from a timeline it never saw. Its own
// rejected write is still its to clean up, though.
_repairRejectedWrite(group);
return;
}
_activeSeekGroup = null;
if (_disposed) return;
if (_resolveDeferredRelocation(group)) return;
final anchor = group.anchor;
final landedTarget = group.landedTarget;
final newestRequestFailed = group.landedRequest != group.newestRequest;
if (landedTarget != null) {
// Something landed and already applied itself. Only a rejected newest
// request needs undoing here: its optimistic write is still on top, and
// the accepted target of a newest request went out as its own request.
if (newestRequestFailed) {
// The rejected newest request's optimistic write is still on top.
// Replace it with the accepted target, or with a tick that has since
// run on from it — rewinding real progress would be its own bug.
final settled = _progressedFrom(landedTarget, group.landedSince);
_setPlaybackPosition(settled);
announcePlayheadJump(settled);
}
return;
}
// Every seek in the group was rejected, so the playhead never went where
// it was announced. Undo it in state and on the stream, or a consumer
// keeps building on a position the backend refused.
if (_lastPositionWriter == _backendReportedWriter) {
// The backend reported a position while the group was in flight, so the
// pre-group position is not what to restore — but `PlayerState.position`
// is throttled and can still be showing an abandoned target, so publish
// the reported value rather than leaving that on display.
final ticked = Duration(milliseconds: _positionMs);
_setPlaybackPosition(ticked);
announcePlayheadJump(ticked);
return;
}
_setPlaybackPosition(anchor);
announcePlayheadJump(anchor);
}
try {
await seekFn();
settle(landed: true);
} on PlatformException catch (e) {
settle(landed: false);
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
appLogger.w('Seek failed (${e.code}), player not ready');
return;
}
rethrow;
} catch (_) {
settle(landed: false);
rethrow;
}
}
/// Injects the log + error events that would fire when the server rejects the
/// stream with [status]. Used by the in-player debug buttons to preview the
/// end-to-end detection path without needing a real misbehaving server: 500
/// is a shared-user bandwidth/transcoding limit, 404 a file the server can no
/// longer read, 503 a server that keeps refusing the stream (the error event
/// stands in for the open-phase watchdog, which cannot arm once playback has
/// a frame). The warn-level log mirrors ffmpeg's real wording, which is what
/// [PlayerError.httpStatusFromLog] parses.
void debugSimulateServerHttpError(int status) {
if (_disposed) return;
logController.add(
PlayerLog(level: PlayerLogLevel.warn, prefix: 'ffmpeg', text: 'https: HTTP error $status Simulated'),
);
final cause = switch (status) {
500 => PlayerError.serverHttp500,
404 => PlayerError.serverHttp404,
503 => PlayerError.serverHttp503,
_ => null,
};
errorController.add(PlayerError('HTTP $status', cause: cause));
}
Future<bool> _waitForNativeOwnershipForDispose() async {
final ready = _nativeOwnershipReady;
if (ready == null) return true;
try {
await ready.timeout(debugNativeOwnershipDisposeTimeout);
return true;
} on TimeoutException catch (error, stackTrace) {
appLogger.w(
'Timed out waiting for the previous player to release the native channel; skipping native dispose',
error: error,
stackTrace: stackTrace,
);
if (!_nativeRelease.isCompleted) _nativeRelease.complete(ready);
return false;
}
}
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
if (_disposed) return;
_disposed = true;
_textureId.value = null;
final channelName = eventChannel.name;
if (identical(_eventChannelOwners[channelName], this)) {
// Keep this owner registered while its native release is pending so a
// player created during disposal inherits the complete release chain.
// The newer listen cannot interleave before cancel() is invoked on this
// isolate; after the first await, ownership is checked again at removal.
try {
await _eventSubscription?.cancel();
} on PlatformException catch (e, st) {
appLogger.d('Player event stream already detached during dispose', error: e, stackTrace: st);
} on MissingPluginException catch (e, st) {
appLogger.d('Player event stream plugin missing during dispose', error: e, stackTrace: st);
}
} else {
// A newer instance owns the channel; cancelling would send a stray
// native 'cancel' that kills *its* stream. Drop ours without cancelling
// — the newer listen already replaced this subscription's routing.
appLogger.d('Player event stream handed off to a newer instance, skipping cancel');
}
_eventSubscription = null;
await _logSubscription?.cancel();
final ownsNativeChannel = await _waitForNativeOwnershipForDispose();
try {
if (ownsNativeChannel) {
await methodChannel.invokeMethod('dispose', {
'preserveDisplayMode': preserveDisplayMode,
}); // Direct call — invoke() is disabled once _disposed is set.
}
} on PlatformException catch (e, st) {
appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st);
} on MissingPluginException catch (e, st) {
appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st);
} finally {
if (ownsNativeChannel && !_nativeRelease.isCompleted) _nativeRelease.complete();
}
// A timed-out predecessor is still represented by this release future.
// Do not expose an empty ownership slot until that chained release settles.
if (_nativeRelease.isCompleted) {
unawaited(
_nativeRelease.future.whenComplete(() {
if (identical(_eventChannelOwners[channelName], this)) {
_eventChannelOwners.remove(channelName);
}
}),
);
}
await closeStreamControllers();
_textureId.dispose();
}
}
/// One run of overlapping [PlayerBase.runSeek] calls.
///
/// Each seek writes its own target optimistically, so no single call knows
/// where the backend actually ended up; the last one to settle reconciles the
/// group. [anchor] is where the playhead was before the first of them started.
class _SeekGroup {
_SeekGroup(this.anchor);
final Duration anchor;
/// The source this group was issued against.
final Set<int> unsettled = <int>{};
int newestRequest = 0;
int landedRequest = 0;
Duration? landedTarget;
/// The winning request's own flight clock, still running. A drain uses it to
/// tell playback progressing from [landedTarget] apart from a stale
/// observation; restarting it at settlement would undercount media time the
/// backend covered while a slow command was still being answered.
Stopwatch? landedSince;
}