fix(player): skip relative to the position a jump landed on

A coalesced key-repeat skip pins its target so a slow backend cannot make
the next press rebase off a position the seek has not reached yet. Nothing
retired that pin when something else moved the playhead, so for the ten
seconds it survived, a skip taken after a timeline tap, a chapter jump, an
OS media control or a peer sync resumed from the superseded target and threw
the user back across their own jump.

Publish every playhead movement on the player and retire the pin whenever
the announced destination is not the accumulator's own commit. Overlapping
seeks and backend-chosen relocations arbitrate by which operation the
backend accepted, so a request that was merely asked for cannot speak for
where the playhead ended up.

close #1819
This commit is contained in:
edde746
2026-08-07 08:43:48 +02:00
parent 660e375248
commit 4816e3928f
21 changed files with 2809 additions and 26 deletions
+6
View File
@@ -54,6 +54,12 @@ abstract class Player {
/// ExoPlayer's native tick is itself 250ms, which bounds freshness there.
Duration get currentPosition;
/// Where the source that just handed over was when it did, or null if none
/// has. A gapless advance retargets [state] and [currentPosition] at the new
/// source immediately, so anything finalising the outgoing item — progress
/// reporting, scrobbling — must read its last position from here.
Duration? get outgoingSourcePosition => null;
/// Whether audio passthrough (bitstream output) is currently active.
///
/// [setRate] with a non-1.0 rate tears passthrough down, so callers that
+444 -9
View File
@@ -70,6 +70,103 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
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 = {};
@@ -246,6 +343,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
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;
@@ -669,13 +770,213 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_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,
@@ -683,8 +984,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
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 []);
@@ -697,6 +1002,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
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;
@@ -708,8 +1018,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
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);
@@ -897,33 +1211,131 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
/// 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 previousPosition = Duration(milliseconds: _positionMs);
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 rollbackPosition() {
// Avoid overwriting a newer native position update if one arrived while
// the platform seek was in flight.
if (_positionMs == position.inMilliseconds) {
_setPlaybackPosition(previousPosition);
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') {
rollbackPosition();
appLogger.w('Seek failed (${e.code}), player not ready');
return;
}
rollbackPosition();
rethrow;
} catch (_) {
rollbackPosition();
settle(landed: false);
rethrow;
}
}
@@ -1021,3 +1433,26 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_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;
}
+49 -1
View File
@@ -479,6 +479,10 @@ class PlayerNative extends PlayerBase {
return;
}
// The handover is off: the entry is not playing and is about to be removed.
// Anything frozen for it would otherwise outlive the arm and be preferred
// by a later advance whose own boundary edge went missing.
discardFrozenOutgoingPosition();
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
if (duringDispose) {
@@ -511,6 +515,9 @@ class PlayerNative extends PlayerBase {
/// arm — the fd (if any) was consumed by mpv — remove the spent entry so
/// the playing entry rebases to index 0, and surface the transition.
void _completeArmedAdvance(String? uri) {
// A different source is playing now, so a seek still in flight against the
// old one must not land its target on this one's timeline (#1819).
takeSourceOwnership();
_hasArmedNext = false;
_armedNextUri = null;
_armedNextFd = null;
@@ -530,7 +537,11 @@ class PlayerNative extends PlayerBase {
@override
void handlePropertyChange(String name, dynamic value) {
if (audioOnly && name == 'playlist-pos') {
// Debug aid only — see _handleAudioFileLoaded for the real detection.
// Detection still belongs to _handleAudioFileLoaded, but this is the last
// point ordered ahead of the new source's own position reports: they ride
// this same property flow, while `file-loaded` rides the event flow. Take
// the outgoing track's final position while it is still the current one.
if (_hasArmedNext) freezeOutgoingSourcePosition();
appLogger.d('MPV-audio: playlist-pos=$value (armed=$_hasArmedNext)');
return;
}
@@ -729,11 +740,48 @@ class PlayerNative extends PlayerBase {
return Map<String, dynamic>.from(result ?? const {});
}
/// mpv commands that relocate the playhead while computing their own
/// destination, so Dart never learns where it landed up front (#1819).
static const _playheadRelocatingCommands = {'sub-seek'};
@override
Future<void> command(List<String> args) async {
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
// Re-checked after the await: initialization can fail, or the core can be
// torn down, while this call is suspended. Announcing a jump that no
// command will follow would retire a consumer's pending target for nothing.
if (_nativeCoreUnavailable) return;
if (args.isEmpty || !_playheadRelocatingCommands.contains(args.first)) {
await invoke('command', {'args': args});
return;
}
// Claimed before the announcement so two overlapping subtitle seeks, or a
// seek issued while this one runs, cannot both think they own the playhead.
final token = beginPlayheadRelocation();
// Announced before dispatch for the same reason runSeek announces its
// request: the stale window is the round trip, not what follows it. The
// destination is unknown at this point, hence null.
announcePlayheadJump(null);
await invoke('command', {'args': args});
// The command was accepted, so the playhead has moved even if the read
// below cannot say where. Take ownership now: an in-flight seek group
// settling afterwards must not roll back across a cue that happened.
commitPlayheadRelocation(token);
// Read back where mpv actually went. `PlayerState.position` is what
// relative seeks rebase from and its tick updates are throttled, so
// without this a skip pressed straight after would start from the
// pre-command position.
//
// Nothing is published when the read fails: guessing would hand a
// fabricated position to consumers as authoritative. The null announced
// before dispatch already told consumers to drop what they were holding,
// and the backend's next tick supplies the real position.
final seconds = double.tryParse(await invoke<String>('getProperty', {'name': 'time-pos'}) ?? '');
if (seconds != null && seconds.isFinite && !seconds.isNegative) {
publishPlayheadRelocation(Duration(milliseconds: (seconds * 1000).round()), token: token);
}
}
@override
@@ -8,6 +8,7 @@ mixin PlayerStreamControllersMixin {
final completedController = StreamController<bool>.broadcast();
final bufferingController = StreamController<bool>.broadcast();
final positionController = StreamController<Duration>.broadcast();
final playheadJumpController = StreamController<Duration?>.broadcast();
final durationController = StreamController<Duration>.broadcast();
final seekableController = StreamController<bool>.broadcast();
final bufferController = StreamController<Duration>.broadcast();
@@ -34,6 +35,7 @@ mixin PlayerStreamControllersMixin {
completed: completedController.stream,
buffering: bufferingController.stream,
position: positionController.stream,
playheadJump: playheadJumpController.stream,
duration: durationController.stream,
seekable: seekableController.stream,
buffer: bufferController.stream,
@@ -61,6 +63,7 @@ mixin PlayerStreamControllersMixin {
await completedController.close();
await bufferingController.close();
await positionController.close();
await playheadJumpController.close();
await durationController.close();
await seekableController.close();
await bufferController.close();
+26
View File
@@ -17,6 +17,31 @@ class PlayerStreams {
/// Stream of position updates.
final Stream<Duration> position;
/// Emits whenever something asks the playhead to move discontinuously — every
/// seek, whatever asked for it, and every source opened at a start position
/// by an in-place reload.
///
/// The value is where the playhead is being put, or null when the backend
/// computes its own destination (`sub-seek`) and Dart does not know it yet.
/// A null MAY be followed by a non-null event once the destination has been
/// read back — but not always: an unreadable position publishes nothing
/// rather than guessing, and the backend's next tick supplies it instead.
///
/// These are announced at REQUEST time, not on completion, because the window
/// a consumer has to care about is exactly while the backend is still
/// working. Treat an event as intent plus a possible correction rather than
/// as an observed landing: a seek the backend rejects is usually followed by
/// a second event carrying the position it was actually left at, though a
/// request that never reached the backend at all has nothing to correct.
/// Listeners that issue seeks themselves will also see their own requests
/// here, so they must recognise their own targets rather than assume every
/// event is foreign.
///
/// [position] cannot stand in for this: a seek writes its target there
/// optimistically, and stale backend ticks then report the pre-seek position
/// again until the seek lands, so a listener cannot tell the two apart.
final Stream<Duration?> playheadJump;
/// Stream of duration changes (when media is loaded).
final Stream<Duration> duration;
@@ -101,6 +126,7 @@ class PlayerStreams {
required this.audioDevices,
required this.bufferRanges,
required this.playbackRestart,
this.playheadJump = const Stream<Duration?>.empty(),
this.fileLoaded = const Stream<void>.empty(),
this.fileStarted = const Stream<void>.empty(),
this.fileLoadFailed = const Stream<void>.empty(),