|
|
|
@@ -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;
|
|
|
|
|
}
|
|
|
|
|