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:
@@ -13,22 +13,55 @@ double steppedSeekMultiplier(int repeatCount) {
|
|||||||
/// The pending target remains pinned until playback reaches it (or the settle
|
/// The pending target remains pinned until playback reaches it (or the settle
|
||||||
/// ceiling expires), so a slow seek cannot make the next burst rebase from a
|
/// ceiling expires), so a slow seek cannot make the next burst rebase from a
|
||||||
/// stale player position.
|
/// stale player position.
|
||||||
|
///
|
||||||
|
/// The pin is dropped as soon as something else lays claim to the playhead —
|
||||||
|
/// feed [playheadJumps] every discontinuity the player announces, and any
|
||||||
|
/// target that is not this accumulator's own commit retires it, so the next
|
||||||
|
/// step rebases from where the user actually is rather than from a superseded
|
||||||
|
/// skip (#1819). Those announcements are request-time intent, so the pin is
|
||||||
|
/// retired as soon as another source asks, not once the backend has obeyed.
|
||||||
class DebouncedSeekAccumulator {
|
class DebouncedSeekAccumulator {
|
||||||
DebouncedSeekAccumulator({
|
DebouncedSeekAccumulator({
|
||||||
required this.currentPosition,
|
required this.currentPosition,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.seek,
|
required this.seek,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.onBurstAbandoned,
|
||||||
|
Stream<Duration?>? playheadJumps,
|
||||||
this.debounce = const Duration(milliseconds: 800),
|
this.debounce = const Duration(milliseconds: 800),
|
||||||
this.settlePoll = const Duration(seconds: 2),
|
this.settlePoll = const Duration(seconds: 2),
|
||||||
this.settleTolerance = const Duration(seconds: 3),
|
this.settleTolerance = const Duration(seconds: 3),
|
||||||
this.settleCeiling = const Duration(seconds: 10),
|
this.settleCeiling = const Duration(seconds: 10),
|
||||||
});
|
}) {
|
||||||
|
_jumpSubscription = playheadJumps?.listen(observePlayheadJump);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a reported jump may land from the pinned target and still count as
|
||||||
|
/// this accumulator's own commit coming back around. Wide enough to absorb
|
||||||
|
/// re-clamping on the way to the backend, narrow enough that a real jump
|
||||||
|
/// elsewhere always retires the pin — and a foreign jump landing inside it is
|
||||||
|
/// indistinguishable from the pinned target anyway.
|
||||||
|
static const Duration ownJumpTolerance = Duration(milliseconds: 250);
|
||||||
|
|
||||||
final Duration Function() currentPosition;
|
final Duration Function() currentPosition;
|
||||||
final Duration Function() duration;
|
final Duration Function() duration;
|
||||||
final void Function(Duration target) seek;
|
final void Function(Duration target) seek;
|
||||||
|
|
||||||
|
/// Fires whenever the pending target changes, including when it is retired.
|
||||||
|
/// This is a repaint ping for previews, not a seek notification.
|
||||||
final void Function()? onChanged;
|
final void Function()? onChanged;
|
||||||
|
|
||||||
|
/// Fires whenever a pending target is dropped instead of being played out —
|
||||||
|
/// a foreign playhead jump, a player swap, or the owner cancelling outright.
|
||||||
|
/// Callers use this to drop state that described that burst, such as a
|
||||||
|
/// running skip total.
|
||||||
|
///
|
||||||
|
/// The target may already have been committed: a burst stays pinned after its
|
||||||
|
/// seek is dispatched, and something else superseding it then still leaves
|
||||||
|
/// that description stale. Only the natural settle, where playback reaches
|
||||||
|
/// the target, retires a pin without firing this.
|
||||||
|
final void Function()? onBurstAbandoned;
|
||||||
|
|
||||||
final Duration debounce;
|
final Duration debounce;
|
||||||
final Duration settlePoll;
|
final Duration settlePoll;
|
||||||
final Duration settleTolerance;
|
final Duration settleTolerance;
|
||||||
@@ -36,12 +69,46 @@ class DebouncedSeekAccumulator {
|
|||||||
|
|
||||||
Duration? _pendingPosition;
|
Duration? _pendingPosition;
|
||||||
Duration? _lastFlushedPosition;
|
Duration? _lastFlushedPosition;
|
||||||
|
Duration? _ownSeekTarget;
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
Timer? _settleTimer;
|
Timer? _settleTimer;
|
||||||
|
StreamSubscription<Duration?>? _jumpSubscription;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
Duration? get pendingPosition => _pendingPosition;
|
Duration? get pendingPosition => _pendingPosition;
|
||||||
|
|
||||||
|
/// Retire the pinned target when the playhead lands somewhere this
|
||||||
|
/// accumulator did not put it. A null [target] is a jump whose destination
|
||||||
|
/// the backend kept to itself, which is always foreign by definition.
|
||||||
|
///
|
||||||
|
/// Position alone cannot decide this: a seek writes its target optimistically
|
||||||
|
/// and stale backend ticks then report the pre-seek position again, so an
|
||||||
|
/// unapplied slow seek looks exactly like a jump elsewhere.
|
||||||
|
void observePlayheadJump(Duration? target) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final own = _ownSeekTarget;
|
||||||
|
if (target != null && own != null && (target - own).abs() <= ownJumpTolerance) {
|
||||||
|
// The commit this accumulator just issued, echoed back. Matching on the
|
||||||
|
// target rather than on arrival order keeps the pin alive even if a
|
||||||
|
// backend reports it late.
|
||||||
|
_ownSeekTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pendingPosition == null) return;
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-point at another player's jump stream after the owner swapped players.
|
||||||
|
///
|
||||||
|
/// The pending target described the old player's timeline and its echo will
|
||||||
|
/// never arrive now, so it is retired rather than carried across.
|
||||||
|
void attachPlayheadJumps(Stream<Duration?>? playheadJumps) {
|
||||||
|
if (_disposed) return;
|
||||||
|
unawaited(_jumpSubscription?.cancel());
|
||||||
|
_jumpSubscription = playheadJumps?.listen(observePlayheadJump);
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
void seekBy(Duration delta) {
|
void seekBy(Duration delta) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
final maximum = duration();
|
final maximum = duration();
|
||||||
@@ -69,6 +136,7 @@ class DebouncedSeekAccumulator {
|
|||||||
final target = _pendingPosition;
|
final target = _pendingPosition;
|
||||||
if (target == null || target == _lastFlushedPosition) return;
|
if (target == null || target == _lastFlushedPosition) return;
|
||||||
_lastFlushedPosition = target;
|
_lastFlushedPosition = target;
|
||||||
|
_ownSeekTarget = target;
|
||||||
seek(target);
|
seek(target);
|
||||||
_scheduleClear(target);
|
_scheduleClear(target);
|
||||||
}
|
}
|
||||||
@@ -92,20 +160,25 @@ class DebouncedSeekAccumulator {
|
|||||||
_settleTimer = Timer(settlePoll, poll);
|
_settleTimer = Timer(settlePoll, poll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the pending burst without committing it.
|
||||||
void cancel() {
|
void cancel() {
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_debounceTimer = null;
|
_debounceTimer = null;
|
||||||
_settleTimer?.cancel();
|
_settleTimer?.cancel();
|
||||||
_settleTimer = null;
|
_settleTimer = null;
|
||||||
_lastFlushedPosition = null;
|
_lastFlushedPosition = null;
|
||||||
|
_ownSeekTarget = null;
|
||||||
if (_pendingPosition != null) {
|
if (_pendingPosition != null) {
|
||||||
_pendingPosition = null;
|
_pendingPosition = null;
|
||||||
onChanged?.call();
|
onChanged?.call();
|
||||||
|
onBurstAbandoned?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
unawaited(_jumpSubscription?.cancel());
|
||||||
|
_jumpSubscription = null;
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_settleTimer?.cancel();
|
_settleTimer?.cancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ abstract class Player {
|
|||||||
/// ExoPlayer's native tick is itself 250ms, which bounds freshness there.
|
/// ExoPlayer's native tick is itself 250ms, which bounds freshness there.
|
||||||
Duration get currentPosition;
|
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.
|
/// Whether audio passthrough (bitstream output) is currently active.
|
||||||
///
|
///
|
||||||
/// [setRate] with a non-1.0 rate tears passthrough down, so callers that
|
/// [setRate] with a non-1.0 rate tears passthrough down, so callers that
|
||||||
|
|||||||
@@ -70,6 +70,103 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
int _lastEmitMs = 0;
|
int _lastEmitMs = 0;
|
||||||
int _lastCacheStateMs = 0;
|
int _lastCacheStateMs = 0;
|
||||||
int _positionMs = 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;
|
Duration? _timelineDuration;
|
||||||
int _nextPropId = 0;
|
int _nextPropId = 0;
|
||||||
final Map<int, String> _propIdToName = {};
|
final Map<int, String> _propIdToName = {};
|
||||||
@@ -246,6 +343,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
if (positionMs != null) {
|
if (positionMs != null) {
|
||||||
final pos = Duration(milliseconds: positionMs);
|
final pos = Duration(milliseconds: positionMs);
|
||||||
_positionMs = 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
|
// Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer
|
||||||
// remains current for synchronous position reads on every tick.
|
// remains current for synchronous position reads on every tick.
|
||||||
final nowMs = _throttleSw.elapsedMilliseconds;
|
final nowMs = _throttleSw.elapsedMilliseconds;
|
||||||
@@ -669,13 +770,213 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
_timelineDuration = 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
|
@protected
|
||||||
Duration? get configuredTimelineDuration => _timelineDuration;
|
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
|
@protected
|
||||||
void resetPlaybackProgress(Duration sourcePosition) {
|
void resetPlaybackProgress(Duration sourcePosition) {
|
||||||
final position = sourcePosition;
|
final position = sourcePosition;
|
||||||
_positionMs = position.inMilliseconds;
|
_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(
|
_state = _state.copyWith(
|
||||||
completed: false,
|
completed: false,
|
||||||
position: position,
|
position: position,
|
||||||
@@ -683,8 +984,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
buffer: Duration.zero,
|
buffer: Duration.zero,
|
||||||
bufferRanges: const [],
|
bufferRanges: const [],
|
||||||
);
|
);
|
||||||
|
_takeOperationOwnership(++_playheadOperations);
|
||||||
|
_lastPositionWriter = _playheadOperations;
|
||||||
|
_activeSeekGroup = null;
|
||||||
completedController.add(false);
|
completedController.add(false);
|
||||||
positionController.add(position);
|
positionController.add(position);
|
||||||
|
announcePlayheadJump(position);
|
||||||
durationController.add(_timelineDuration ?? Duration.zero);
|
durationController.add(_timelineDuration ?? Duration.zero);
|
||||||
bufferController.add(Duration.zero);
|
bufferController.add(Duration.zero);
|
||||||
bufferRangesController.add(const []);
|
bufferRangesController.add(const []);
|
||||||
@@ -697,6 +1002,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
trackController.add(snapshot.track);
|
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
|
@protected
|
||||||
void restorePlaybackProgress(PlayerState snapshot, {Duration? position}) {
|
void restorePlaybackProgress(PlayerState snapshot, {Duration? position}) {
|
||||||
final restoredPosition = position ?? snapshot.position;
|
final restoredPosition = position ?? snapshot.position;
|
||||||
@@ -708,8 +1018,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
buffer: snapshot.buffer,
|
buffer: snapshot.buffer,
|
||||||
bufferRanges: snapshot.bufferRanges,
|
bufferRanges: snapshot.bufferRanges,
|
||||||
);
|
);
|
||||||
|
_takeOperationOwnership(++_playheadOperations);
|
||||||
|
_lastPositionWriter = _playheadOperations;
|
||||||
|
_activeSeekGroup = null;
|
||||||
completedController.add(snapshot.completed);
|
completedController.add(snapshot.completed);
|
||||||
positionController.add(restoredPosition);
|
positionController.add(restoredPosition);
|
||||||
|
announcePlayheadJump(restoredPosition);
|
||||||
durationController.add(snapshot.duration);
|
durationController.add(snapshot.duration);
|
||||||
bufferController.add(snapshot.buffer);
|
bufferController.add(snapshot.buffer);
|
||||||
bufferRangesController.add(snapshot.bufferRanges);
|
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
|
/// Run a backend-specific seek call, swallowing the common "not ready" errors
|
||||||
/// the native channel throws when the engine was torn down mid-seek.
|
/// 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
|
@protected
|
||||||
Future<void> runSeek(Duration position, Future<void> Function() seekFn) async {
|
Future<void> runSeek(Duration position, Future<void> Function() seekFn) async {
|
||||||
if (_disposed) return;
|
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);
|
_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() {
|
void settle({required bool landed}) {
|
||||||
// Avoid overwriting a newer native position update if one arrived while
|
// A newer request in this group has already displaced the playhead
|
||||||
// the platform seek was in flight.
|
// optimistically, so nothing here can read the backend's position: record
|
||||||
if (_positionMs == position.inMilliseconds) {
|
// the exact target and let the group reconcile once that newer request
|
||||||
_setPlaybackPosition(previousPosition);
|
// 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 {
|
try {
|
||||||
await seekFn();
|
await seekFn();
|
||||||
|
settle(landed: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
|
settle(landed: false);
|
||||||
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
|
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
|
||||||
rollbackPosition();
|
|
||||||
appLogger.w('Seek failed (${e.code}), player not ready');
|
appLogger.w('Seek failed (${e.code}), player not ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rollbackPosition();
|
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
rollbackPosition();
|
settle(landed: false);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1021,3 +1433,26 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
_textureId.dispose();
|
_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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -479,6 +479,10 @@ class PlayerNative extends PlayerBase {
|
|||||||
return;
|
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)');
|
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
|
||||||
try {
|
try {
|
||||||
if (duringDispose) {
|
if (duringDispose) {
|
||||||
@@ -511,6 +515,9 @@ class PlayerNative extends PlayerBase {
|
|||||||
/// arm — the fd (if any) was consumed by mpv — remove the spent entry so
|
/// 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.
|
/// the playing entry rebases to index 0, and surface the transition.
|
||||||
void _completeArmedAdvance(String? uri) {
|
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;
|
_hasArmedNext = false;
|
||||||
_armedNextUri = null;
|
_armedNextUri = null;
|
||||||
_armedNextFd = null;
|
_armedNextFd = null;
|
||||||
@@ -530,7 +537,11 @@ class PlayerNative extends PlayerBase {
|
|||||||
@override
|
@override
|
||||||
void handlePropertyChange(String name, dynamic value) {
|
void handlePropertyChange(String name, dynamic value) {
|
||||||
if (audioOnly && name == 'playlist-pos') {
|
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)');
|
appLogger.d('MPV-audio: playlist-pos=$value (armed=$_hasArmedNext)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -729,11 +740,48 @@ class PlayerNative extends PlayerBase {
|
|||||||
return Map<String, dynamic>.from(result ?? const {});
|
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
|
@override
|
||||||
Future<void> command(List<String> args) async {
|
Future<void> command(List<String> args) async {
|
||||||
if (_nativeCoreUnavailable) return;
|
if (_nativeCoreUnavailable) return;
|
||||||
await _ensureInitialized();
|
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});
|
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
|
@override
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mixin PlayerStreamControllersMixin {
|
|||||||
final completedController = StreamController<bool>.broadcast();
|
final completedController = StreamController<bool>.broadcast();
|
||||||
final bufferingController = StreamController<bool>.broadcast();
|
final bufferingController = StreamController<bool>.broadcast();
|
||||||
final positionController = StreamController<Duration>.broadcast();
|
final positionController = StreamController<Duration>.broadcast();
|
||||||
|
final playheadJumpController = StreamController<Duration?>.broadcast();
|
||||||
final durationController = StreamController<Duration>.broadcast();
|
final durationController = StreamController<Duration>.broadcast();
|
||||||
final seekableController = StreamController<bool>.broadcast();
|
final seekableController = StreamController<bool>.broadcast();
|
||||||
final bufferController = StreamController<Duration>.broadcast();
|
final bufferController = StreamController<Duration>.broadcast();
|
||||||
@@ -34,6 +35,7 @@ mixin PlayerStreamControllersMixin {
|
|||||||
completed: completedController.stream,
|
completed: completedController.stream,
|
||||||
buffering: bufferingController.stream,
|
buffering: bufferingController.stream,
|
||||||
position: positionController.stream,
|
position: positionController.stream,
|
||||||
|
playheadJump: playheadJumpController.stream,
|
||||||
duration: durationController.stream,
|
duration: durationController.stream,
|
||||||
seekable: seekableController.stream,
|
seekable: seekableController.stream,
|
||||||
buffer: bufferController.stream,
|
buffer: bufferController.stream,
|
||||||
@@ -61,6 +63,7 @@ mixin PlayerStreamControllersMixin {
|
|||||||
await completedController.close();
|
await completedController.close();
|
||||||
await bufferingController.close();
|
await bufferingController.close();
|
||||||
await positionController.close();
|
await positionController.close();
|
||||||
|
await playheadJumpController.close();
|
||||||
await durationController.close();
|
await durationController.close();
|
||||||
await seekableController.close();
|
await seekableController.close();
|
||||||
await bufferController.close();
|
await bufferController.close();
|
||||||
|
|||||||
@@ -17,6 +17,31 @@ class PlayerStreams {
|
|||||||
/// Stream of position updates.
|
/// Stream of position updates.
|
||||||
final Stream<Duration> position;
|
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).
|
/// Stream of duration changes (when media is loaded).
|
||||||
final Stream<Duration> duration;
|
final Stream<Duration> duration;
|
||||||
|
|
||||||
@@ -101,6 +126,7 @@ class PlayerStreams {
|
|||||||
required this.audioDevices,
|
required this.audioDevices,
|
||||||
required this.bufferRanges,
|
required this.bufferRanges,
|
||||||
required this.playbackRestart,
|
required this.playbackRestart,
|
||||||
|
this.playheadJump = const Stream<Duration?>.empty(),
|
||||||
this.fileLoaded = const Stream<void>.empty(),
|
this.fileLoaded = const Stream<void>.empty(),
|
||||||
this.fileStarted = const Stream<void>.empty(),
|
this.fileStarted = const Stream<void>.empty(),
|
||||||
this.fileLoadFailed = const Stream<void>.empty(),
|
this.fileLoadFailed = const Stream<void>.empty(),
|
||||||
|
|||||||
@@ -1036,6 +1036,10 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
|
|||||||
unawaited(service.seek(target));
|
unawaited(service.seek(target));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// The scrub bar cancels the pin itself, but OS media controls, a headset
|
||||||
|
// and the lock screen all seek straight through the service, and those
|
||||||
|
// have to retire it too (#1819).
|
||||||
|
playheadJumps: context.read<MusicPlaybackService>().playheadJumpStream,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ abstract class MusicPlaybackService extends ChangeNotifier {
|
|||||||
Duration get position;
|
Duration get position;
|
||||||
Stream<Duration> get positionStream;
|
Stream<Duration> get positionStream;
|
||||||
|
|
||||||
|
/// Mirrors `Player.streams.playheadJump`: something is moving the playhead
|
||||||
|
/// discontinuously, to this position, or to somewhere only the backend knows
|
||||||
|
/// when null. Request-time intent, not an observed landing.
|
||||||
|
/// Consumers coalescing their own relative seeks use it to drop a pending
|
||||||
|
/// target something else superseded (#1819).
|
||||||
|
Stream<Duration?> get playheadJumpStream => const Stream<Duration?>.empty();
|
||||||
|
|
||||||
/// Full queue in playback order (shuffle already applied).
|
/// Full queue in playback order (shuffle already applied).
|
||||||
List<MediaItem> get queue;
|
List<MediaItem> get queue;
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
bool _sleepTimerEndOfTrack = false;
|
bool _sleepTimerEndOfTrack = false;
|
||||||
|
|
||||||
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
|
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
|
||||||
|
final StreamController<Duration?> _playheadJumpController = StreamController<Duration?>.broadcast();
|
||||||
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
|
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -229,6 +230,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
@override
|
@override
|
||||||
Stream<Duration> get positionStream => _positionController.stream;
|
Stream<Duration> get positionStream => _positionController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Duration?> get playheadJumpStream => _playheadJumpController.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<MediaItem> get queue => _queue.queue;
|
List<MediaItem> get queue => _queue.queue;
|
||||||
|
|
||||||
@@ -618,6 +622,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
_playerSubs
|
_playerSubs
|
||||||
..clear()
|
..clear()
|
||||||
..add(player.streams.position.listen(_onPosition))
|
..add(player.streams.position.listen(_onPosition))
|
||||||
|
..add(player.streams.playheadJump.listen(_playheadJumpController.add))
|
||||||
..add(player.streams.playing.listen(_onPlayingChanged))
|
..add(player.streams.playing.listen(_onPlayingChanged))
|
||||||
..add(player.streams.trackTransition.listen(_onTrackTransition))
|
..add(player.streams.trackTransition.listen(_onTrackTransition))
|
||||||
..add(player.streams.completed.listen(_onCompleted))
|
..add(player.streams.completed.listen(_onCompleted))
|
||||||
@@ -686,8 +691,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
_invalidateArmRequests();
|
_invalidateArmRequests();
|
||||||
|
|
||||||
// The finished track played out fully — report stopped at its duration.
|
// The finished track played out fully — report stopped at its duration.
|
||||||
|
// Without one, the player's own record of where the outgoing source got to:
|
||||||
|
// by now its live position belongs to the track that replaced it.
|
||||||
final finishedMs = _currentTrack?.durationMs;
|
final finishedMs = _currentTrack?.durationMs;
|
||||||
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
|
_finalizeCurrentTrack(
|
||||||
|
positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : _player?.outgoingSourcePosition,
|
||||||
|
);
|
||||||
|
|
||||||
// Move the cursor to the armed entry: the expected natural-next when it
|
// Move the cursor to the armed entry: the expected natural-next when it
|
||||||
// still matches, otherwise wherever the armed track now sits.
|
// still matches, otherwise wherever the armed track now sits.
|
||||||
@@ -1571,6 +1580,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
// Runs to completion synchronously — see the awaitStop: false contract.
|
// Runs to completion synchronously — see the awaitStop: false contract.
|
||||||
unawaited(_teardownPlayerAndControls(awaitStop: false));
|
unawaited(_teardownPlayerAndControls(awaitStop: false));
|
||||||
unawaited(_positionController.close());
|
unawaited(_positionController.close());
|
||||||
|
unawaited(_playheadJumpController.close());
|
||||||
unawaited(_errorsController.close());
|
unawaited(_errorsController.close());
|
||||||
_volumeNotifier.dispose();
|
_volumeNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -247,15 +247,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
currentPosition: () => widget.player.state.position,
|
currentPosition: () => widget.player.state.position,
|
||||||
duration: () => widget.player.state.duration,
|
duration: () => widget.player.state.duration,
|
||||||
seek: widget.onSeekEnd,
|
seek: widget.onSeekEnd,
|
||||||
|
playheadJumps: widget.player.streams.playheadJump,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a coalesced timeline burst that will never be committed, because what
|
||||||
|
/// it was seeking through is being replaced.
|
||||||
|
void abandonPendingSeek() => _timelineSeek.cancel();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(DesktopVideoControls oldWidget) {
|
void didUpdateWidget(DesktopVideoControls oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.player != widget.player) {
|
||||||
|
_timelineSeek.attachPlayheadJumps(widget.player.streams.playheadJump);
|
||||||
|
}
|
||||||
if (oldWidget.chromeController != widget.chromeController) {
|
if (oldWidget.chromeController != widget.chromeController) {
|
||||||
oldWidget.chromeController?.removeListener(_onChromeControllerChanged);
|
oldWidget.chromeController?.removeListener(_onChromeControllerChanged);
|
||||||
widget.chromeController?.addListener(_onChromeControllerChanged);
|
widget.chromeController?.addListener(_onChromeControllerChanged);
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
onPlayPause: () => unawaited(_playOrPause()),
|
onPlayPause: () => unawaited(_playOrPause()),
|
||||||
onToggleShader: _toggleShader,
|
onToggleShader: _toggleShader,
|
||||||
onSkipMarker: onSkipMarker,
|
onSkipMarker: onSkipMarker,
|
||||||
onNextEpisode: widget.onNext,
|
onNextEpisode: _abandoningBurst(widget.onNext),
|
||||||
onPreviousEpisode: widget.onPrevious,
|
onPreviousEpisode: _abandoningBurst(widget.onPrevious),
|
||||||
onScreenshot: _showScreenshotToast,
|
onScreenshot: _showScreenshotToast,
|
||||||
onZoomIn: widget.onZoomIn,
|
onZoomIn: widget.onZoomIn,
|
||||||
onZoomOut: widget.onZoomOut,
|
onZoomOut: widget.onZoomOut,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState {
|
|||||||
|
|
||||||
if (marker.isCredits && isAtEnd) {
|
if (marker.isCredits && isAtEnd) {
|
||||||
if (!skipAutoPlayCountdown && widget.onNext != null) {
|
if (!skipAutoPlayCountdown && widget.onNext != null) {
|
||||||
widget.onNext!.call();
|
_abandoningBurst(widget.onNext)!.call();
|
||||||
} else {
|
} else {
|
||||||
// Seeking to EOF is unreliable due to position stream throttling,
|
// Seeking to EOF is unreliable due to position stream throttling,
|
||||||
// so pause and defer to the parent's completion flow.
|
// so pause and defer to the parent's completion flow.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
|||||||
player: widget.player,
|
player: widget.player,
|
||||||
volumeController: widget.volumeController,
|
volumeController: widget.volumeController,
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
onNext: widget.onNext,
|
onNext: _abandoningBurst(widget.onNext),
|
||||||
onPrevious: widget.onPrevious,
|
onPrevious: _abandoningBurst(widget.onPrevious),
|
||||||
onPlayPause: () => unawaited(_playOrPause()),
|
onPlayPause: () => unawaited(_playOrPause()),
|
||||||
chapters: _chapters,
|
chapters: _chapters,
|
||||||
chaptersLoaded: _chaptersLoaded,
|
chaptersLoaded: _chaptersLoaded,
|
||||||
@@ -46,9 +46,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
|||||||
isAtLiveEdge: widget.isAtLiveEdge,
|
isAtLiveEdge: widget.isAtLiveEdge,
|
||||||
streamStartEpoch: widget.streamStartEpoch,
|
streamStartEpoch: widget.streamStartEpoch,
|
||||||
currentPositionEpoch: widget.currentPositionEpoch,
|
currentPositionEpoch: widget.currentPositionEpoch,
|
||||||
onLiveSeek: widget.onLiveSeek,
|
onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek),
|
||||||
onLiveSeekBy: widget.onLiveSeekBy,
|
onLiveSeekBy: widget.onLiveSeekBy,
|
||||||
onJumpToLive: widget.onJumpToLive,
|
onJumpToLive: _abandoningBurst(widget.onJumpToLive),
|
||||||
useDpadNavigation: useDpad,
|
useDpadNavigation: useDpad,
|
||||||
serverId: widget.metadata.serverId,
|
serverId: widget.metadata.serverId,
|
||||||
showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems,
|
showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems,
|
||||||
@@ -65,6 +65,11 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onQueueItemSelected(MediaItem item) {
|
void _onQueueItemSelected(MediaItem item) {
|
||||||
|
// Same contract as next/previous: the switch is asynchronous, so a burst
|
||||||
|
// still armed here would debounce into a seek on the outgoing item.
|
||||||
|
_hiddenSeek.cancel();
|
||||||
|
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||||
|
_dismissSkipFeedback();
|
||||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||||
videoPlayerState?.navigateToQueueItem(item);
|
videoPlayerState?.navigateToQueueItem(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -622,10 +622,51 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|||||||
_showSkipFeedback(isForward: isForward);
|
_showSkipFeedback(isForward: isForward);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrap an absolute live action so it takes down the badge a pending live
|
||||||
|
/// skip raised.
|
||||||
|
///
|
||||||
|
/// Live relative skips bypass [_hiddenSeek] for the parent's epoch
|
||||||
|
/// accumulator (#1253), so the jump the reopen announces finds no pending
|
||||||
|
/// target here and nothing retires the readout. The absolute action cancels
|
||||||
|
/// that queued skip, so its promised total is no longer going anywhere.
|
||||||
|
ValueChanged<int>? _liveSeekAbandoningBurst(ValueChanged<int>? onLiveSeek) {
|
||||||
|
if (onLiveSeek == null) return null;
|
||||||
|
return (offset) {
|
||||||
|
_dismissSkipFeedback();
|
||||||
|
onLiveSeek(offset);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an action that replaces what is playing — a live channel switch, the
|
||||||
|
/// next or previous item — so the badge goes with the timeline it described.
|
||||||
|
///
|
||||||
|
/// The pending skip is cancelled by the switch itself (live keeps its offset
|
||||||
|
/// in the parent accumulator, video re-keys on the new item), but neither
|
||||||
|
/// route runs through [_hiddenSeek], so nothing else takes the readout down.
|
||||||
|
VoidCallback? _abandoningBurst(VoidCallback? action) {
|
||||||
|
if (action == null) return null;
|
||||||
|
return () {
|
||||||
|
// Cancel as well as hide: a held-arrow target stays armed across the
|
||||||
|
// asynchronous switch and would otherwise debounce into a seek on the
|
||||||
|
// outgoing player before the new item re-keys the controls. The focused
|
||||||
|
// desktop timeline coalesces into its own accumulator, so it needs the
|
||||||
|
// same treatment.
|
||||||
|
_hiddenSeek.cancel();
|
||||||
|
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||||
|
_dismissSkipFeedback();
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a completed skip-zone double tap.
|
/// Handle a completed skip-zone double tap.
|
||||||
void _handleDoubleTapSkip({required bool isForward}) {
|
void _handleDoubleTapSkip({required bool isForward}) {
|
||||||
if (!widget.canControl) return;
|
if (!widget.canControl) return;
|
||||||
|
|
||||||
|
// This tap supersedes any burst the keyboard/D-pad left pending, and it
|
||||||
|
// shares the badge with it. Retire that burst first, or its abandonment —
|
||||||
|
// triggered by this tap's own seek — would take down the readout this tap
|
||||||
|
// is about to put up.
|
||||||
|
_hiddenSeek.cancel();
|
||||||
_registerSkipFeedback(isForward: isForward, seconds: _seekTimeSmall);
|
_registerSkipFeedback(isForward: isForward, seconds: _seekTimeSmall);
|
||||||
|
|
||||||
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
final delta = Duration(seconds: isForward ? _seekTimeSmall : -_seekTimeSmall);
|
||||||
@@ -642,6 +683,9 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|||||||
|
|
||||||
/// Show animated visual feedback for skip gesture
|
/// Show animated visual feedback for skip gesture
|
||||||
void _showSkipFeedback({required bool isForward}) {
|
void _showSkipFeedback({required bool isForward}) {
|
||||||
|
// Reads `tokens(context)` below, so a caller reaching here after disposal
|
||||||
|
// would touch a defunct element rather than merely no-op.
|
||||||
|
if (!mounted) return;
|
||||||
// Cancel BOTH timers: a skip landing during the fade-out window must not
|
// Cancel BOTH timers: a skip landing during the fade-out window must not
|
||||||
// leave the old hide timer pending, or it kills the fresh readout and zeroes
|
// leave the old hide timer pending, or it kills the fresh readout and zeroes
|
||||||
// the accumulated count mid-display.
|
// the accumulated count mid-display.
|
||||||
@@ -675,6 +719,22 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the skip readout down at once, because the burst it was counting will
|
||||||
|
/// never be committed. Fading it out would keep showing a total the player is
|
||||||
|
/// not going to seek to; zeroing it without hiding would flash `0s`.
|
||||||
|
void _dismissSkipFeedback() {
|
||||||
|
_feedbackTimer?.cancel();
|
||||||
|
_feedbackTimer = null;
|
||||||
|
_feedbackHideTimer?.cancel();
|
||||||
|
_feedbackHideTimer = null;
|
||||||
|
if (!_showDoubleTapFeedback && _accumulatedSkipSeconds == 0) return;
|
||||||
|
_setControlsState(() {
|
||||||
|
_showDoubleTapFeedback = false;
|
||||||
|
_doubleTapFeedbackOpacity = 0.0;
|
||||||
|
_accumulatedSkipSeconds = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle tap on controls overlay - route to skip zones or toggle controls
|
/// Handle tap on controls overlay - route to skip zones or toggle controls
|
||||||
void _handleControlsOverlayTap(TapUpDetails details, Size size) {
|
void _handleControlsOverlayTap(TapUpDetails details, Size size) {
|
||||||
final isMobile = PlatformDetector.isMobile(context);
|
final isMobile = PlatformDetector.isMobile(context);
|
||||||
|
|||||||
@@ -790,6 +790,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
currentPosition: () => widget.player.state.position,
|
currentPosition: () => widget.player.state.position,
|
||||||
duration: () => widget.player.state.duration,
|
duration: () => widget.player.state.duration,
|
||||||
seek: (target) => unawaited(_seekToPosition(target)),
|
seek: (target) => unawaited(_seekToPosition(target)),
|
||||||
|
playheadJumps: widget.player.streams.playheadJump,
|
||||||
|
// The badge is a promise about the coalesced burst. Once that burst is
|
||||||
|
// abandoned — the timeline, a chapter jump, a peer, a stream rebuilt at a
|
||||||
|
// resume position, a new item, a swapped player — the promise is void, so
|
||||||
|
// take the readout down instead of leaving a total nothing will seek to
|
||||||
|
// (#1819, keeping the #1676 badge-matches-seek invariant).
|
||||||
|
onBurstAbandoned: _dismissSkipFeedback,
|
||||||
);
|
);
|
||||||
// Side effects: rotation lock + focus on nav-enable. Both fire immediately
|
// Side effects: rotation lock + focus on nav-enable. Both fire immediately
|
||||||
// so init wiring (orientation, focus) lives in one place.
|
// so init wiring (orientation, focus) lives in one place.
|
||||||
@@ -876,6 +883,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.player != widget.player) {
|
if (oldWidget.player != widget.player) {
|
||||||
++_subtitleVisibilityWriteGeneration;
|
++_subtitleVisibilityWriteGeneration;
|
||||||
|
// Otherwise the accumulator keeps listening to the retired player and
|
||||||
|
// never hears the new one move.
|
||||||
|
_hiddenSeek.attachPlayheadJumps(widget.player.streams.playheadJump);
|
||||||
}
|
}
|
||||||
if (oldWidget.chromeController != widget.chromeController) {
|
if (oldWidget.chromeController != widget.chromeController) {
|
||||||
oldWidget.chromeController.removeListener(_onChromeChanged);
|
oldWidget.chromeController.removeListener(_onChromeChanged);
|
||||||
@@ -888,6 +898,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
// the per-item chapters/markers/skip state when the item changes.
|
// the per-item chapters/markers/skip state when the item changes.
|
||||||
// (Quality/version switches keep the same item, so no refetch churn.)
|
// (Quality/version switches keep the same item, so no refetch churn.)
|
||||||
if (oldWidget.metadata.globalKey != widget.metadata.globalKey) {
|
if (oldWidget.metadata.globalKey != widget.metadata.globalKey) {
|
||||||
|
// A pending skip is an offset into the outgoing item's timeline; letting
|
||||||
|
// it survive would rebase the next press onto the previous episode. This
|
||||||
|
// is the lifecycle backstop for every route that replaces the item
|
||||||
|
// without going through a wrapped next/previous callback — natural
|
||||||
|
// completion, a peer, an external change — so it also has to reach the
|
||||||
|
// desktop timeline's own accumulator and the shared readout.
|
||||||
|
_hiddenSeek.cancel();
|
||||||
|
_desktopControlsKey.currentState?.abandonPendingSeek();
|
||||||
|
_dismissSkipFeedback();
|
||||||
_setControlsState(() {
|
_setControlsState(() {
|
||||||
_chapters = [];
|
_chapters = [];
|
||||||
_chaptersLoaded = false;
|
_chaptersLoaded = false;
|
||||||
@@ -1187,8 +1206,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
onCancelAutoHide: widget.chromeController.cancelAutoHide,
|
||||||
onStartAutoHide: widget.chromeController.startAutoHide,
|
onStartAutoHide: widget.chromeController.startAutoHide,
|
||||||
onBack: widget.onBack,
|
onBack: widget.onBack,
|
||||||
onNext: widget.onNext,
|
onNext: _abandoningBurst(widget.onNext),
|
||||||
onPrevious: widget.onPrevious,
|
onPrevious: _abandoningBurst(widget.onPrevious),
|
||||||
canControl: widget.canControl,
|
canControl: widget.canControl,
|
||||||
hasFirstFrame: widget.hasFirstFrame,
|
hasFirstFrame: widget.hasFirstFrame,
|
||||||
thumbnailDataBuilder: widget.thumbnailDataBuilder,
|
thumbnailDataBuilder: widget.thumbnailDataBuilder,
|
||||||
@@ -1197,7 +1216,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
captureBuffer: widget.captureBuffer,
|
captureBuffer: widget.captureBuffer,
|
||||||
isAtLiveEdge: widget.isAtLiveEdge,
|
isAtLiveEdge: widget.isAtLiveEdge,
|
||||||
streamStartEpoch: widget.streamStartEpoch,
|
streamStartEpoch: widget.streamStartEpoch,
|
||||||
onLiveSeek: widget.onLiveSeek,
|
onLiveSeek: _liveSeekAbandoningBurst(widget.onLiveSeek),
|
||||||
serverId: widget.metadata.serverId,
|
serverId: widget.metadata.serverId,
|
||||||
showQueueTab: canShowQueue,
|
showQueueTab: canShowQueue,
|
||||||
onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null,
|
onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/media/stepped_seek.dart';
|
import 'package:plezy/media/stepped_seek.dart';
|
||||||
@@ -64,4 +66,209 @@ void main() {
|
|||||||
accumulator.dispose();
|
accumulator.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('foreign seeks', () {
|
||||||
|
test('a seek from elsewhere retires the pin so the next step rebases', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
var position = const Duration(seconds: 20);
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
var abandonments = 0;
|
||||||
|
final playerJumps = StreamController<Duration?>.broadcast();
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => position,
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: seeks.add,
|
||||||
|
playheadJumps: playerJumps.stream,
|
||||||
|
onBurstAbandoned: () => abandonments++,
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.flush();
|
||||||
|
expect(seeks, [const Duration(seconds: 30)]);
|
||||||
|
// The accumulator's own commit, echoed back by the player.
|
||||||
|
position = const Duration(seconds: 30);
|
||||||
|
playerJumps.add(const Duration(seconds: 30));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(accumulator.pendingPosition, const Duration(seconds: 30));
|
||||||
|
expect(abandonments, 0, reason: 'the accumulator must not mistake its own seek for a foreign one');
|
||||||
|
|
||||||
|
// The user drops the playhead somewhere else with the timeline.
|
||||||
|
position = const Duration(minutes: 5);
|
||||||
|
playerJumps.add(const Duration(minutes: 5));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(accumulator.pendingPosition, isNull);
|
||||||
|
expect(abandonments, 1);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.flush();
|
||||||
|
expect(
|
||||||
|
seeks.last,
|
||||||
|
const Duration(minutes: 5, seconds: 10),
|
||||||
|
reason: 'the skip must be relative to the timeline jump, not to the superseded target',
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.dispose();
|
||||||
|
playerJumps.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a seek from elsewhere mid-burst drops the undispatched target', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
var position = const Duration(seconds: 20);
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
final playerJumps = StreamController<Duration?>.broadcast();
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => position,
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: seeks.add,
|
||||||
|
playheadJumps: playerJumps.stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
position = const Duration(minutes: 5);
|
||||||
|
playerJumps.add(const Duration(minutes: 5));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 2));
|
||||||
|
expect(seeks, isEmpty, reason: 'the debounce belonged to a burst the foreign seek abandoned');
|
||||||
|
expect(accumulator.pendingPosition, isNull);
|
||||||
|
|
||||||
|
accumulator.dispose();
|
||||||
|
playerJumps.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onBurstAbandoned stays out of the ordinary step and settle paths', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
var position = const Duration(seconds: 20);
|
||||||
|
var abandonments = 0;
|
||||||
|
var changes = 0;
|
||||||
|
final playerJumps = StreamController<Duration?>.broadcast();
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => position,
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: (_) {},
|
||||||
|
playheadJumps: playerJumps.stream,
|
||||||
|
onChanged: () => changes++,
|
||||||
|
onBurstAbandoned: () => abandonments++,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Repeated presses advance the pending target: previews repaint, but no
|
||||||
|
// press is a foreign seek. Reporting one here would reset the caller's
|
||||||
|
// running skip total on every repeat.
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
expect(changes, 3);
|
||||||
|
expect(abandonments, 0);
|
||||||
|
|
||||||
|
// Nor is the natural settle once playback reaches the target.
|
||||||
|
accumulator.flush();
|
||||||
|
position = const Duration(seconds: 50);
|
||||||
|
async.elapse(const Duration(seconds: 2));
|
||||||
|
expect(accumulator.pendingPosition, isNull);
|
||||||
|
expect(abandonments, 0);
|
||||||
|
|
||||||
|
accumulator.dispose();
|
||||||
|
playerJumps.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a retired accumulator stops listening to the player', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final playerJumps = StreamController<Duration?>.broadcast();
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => const Duration(seconds: 20),
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: (_) {},
|
||||||
|
playheadJumps: playerJumps.stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.dispose();
|
||||||
|
expect(playerJumps.hasListener, isFalse);
|
||||||
|
|
||||||
|
playerJumps.add(const Duration(minutes: 5));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
playerJumps.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a jump with no reported destination is always foreign', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final seeks = <Duration>[];
|
||||||
|
var abandonments = 0;
|
||||||
|
final playerJumps = StreamController<Duration?>.broadcast();
|
||||||
|
var position = const Duration(seconds: 20);
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => position,
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: seeks.add,
|
||||||
|
playheadJumps: playerJumps.stream,
|
||||||
|
onBurstAbandoned: () => abandonments++,
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.flush();
|
||||||
|
expect(accumulator.pendingPosition, const Duration(seconds: 30));
|
||||||
|
|
||||||
|
// mpv's sub-seek picks its own cue, so the destination is unknown.
|
||||||
|
position = const Duration(minutes: 2);
|
||||||
|
playerJumps.add(null);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(accumulator.pendingPosition, isNull);
|
||||||
|
expect(abandonments, 1);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.flush();
|
||||||
|
expect(seeks.last, const Duration(minutes: 2, seconds: 10));
|
||||||
|
|
||||||
|
accumulator.dispose();
|
||||||
|
playerJumps.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebinding to another player drops the old pin and its stream', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
var abandonments = 0;
|
||||||
|
final oldPlayer = StreamController<Duration?>.broadcast();
|
||||||
|
final newPlayer = StreamController<Duration?>.broadcast();
|
||||||
|
final accumulator = DebouncedSeekAccumulator(
|
||||||
|
currentPosition: () => const Duration(seconds: 20),
|
||||||
|
duration: () => const Duration(minutes: 10),
|
||||||
|
seek: (_) {},
|
||||||
|
playheadJumps: oldPlayer.stream,
|
||||||
|
onBurstAbandoned: () => abandonments++,
|
||||||
|
);
|
||||||
|
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
accumulator.attachPlayheadJumps(newPlayer.stream);
|
||||||
|
|
||||||
|
expect(oldPlayer.hasListener, isFalse);
|
||||||
|
expect(
|
||||||
|
accumulator.pendingPosition,
|
||||||
|
isNull,
|
||||||
|
reason: 'the pin described the retired player, whose echo will never arrive',
|
||||||
|
);
|
||||||
|
expect(abandonments, 1, reason: 'the burst was dropped, so its running total is stale too');
|
||||||
|
|
||||||
|
// A late event from the retired player must not reach the accumulator.
|
||||||
|
accumulator.seekBy(const Duration(seconds: 10));
|
||||||
|
oldPlayer.add(const Duration(minutes: 4));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(accumulator.pendingPosition, isNotNull);
|
||||||
|
expect(abandonments, 1);
|
||||||
|
|
||||||
|
newPlayer.add(const Duration(minutes: 6));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(accumulator.pendingPosition, isNull, reason: 'the new player is what it listens to now');
|
||||||
|
expect(abandonments, 2);
|
||||||
|
|
||||||
|
accumulator.dispose();
|
||||||
|
oldPlayer.close();
|
||||||
|
newPlayer.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ class _FakeMusicService extends StubMusicPlaybackService {
|
|||||||
MediaItem track;
|
MediaItem track;
|
||||||
final MusicPlayContext context;
|
final MusicPlayContext context;
|
||||||
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
|
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast(sync: true);
|
||||||
|
final StreamController<Duration?> _playheadJumpController = StreamController<Duration?>.broadcast(sync: true);
|
||||||
final List<Duration> seeks = [];
|
final List<Duration> seeks = [];
|
||||||
Duration _position = Duration.zero;
|
Duration _position = Duration.zero;
|
||||||
|
|
||||||
@@ -55,6 +56,13 @@ class _FakeMusicService extends StubMusicPlaybackService {
|
|||||||
_positionController.add(position);
|
_positionController.add(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Something outside the screen — OS media controls, a headset, the lock
|
||||||
|
/// screen — moved the playhead.
|
||||||
|
void emitPlayheadJump(Duration position) {
|
||||||
|
_position = position;
|
||||||
|
_playheadJumpController.add(position);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MediaItem get currentTrack => track;
|
MediaItem get currentTrack => track;
|
||||||
|
|
||||||
@@ -67,6 +75,9 @@ class _FakeMusicService extends StubMusicPlaybackService {
|
|||||||
@override
|
@override
|
||||||
Stream<Duration> get positionStream => _positionController.stream;
|
Stream<Duration> get positionStream => _positionController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Duration?> get playheadJumpStream => _playheadJumpController.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Duration get duration => const Duration(minutes: 3);
|
Duration get duration => const Duration(minutes: 3);
|
||||||
|
|
||||||
@@ -87,6 +98,7 @@ class _FakeMusicService extends StubMusicPlaybackService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_playheadJumpController.close();
|
||||||
_positionController.close();
|
_positionController.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -193,6 +205,44 @@ void main() {
|
|||||||
expect(service.seeks, isEmpty);
|
expect(service.seeks, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a d-pad seek after an outside jump starts from where the jump landed', (tester) async {
|
||||||
|
// The seek bar pins its coalesced target so a slow backend cannot make the
|
||||||
|
// next press rebase off a stale position. OS media controls, a headset and
|
||||||
|
// the lock screen seek straight through the service, so that pin has to be
|
||||||
|
// retired when one of them moves the playhead (#1819).
|
||||||
|
final track = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
||||||
|
final service = _FakeMusicService(
|
||||||
|
track: track,
|
||||||
|
context: const MusicPlayContext(title: 'Queue', kind: MusicPlayContextKind.tracks),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpNowPlaying(tester, service, isTv: true);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
expect(service.seeks, hasLength(1));
|
||||||
|
final step = service.seeks.single;
|
||||||
|
expect(step, greaterThan(Duration.zero));
|
||||||
|
|
||||||
|
service.emitPlayheadJump(const Duration(minutes: 2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
service.seeks.last,
|
||||||
|
const Duration(minutes: 2) + step,
|
||||||
|
reason: 'the step must build on the outside jump, not on the superseded pin',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('seek progress resets immediately when the track changes', (tester) async {
|
testWidgets('seek progress resets immediately when the track changes', (tester) async {
|
||||||
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
final first = _track(id: 'one', title: 'First Track', album: 'First Album', year: 1973);
|
||||||
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
|
final second = _track(id: 'two', title: 'Second Track', album: 'Second Album', year: 1999);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class FakePlayer implements Player {
|
|||||||
final completedCtrl = StreamController<bool>.broadcast(sync: true);
|
final completedCtrl = StreamController<bool>.broadcast(sync: true);
|
||||||
final bufferingCtrl = StreamController<bool>.broadcast(sync: true);
|
final bufferingCtrl = StreamController<bool>.broadcast(sync: true);
|
||||||
final positionCtrl = StreamController<Duration>.broadcast(sync: true);
|
final positionCtrl = StreamController<Duration>.broadcast(sync: true);
|
||||||
|
final playheadJumpCtrl = StreamController<Duration?>.broadcast(sync: true);
|
||||||
final durationCtrl = StreamController<Duration>.broadcast(sync: true);
|
final durationCtrl = StreamController<Duration>.broadcast(sync: true);
|
||||||
final seekableCtrl = StreamController<bool>.broadcast(sync: true);
|
final seekableCtrl = StreamController<bool>.broadcast(sync: true);
|
||||||
final bufferCtrl = StreamController<Duration>.broadcast(sync: true);
|
final bufferCtrl = StreamController<Duration>.broadcast(sync: true);
|
||||||
@@ -83,6 +84,7 @@ class FakePlayer implements Player {
|
|||||||
completed: completedCtrl.stream,
|
completed: completedCtrl.stream,
|
||||||
buffering: bufferingCtrl.stream,
|
buffering: bufferingCtrl.stream,
|
||||||
position: positionCtrl.stream,
|
position: positionCtrl.stream,
|
||||||
|
playheadJump: playheadJumpCtrl.stream,
|
||||||
duration: durationCtrl.stream,
|
duration: durationCtrl.stream,
|
||||||
seekable: seekableCtrl.stream,
|
seekable: seekableCtrl.stream,
|
||||||
buffer: bufferCtrl.stream,
|
buffer: bufferCtrl.stream,
|
||||||
@@ -162,6 +164,7 @@ class FakePlayer implements Player {
|
|||||||
completedCtrl.close();
|
completedCtrl.close();
|
||||||
bufferingCtrl.close();
|
bufferingCtrl.close();
|
||||||
positionCtrl.close();
|
positionCtrl.close();
|
||||||
|
playheadJumpCtrl.close();
|
||||||
durationCtrl.close();
|
durationCtrl.close();
|
||||||
seekableCtrl.close();
|
seekableCtrl.close();
|
||||||
bufferCtrl.close();
|
bufferCtrl.close();
|
||||||
@@ -189,6 +192,11 @@ class FakePlayer implements Player {
|
|||||||
@override
|
@override
|
||||||
Duration get currentPosition => _state.position;
|
Duration get currentPosition => _state.position;
|
||||||
|
|
||||||
|
/// Set by tests that drive a gapless transition; the real player records this
|
||||||
|
/// as the outgoing source hands over.
|
||||||
|
@override
|
||||||
|
Duration? outgoingSourcePosition;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get audioPassthroughActive => false;
|
bool get audioPassthroughActive => false;
|
||||||
|
|
||||||
@@ -967,6 +975,38 @@ void main() {
|
|||||||
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
|
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a track with no metadata duration is reported stopped where the source actually got to', () async {
|
||||||
|
// Nothing supplies a duration to report at, so the outgoing position is the
|
||||||
|
// only truth — and by the time the transition is handled the player's live
|
||||||
|
// position already belongs to the track that replaced it.
|
||||||
|
final undated = testMediaItem(
|
||||||
|
id: 'nd',
|
||||||
|
backend: MediaBackend.plex,
|
||||||
|
kind: MediaKind.track,
|
||||||
|
title: 'Unknown length',
|
||||||
|
parentTitle: 'Album',
|
||||||
|
grandparentTitle: 'Artist',
|
||||||
|
serverId: 'srv',
|
||||||
|
);
|
||||||
|
await h.playTracks([undated, t2]);
|
||||||
|
|
||||||
|
// The gapless advance: the new source is at its start, and the player has
|
||||||
|
// recorded where the old one handed over.
|
||||||
|
h.player.outgoingSourcePosition = const Duration(minutes: 2, seconds: 12);
|
||||||
|
h.player.setPosition(Duration.zero);
|
||||||
|
h.player.emitTransition(_urlFor(t2));
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final stopped = h.client.reportsFor('stopped').toList();
|
||||||
|
expect(stopped, hasLength(1));
|
||||||
|
expect(stopped.single.itemId, 'nd');
|
||||||
|
expect(
|
||||||
|
stopped.single.position,
|
||||||
|
const Duration(minutes: 2, seconds: 12),
|
||||||
|
reason: 'the new source has reset the live position; the outgoing track played to 2:12',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('completed with nothing armed parks paused at the end and keeps the track', () async {
|
test('completed with nothing armed parks paused at the end and keeps the track', () async {
|
||||||
await h.playTracks([t1, t2]);
|
await h.playTracks([t1, t2]);
|
||||||
h.player.emitTransition(_urlFor(t2));
|
h.player.emitTransition(_urlFor(t2));
|
||||||
@@ -1390,4 +1430,22 @@ void main() {
|
|||||||
expect(h.service.currentTrack?.id, 't1');
|
expect(h.service.currentTrack?.id, 't1');
|
||||||
expect(h.service.sleepTimerActive, isFalse);
|
expect(h.service.sleepTimerActive, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the service republishes the player playhead jumps the now-playing bar listens to', () async {
|
||||||
|
// OS media controls, a headset and the lock screen seek straight through
|
||||||
|
// the service, so this stream is the only way the now-playing seek bar can
|
||||||
|
// learn that its pending keyboard target was superseded (#1819). What the
|
||||||
|
// bar then does with a jump is covered in test/media/stepped_seek_test.dart.
|
||||||
|
await h.playTracks([t1]);
|
||||||
|
|
||||||
|
final jumps = <Duration?>[];
|
||||||
|
final subscription = h.service.playheadJumpStream.listen(jumps.add);
|
||||||
|
addTearDown(subscription.cancel);
|
||||||
|
|
||||||
|
h.player.playheadJumpCtrl.add(const Duration(minutes: 2));
|
||||||
|
h.player.playheadJumpCtrl.add(null);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(jumps, [const Duration(minutes: 2), isNull]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/gestures.dart' show kDoubleTapTimeout;
|
import 'package:flutter/gestures.dart' show kDoubleTapTimeout;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -299,6 +300,26 @@ void main() {
|
|||||||
await settleFeedback(tester);
|
await settleFeedback(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a double tap keeps its own readout while a keyboard burst is pending', (tester) async {
|
||||||
|
// Both input paths share one badge. The tap's seek is foreign to the
|
||||||
|
// keyboard accumulator, so retiring that burst must not take down the
|
||||||
|
// readout the tap just raised.
|
||||||
|
await pumpControls(tester);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(DoubleTapFeedback), findsOneWidget);
|
||||||
|
|
||||||
|
await doubleTap(tester, forwardZoneOf(tester));
|
||||||
|
|
||||||
|
expect(find.byType(DoubleTapFeedback), findsOneWidget, reason: 'the tap that just seeked owns the readout now');
|
||||||
|
expect(find.text('10s'), findsOneWidget, reason: 'and it counts only its own step');
|
||||||
|
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a lone tap in the opposite zone does not skip', (tester) async {
|
testWidgets('a lone tap in the opposite zone does not skip', (tester) async {
|
||||||
await pumpControls(tester);
|
await pumpControls(tester);
|
||||||
|
|
||||||
@@ -340,6 +361,7 @@ void main() {
|
|||||||
/// Minimal [Player] recording seek targets against a fixed 45-minute item.
|
/// Minimal [Player] recording seek targets against a fixed 45-minute item.
|
||||||
class _RecordingPlayer implements Player {
|
class _RecordingPlayer implements Player {
|
||||||
final List<Duration> seeks = [];
|
final List<Duration> seeks = [];
|
||||||
|
final StreamController<Duration?> _jumpController = StreamController<Duration?>.broadcast();
|
||||||
|
|
||||||
bool _playing = true;
|
bool _playing = true;
|
||||||
Duration _position = const Duration(minutes: 10);
|
Duration _position = const Duration(minutes: 10);
|
||||||
@@ -353,6 +375,7 @@ class _RecordingPlayer implements Player {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
PlayerStreams get streams => PlayerStreams(
|
PlayerStreams get streams => PlayerStreams(
|
||||||
|
playheadJump: _jumpController.stream,
|
||||||
playing: const Stream<bool>.empty(),
|
playing: const Stream<bool>.empty(),
|
||||||
completed: const Stream<bool>.empty(),
|
completed: const Stream<bool>.empty(),
|
||||||
buffering: const Stream<bool>.empty(),
|
buffering: const Stream<bool>.empty(),
|
||||||
@@ -377,6 +400,12 @@ class _RecordingPlayer implements Player {
|
|||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
seeks.add(position);
|
seeks.add(position);
|
||||||
_position = position;
|
_position = position;
|
||||||
|
_jumpController.add(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
||||||
|
await _jumpController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -17,6 +18,7 @@ import 'package:plezy/utils/platform_detector.dart';
|
|||||||
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
||||||
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
|
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
|
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
|
||||||
import 'package:plezy/widgets/video_controls/video_controls.dart';
|
import 'package:plezy/widgets/video_controls/video_controls.dart';
|
||||||
import 'package:plezy/widgets/video_controls/widgets/double_tap_feedback.dart';
|
import 'package:plezy/widgets/video_controls/widgets/double_tap_feedback.dart';
|
||||||
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
|
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
|
||||||
@@ -75,6 +77,7 @@ void main() {
|
|||||||
chrome.dispose();
|
chrome.dispose();
|
||||||
toast.dispose();
|
toast.dispose();
|
||||||
await database.close();
|
await database.close();
|
||||||
|
await player.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpControls(
|
Future<void> pumpControls(
|
||||||
@@ -83,6 +86,7 @@ void main() {
|
|||||||
bool wireTransportCallback = false,
|
bool wireTransportCallback = false,
|
||||||
bool isLive = false,
|
bool isLive = false,
|
||||||
ValueChanged<int>? onLiveSeekBy,
|
ValueChanged<int>? onLiveSeekBy,
|
||||||
|
String itemId = 'transient-feedback',
|
||||||
}) async {
|
}) async {
|
||||||
transportCommands = [];
|
transportCommands = [];
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -101,7 +105,7 @@ void main() {
|
|||||||
child: PlexVideoControls(
|
child: PlexVideoControls(
|
||||||
player: player,
|
player: player,
|
||||||
volumeController: volume,
|
volumeController: volume,
|
||||||
metadata: testMediaItem(id: 'transient-feedback'),
|
metadata: testMediaItem(id: itemId),
|
||||||
toastController: toast,
|
toastController: toast,
|
||||||
chromeController: chrome,
|
chromeController: chrome,
|
||||||
initialChapters: chapters,
|
initialChapters: chapters,
|
||||||
@@ -266,6 +270,101 @@ void main() {
|
|||||||
await settleFeedback(tester);
|
await settleFeedback(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a skip after a jump elsewhere starts from where the jump landed', (tester) async {
|
||||||
|
// #1819: the timeline, a chapter jump, an OS media control and a Watch
|
||||||
|
// Together peer all land on Player.seek. Whichever of them moves the
|
||||||
|
// playhead, the coalesced target the previous skip pinned is stale, and a
|
||||||
|
// skip that resumes from it rewinds the user back across their own jump.
|
||||||
|
await pumpControls(tester);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
expect(player.seeks, [const Duration(minutes: 10, seconds: 10)]);
|
||||||
|
|
||||||
|
await player.seek(const Duration(minutes: 30));
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
find.text('10s'),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'the readout promised a skip the jump just cancelled, so it must come down at once',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
player.seeks.last,
|
||||||
|
const Duration(minutes: 30, seconds: 10),
|
||||||
|
reason: 'the skip must be relative to the new position, not to the superseded 10:10 target',
|
||||||
|
);
|
||||||
|
expect(find.text('10s'), findsOneWidget, reason: 'the abandoned burst total must not keep climbing');
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a skip after a stream rebuilt at a resume position starts from there', (tester) async {
|
||||||
|
// Dead-stream recovery answers a seek request by reopening the source at
|
||||||
|
// the target rather than seeking, so a fix that only watched Player.seek
|
||||||
|
// would leave the pin stale here.
|
||||||
|
await pumpControls(tester);
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
final seeksBeforeReload = player.seeks.length;
|
||||||
|
|
||||||
|
player.reopenAt(const Duration(minutes: 3));
|
||||||
|
await tester.pump();
|
||||||
|
expect(player.seeks, hasLength(seeksBeforeReload), reason: 'a reload is not a seek');
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(player.seeks.last, const Duration(minutes: 3, seconds: 10));
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a new item drops the previous item\'s pending skip and its badge total', (tester) async {
|
||||||
|
// The controls survive an in-place episode swap. A pending target is an
|
||||||
|
// offset into the outgoing item's timeline, and the badge total describes
|
||||||
|
// a burst that will never be committed.
|
||||||
|
player.freezePositionOnSeek = true;
|
||||||
|
await pumpControls(tester);
|
||||||
|
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
expect(find.text('20s'), findsOneWidget);
|
||||||
|
expect(player.seeks.last, const Duration(minutes: 10, seconds: 20));
|
||||||
|
|
||||||
|
await pumpControls(tester, itemId: 'next-episode');
|
||||||
|
|
||||||
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.mediaFastForward);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
player.seeks.last,
|
||||||
|
const Duration(minutes: 10, seconds: 10),
|
||||||
|
reason: 'the new item restarts from the live position, not from the outgoing 10:20 target',
|
||||||
|
);
|
||||||
|
expect(find.text('10s'), findsOneWidget, reason: 'the badge must not keep counting the abandoned burst');
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a media fast-forward key announces the chapter it lands on', (tester) async {
|
testWidgets('a media fast-forward key announces the chapter it lands on', (tester) async {
|
||||||
await pumpControls(
|
await pumpControls(
|
||||||
tester,
|
tester,
|
||||||
@@ -713,9 +812,19 @@ void main() {
|
|||||||
chrome.dispose();
|
chrome.dispose();
|
||||||
toast.dispose();
|
toast.dispose();
|
||||||
await database.close();
|
await database.close();
|
||||||
|
await player.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpDesktopControls(WidgetTester tester) async {
|
Future<void> pumpDesktopControls(
|
||||||
|
WidgetTester tester, {
|
||||||
|
_RecordingPlayer? withPlayer,
|
||||||
|
bool isLive = false,
|
||||||
|
ValueChanged<int>? onLiveSeekBy,
|
||||||
|
ValueChanged<int>? onLiveSeek,
|
||||||
|
VoidCallback? onNext,
|
||||||
|
bool canNavigateMediaItems = false,
|
||||||
|
}) async {
|
||||||
|
final active = withPlayer ?? player;
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -730,12 +839,16 @@ void main() {
|
|||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
child: PlexVideoControls(
|
child: PlexVideoControls(
|
||||||
player: player,
|
player: active,
|
||||||
volumeController: volume,
|
volumeController: volume,
|
||||||
metadata: testMediaItem(id: 'desktop-keyboard-seek'),
|
metadata: testMediaItem(id: 'desktop-keyboard-seek'),
|
||||||
toastController: toast,
|
toastController: toast,
|
||||||
chromeController: chrome,
|
chromeController: chrome,
|
||||||
canNavigateMediaItems: false,
|
canNavigateMediaItems: canNavigateMediaItems,
|
||||||
|
isLive: isLive,
|
||||||
|
onLiveSeekBy: onLiveSeekBy,
|
||||||
|
onLiveSeek: onLiveSeek,
|
||||||
|
onNext: onNext,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -830,6 +943,119 @@ void main() {
|
|||||||
|
|
||||||
await settleFeedback(tester);
|
await settleFeedback(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a lagging backend still yields the pin to a jump elsewhere', (tester) async {
|
||||||
|
// The two invariants pull in opposite directions: a slow seek must not
|
||||||
|
// retire the pin (#1676), a jump from anywhere else must (#1819). Freeze
|
||||||
|
// the reported position so only the seek announcement can tell them apart.
|
||||||
|
player.freezePositionOnSeek = true;
|
||||||
|
await pumpDesktopControls(tester);
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(player.seeks.last, const Duration(minutes: 10, seconds: 20), reason: 'the burst is still pinned');
|
||||||
|
|
||||||
|
player.setPosition(const Duration(minutes: 2));
|
||||||
|
await player.seek(const Duration(minutes: 2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
|
||||||
|
expect(player.seeks.last, const Duration(minutes: 2, seconds: 10));
|
||||||
|
expect(find.text('10s'), findsOneWidget);
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('swapping the player moves the pin listener with it', (tester) async {
|
||||||
|
// The controls survive a player replacement (didUpdateWidget accepts a new
|
||||||
|
// instance), so an accumulator left bound to the retired player would keep
|
||||||
|
// a pin from a timeline that no longer exists and take orders from a
|
||||||
|
// player nobody is watching.
|
||||||
|
final replacement = _RecordingPlayer()
|
||||||
|
..setPosition(const Duration(minutes: 4))
|
||||||
|
// Frozen, so a pinned chain stays distinguishable from a rebase.
|
||||||
|
..freezePositionOnSeek = true;
|
||||||
|
addTearDown(replacement.dispose);
|
||||||
|
|
||||||
|
await pumpDesktopControls(tester);
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(player.seeks.last, const Duration(minutes: 10, seconds: 10));
|
||||||
|
|
||||||
|
await pumpDesktopControls(tester, withPlayer: replacement);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(
|
||||||
|
replacement.seeks.last,
|
||||||
|
const Duration(minutes: 4, seconds: 10),
|
||||||
|
reason: 'the retired pin must not survive the swap and chain to 10:20',
|
||||||
|
);
|
||||||
|
// The badge is not asserted here: pumping the replacement settles the
|
||||||
|
// feedback timer, which clears the total on its own.
|
||||||
|
|
||||||
|
// Build a fresh pin on the new player, then let the retired one shout.
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(replacement.seeks.last, const Duration(minutes: 4, seconds: 20));
|
||||||
|
await player.seek(const Duration(minutes: 30));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(
|
||||||
|
replacement.seeks.last,
|
||||||
|
const Duration(minutes: 4, seconds: 30),
|
||||||
|
reason: 'a jump from the retired player must not retire the current pin',
|
||||||
|
);
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an absolute live seek takes down the badge a live skip raised', (tester) async {
|
||||||
|
// Live relative skips go to the parent epoch accumulator, not _hiddenSeek
|
||||||
|
// (#1253), so no playhead jump can retire this badge. The absolute seek
|
||||||
|
// cancels the queued skip, so its promised total is going nowhere.
|
||||||
|
final liveOffsets = <int>[];
|
||||||
|
final absoluteSeeks = <int>[];
|
||||||
|
await pumpDesktopControls(tester, isLive: true, onLiveSeekBy: liveOffsets.add, onLiveSeek: absoluteSeeks.add);
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(liveOffsets, [10]);
|
||||||
|
expect(find.text('10s'), findsOneWidget);
|
||||||
|
|
||||||
|
chrome.show();
|
||||||
|
await tester.pump();
|
||||||
|
final live = tester.widget<DesktopVideoControls>(find.byType(DesktopVideoControls));
|
||||||
|
live.onLiveSeek!(120);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(absoluteSeeks, [120], reason: 'the wrapper still delegates to the screen');
|
||||||
|
expect(find.byType(DoubleTapFeedback), findsNothing, reason: 'the skip it promised was cancelled with it');
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('switching what is playing takes the badge with the old timeline', (tester) async {
|
||||||
|
// A live channel switch cancels the queued live offset, and an item
|
||||||
|
// change re-keys the whole timeline. Neither runs through _hiddenSeek, so
|
||||||
|
// nothing else would retire a readout that now describes nothing.
|
||||||
|
final liveOffsets = <int>[];
|
||||||
|
var nextPresses = 0;
|
||||||
|
await pumpDesktopControls(tester, isLive: true, onLiveSeekBy: liveOffsets.add, onNext: () => nextPresses++);
|
||||||
|
|
||||||
|
await pressKey(tester, LogicalKeyboardKey.arrowRight);
|
||||||
|
expect(liveOffsets, [10]);
|
||||||
|
expect(find.text('10s'), findsOneWidget);
|
||||||
|
|
||||||
|
chrome.show();
|
||||||
|
await tester.pump();
|
||||||
|
tester.widget<DesktopVideoControls>(find.byType(DesktopVideoControls)).onNext!();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(nextPresses, 1, reason: 'the wrapper still delegates to the screen');
|
||||||
|
expect(find.byType(DoubleTapFeedback), findsNothing);
|
||||||
|
|
||||||
|
await settleFeedback(tester);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('formatSkipFeedbackLabel', () {
|
group('formatSkipFeedbackLabel', () {
|
||||||
@@ -851,6 +1077,7 @@ void main() {
|
|||||||
/// playing/position state so intent-dependent behaviour can be asserted.
|
/// playing/position state so intent-dependent behaviour can be asserted.
|
||||||
class _RecordingPlayer implements Player {
|
class _RecordingPlayer implements Player {
|
||||||
final List<Duration> seeks = [];
|
final List<Duration> seeks = [];
|
||||||
|
final StreamController<Duration?> _jumpController = StreamController<Duration?>.broadcast();
|
||||||
int playCalls = 0;
|
int playCalls = 0;
|
||||||
int pauseCalls = 0;
|
int pauseCalls = 0;
|
||||||
int playOrPauseCalls = 0;
|
int playOrPauseCalls = 0;
|
||||||
@@ -866,6 +1093,13 @@ class _RecordingPlayer implements Player {
|
|||||||
|
|
||||||
void setPosition(Duration value) => _position = value;
|
void setPosition(Duration value) => _position = value;
|
||||||
|
|
||||||
|
/// Mirrors [PlayerBase.resetPlaybackProgress]: an in-place reload rebuilds
|
||||||
|
/// the stream at a resume position without ever calling [seek].
|
||||||
|
void reopenAt(Duration value) {
|
||||||
|
_position = value;
|
||||||
|
_jumpController.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playerType => 'mpv';
|
String get playerType => 'mpv';
|
||||||
|
|
||||||
@@ -879,6 +1113,7 @@ class _RecordingPlayer implements Player {
|
|||||||
completed: const Stream<bool>.empty(),
|
completed: const Stream<bool>.empty(),
|
||||||
buffering: const Stream<bool>.empty(),
|
buffering: const Stream<bool>.empty(),
|
||||||
position: const Stream<Duration>.empty(),
|
position: const Stream<Duration>.empty(),
|
||||||
|
playheadJump: _jumpController.stream,
|
||||||
duration: const Stream<Duration>.empty(),
|
duration: const Stream<Duration>.empty(),
|
||||||
seekable: const Stream<bool>.empty(),
|
seekable: const Stream<bool>.empty(),
|
||||||
buffer: const Stream<Duration>.empty(),
|
buffer: const Stream<Duration>.empty(),
|
||||||
@@ -895,10 +1130,19 @@ class _RecordingPlayer implements Player {
|
|||||||
backendSwitched: const Stream<void>.empty(),
|
backendSwitched: const Stream<void>.empty(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Mirrors [PlayerBase.runSeek]: the requested target is announced whatever
|
||||||
|
/// asked for it, while [freezePositionOnSeek] models a backend that has not
|
||||||
|
/// moved the reported position yet.
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
seeks.add(position);
|
seeks.add(position);
|
||||||
if (!freezePositionOnSeek) _position = position;
|
if (!freezePositionOnSeek) _position = position;
|
||||||
|
_jumpController.add(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose({bool preserveDisplayMode = false}) async {
|
||||||
|
await _jumpController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user