fix(livetv): debounce rapid time-shift skips to stop overshoot

This commit is contained in:
edde746
2026-06-06 10:48:51 +02:00
parent 32421e4c84
commit f74314bbf0
14 changed files with 511 additions and 17 deletions
+2 -1
View File
@@ -306,7 +306,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
isAtLiveEdge: _isAtLiveEdge,
streamStartEpoch: _streamStartEpoch,
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
onLiveSeek: _captureBuffer != null ? _seekLivePosition : null,
onLiveSeek: _captureBuffer != null ? _seekLiveToEpoch : null,
onLiveSeekBy: _captureBuffer != null ? _liveSeek.seekBy : null,
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
@@ -17,7 +17,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _captureBuffer != null) {
await _seekLivePosition(_currentPositionEpoch + seekSeconds);
_liveSeek.seekBy(seekSeconds);
return;
}
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds));
@@ -28,7 +28,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _captureBuffer != null) {
await _seekLivePosition(_currentPositionEpoch - seekSeconds);
_liveSeek.seekBy(-seekSeconds);
return;
}
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds));
+59 -2
View File
@@ -88,6 +88,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// channel directly with a session-less URL, so retry is just re-opening
/// that URL — degradation knobs apply only to the Plex transcoder branch.
Future<void> _retryLiveStream() async {
_liveSeek.cancel();
final client = _liveClient;
final ds = _liveStreamFallbackLevel < 1;
final dsa = _liveStreamFallbackLevel < 2;
@@ -164,8 +165,19 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
await player!.setProperty('force-seekable', 'no');
}
/// The raw live playback position as an absolute epoch second
/// (`_streamStartEpoch + player position`).
int get _rawPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// The current playback position as an absolute epoch second (for live TV time-shift).
int get _currentPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
///
/// While a relative skip is pending/settling, this returns the accumulator's
/// target rather than the raw sum. During a live re-open `_streamStartEpoch`
/// is advanced to the target before the new stream's position resets to ~0,
/// so the raw sum transiently overshoots; pinning to the pending target keeps
/// seek accumulation and the live-edge heartbeat ([_sendLiveTimeline]) correct
/// (close #1253).
int get _currentPositionEpoch => _liveSeek.pendingEpoch ?? _rawPositionEpoch;
/// Show "Watch from Start" / "Watch Live" dialog.
/// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
@@ -221,10 +233,54 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (mounted) _setPlayerState(() {});
}
/// Current seekable epoch window for [_liveSeek], or null when there is no
/// live capture buffer.
LiveSeekBounds? _liveSeekBounds() {
final buffer = _captureBuffer;
if (buffer == null) return null;
return (start: buffer.seekableStartEpoch, end: buffer.seekableEndEpoch);
}
/// Rebuild and refresh live-edge state when [_liveSeek]'s pending target
/// changes (a skip was accumulated, or the post-seek pin was released).
void _onLiveSeekTargetChanged() {
if (!mounted) return;
final pending = _liveSeek.pendingEpoch;
final buffer = _captureBuffer;
_setPlayerState(() {
if (pending != null && buffer != null) {
_isAtLiveEdge = pending >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds;
}
});
}
/// Re-open the live stream at [targetEpochSeconds], logging (rather than
/// throwing) on failure. A throw is rethrown so [_liveSeek] releases its
/// pending pin; direct callers catch it.
Future<void> _runLiveSeek(int targetEpochSeconds) async {
try {
await _seekLivePosition(targetEpochSeconds);
} catch (e, st) {
appLogger.w('Live time-shift seek failed', error: e, stackTrace: st);
rethrow;
}
}
/// Seek the live stream to an absolute epoch (scrubber / jump-to-live). Drops
/// any pending relative-skip burst first so a queued seek can't override it.
Future<void> _seekLiveToEpoch(int targetEpochSeconds) async {
_liveSeek.cancel();
try {
await _runLiveSeek(targetEpochSeconds);
} catch (_) {
// Already logged; an absolute live seek is best-effort.
}
}
/// Jump to the live edge of the capture buffer.
Future<void> _jumpToLiveEdge() async {
if (_captureBuffer == null) return;
await _seekLivePosition(_captureBuffer!.seekableEndEpoch);
await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch);
}
Future<void> _switchLiveChannel(int delta) async {
@@ -236,6 +292,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (newIndex < 0 || newIndex >= channels.length) return;
_isSwitchingChannel = true;
_liveSeek.cancel();
// Stop old session heartbeats and notify server
_stopLiveTimelineUpdates();
+14
View File
@@ -21,6 +21,7 @@ import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_server_client.dart';
import '../services/jellyfin_client.dart';
import '../services/live_seek_accumulator.dart';
import '../services/live_session_tracker.dart';
import '../services/plex_client.dart';
import '../utils/session_identifier.dart';
@@ -345,6 +346,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isAtLiveEdge = true;
String? _transcodeSessionId;
/// Coalesces rapid relative live-TV skips into a single transcode re-open so
/// mashing skip-forward can't compound into an overshoot to live (#1253).
/// Lazily built; its closures read the current live state on each call.
late final LiveSeekAccumulator _liveSeek = LiveSeekAccumulator(
seek: _runLiveSeek,
currentEpoch: () => _rawPositionEpoch,
positionSeconds: () => player?.state.position.inSeconds ?? 0,
bounds: _liveSeekBounds,
onChanged: _onLiveSeekTargetChanged,
);
/// Fallback level for live TV stream errors (mirrors Plex web client behavior).
/// 0 = directStream+directStreamAudio, 1 = no directStream, 2 = no DS + no DS audio.
int _liveStreamFallbackLevel = 0;
@@ -1183,6 +1195,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_stillWatchingTimer?.cancel();
_liveSeek.dispose();
_playNextCancelFocusNode.dispose();
_playNextConfirmFocusNode.dispose();
+7 -2
View File
@@ -190,6 +190,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onZoomReset,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
ValueChanged<int>? onLiveSeekBy,
Future<void> Function(Duration position)? onSeekRequested,
}) {
final isRepeat = event is KeyRepeatEvent;
@@ -276,6 +277,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
onZoomReset: onZoomReset,
currentPositionEpoch: currentPositionEpoch,
onLiveSeek: onLiveSeek,
onLiveSeekBy: onLiveSeekBy,
onSeekRequested: onSeekRequested,
);
return KeyEventResult.handled;
@@ -304,11 +306,14 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onZoomReset,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
ValueChanged<int>? onLiveSeekBy,
Future<void> Function(Duration position)? onSeekRequested,
}) {
void performSeek(int offsetSeconds) {
if (onLiveSeek != null && currentPositionEpoch != null) {
onLiveSeek(currentPositionEpoch + offsetSeconds);
// Relative live-TV skip: route through the parent accumulator, which
// coalesces a rapid burst into one transcode re-open (#1253).
if (onLiveSeekBy != null) {
onLiveSeekBy(offsetSeconds);
} else {
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
unawaited((onSeekRequested ?? player.seek)(target));
+173
View File
@@ -0,0 +1,173 @@
import 'dart:async';
/// Inclusive epoch-second window a live seek may target (the capture buffer's
/// seekable range). `start` ≈ earliest seekable point, `end` ≈ the live edge.
typedef LiveSeekBounds = ({int start, int end});
/// Coalesces rapid relative live-TV skips into a single transcode re-open.
///
/// Live time-shift seeks don't use `player.seek()` — each one re-opens a fresh
/// Plex transcode session at an epoch offset, and the new stream's reported
/// position lags behind the new origin for a second or two. Deriving each skip
/// target from the live `streamStart + position` epoch therefore compounds into
/// wild overshoots when the user mashes skip-forward, occasionally jumping all
/// the way to live (#1253).
///
/// This accumulates a stable in-memory target ([pendingEpoch]) — every press
/// adds onto the previous target, never re-reading the laggy live epoch — and
/// debounces the actual re-open so a whole burst collapses into one [seek].
/// The pending target is held until the re-opened stream's position settles
/// near zero, so a subsequent idle press still bases off a correct value.
///
/// Pure-Dart and timer-driven (no wall-clock reads), so it virtualizes cleanly
/// under `fakeAsync` in tests.
class LiveSeekAccumulator {
LiveSeekAccumulator({
required this.seek,
required this.currentEpoch,
required this.positionSeconds,
required this.bounds,
this.onChanged,
this.debounce = const Duration(milliseconds: 300),
this.settleCeiling = const Duration(milliseconds: 1500),
this.settlePoll = const Duration(milliseconds: 100),
});
/// Re-open the live stream at the target epoch (a fresh transcode session).
/// Should log its own errors; if it throws, the pending pin is released so a
/// failed re-open can't freeze the masked position.
final Future<void> Function(int targetEpoch) seek;
/// The live playback position as an absolute epoch second
/// (`streamStart + position`) — used as the base for a fresh burst.
final int Function() currentEpoch;
/// Player position in seconds — used to detect that a re-opened stream has
/// settled (position reset to ~0) before unpinning [pendingEpoch].
final int Function() positionSeconds;
/// Current seekable window, or null when there is no live capture buffer.
final LiveSeekBounds? Function() bounds;
/// Notified whenever [pendingEpoch] changes (so the owner can rebuild UI and
/// recompute live-edge state).
final void Function()? onChanged;
/// How long after the last press to wait before executing the seek.
final Duration debounce;
/// Upper bound on how long [pendingEpoch] stays pinned after a re-open before
/// it is cleared regardless of whether the position has settled.
final Duration settleCeiling;
/// Interval at which the post-seek settle is polled.
final Duration settlePoll;
int? _pendingEpoch;
Timer? _debounceTimer;
Timer? _settleTimer;
bool _flushing = false;
bool _disposed = false;
/// The accumulated target while a skip is pending or settling, else null.
/// Callers mask their "current position" with this so accumulation and the
/// live-edge heartbeat stay correct across the re-open's position lag.
int? get pendingEpoch => _pendingEpoch;
/// Accumulate a relative skip of [deltaSeconds] and (re)arm the debounce.
/// No-op when there is no seekable window.
void seekBy(int deltaSeconds) {
if (_disposed) return;
final window = bounds();
if (window == null) return;
final base = _pendingEpoch ?? currentEpoch();
final target = (base + deltaSeconds).clamp(window.start, window.end);
if (target != _pendingEpoch) {
_pendingEpoch = target;
onChanged?.call();
}
_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, () => unawaited(_flush()));
}
Future<void> _flush() async {
if (_flushing || _disposed) return;
final target = _pendingEpoch;
if (target == null) return;
// We're committing to this seek; don't let a stale debounce double-fire it.
_debounceTimer?.cancel();
_flushing = true;
var failed = false;
try {
await seek(target);
} catch (_) {
// A failed re-open must release the pin, or the masked position would
// freeze at a target the stream never reached. `seek` is expected to log
// its own errors; here we only guarantee forward progress.
failed = true;
} finally {
_flushing = false;
}
if (_disposed) return;
if (failed) {
if (_pendingEpoch == target) {
_pendingEpoch = null;
onChanged?.call();
}
return;
}
// A press landed during the network round-trip + open: flush the newer
// target immediately rather than waiting for another debounce.
if (_pendingEpoch != target) {
unawaited(_flush());
return;
}
_scheduleClear(target);
}
/// Hold the pinned target until the fresh transcode's position resets to ~0
/// (then `streamStart + position` == target and unpinning is seamless), with
/// [settleCeiling] as a backstop in case it never settles.
void _scheduleClear(int target) {
_settleTimer?.cancel();
var elapsed = Duration.zero;
void tick() {
if (_disposed || _pendingEpoch != target) return;
elapsed += settlePoll;
if (positionSeconds() < 2 || elapsed >= settleCeiling) {
_pendingEpoch = null;
onChanged?.call();
return;
}
_settleTimer = Timer(settlePoll, tick);
}
_settleTimer = Timer(settlePoll, tick);
}
/// Drop any queued/settling seek. Used when the session is about to be
/// replaced (channel switch, retry) or superseded by an absolute seek, so a
/// stale debounced seek can't fire against the new stream.
void cancel() {
_debounceTimer?.cancel();
_debounceTimer = null;
_settleTimer?.cancel();
_settleTimer = null;
_flushing = false;
if (_pendingEpoch != null) {
_pendingEpoch = null;
onChanged?.call();
}
}
void dispose() {
_disposed = true;
_debounceTimer?.cancel();
_settleTimer?.cancel();
}
}
@@ -74,6 +74,9 @@ class DesktopVideoControls extends StatefulWidget {
final double streamStartEpoch;
final int? currentPositionEpoch;
final ValueChanged<int>? onLiveSeek;
/// Relative live-TV skip callback (delta seconds); parent accumulates+debounces.
final ValueChanged<int>? onLiveSeekBy;
final VoidCallback? onJumpToLive;
/// Whether to use dpad navigation for content strip (TV or keyboard nav mode)
@@ -134,6 +137,7 @@ class DesktopVideoControls extends StatefulWidget {
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onLiveSeekBy,
this.onJumpToLive,
this.useDpadNavigation = false,
this.serverId,
@@ -530,11 +534,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
final isForward = key == LogicalKeyboardKey.arrowRight;
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0;
// Live TV: epoch-based seeking via onLiveSeek
if (_isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
// Live TV: relative epoch-based seeking via the parent accumulator, which
// coalesces a rapid/held burst into one transcode re-open (#1253). The
// acceleration multiplier still grows the per-press step; the accumulator
// sums them.
if (_isLive && widget.onLiveSeekBy != null) {
final stepSeconds = (widget.seekTimeSmall * effectiveMultiplier).clamp(1, 300).round();
final targetEpoch = widget.currentPositionEpoch! + (isForward ? stepSeconds : -stepSeconds);
widget.onLiveSeek!(targetEpoch);
widget.onLiveSeekBy!(isForward ? stepSeconds : -stepSeconds);
widget.onFocusActivity?.call();
return KeyEventResult.handled;
}
@@ -162,6 +162,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onLiveSeekBy: widget.onLiveSeekBy,
);
if (result == KeyEventResult.handled) {
_focusNode.requestFocus(); // self-heal focus
@@ -315,6 +316,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onLiveSeekBy: widget.onLiveSeekBy,
onSeekRequested: widget.onSeekRequested,
);
if (!event.logicalKey.isNavigationKey) return result;
@@ -43,6 +43,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
streamStartEpoch: widget.streamStartEpoch,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onLiveSeekBy: widget.onLiveSeekBy,
onJumpToLive: widget.onJumpToLive,
useDpadNavigation: useDpad,
serverId: widget.metadata.serverId,
@@ -63,9 +63,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
}
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
// Route through live seek callback for time-shifted live TV
if (widget.isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
widget.onLiveSeek!(widget.currentPositionEpoch! + delta.inSeconds);
// Route relative live-TV skips through the parent accumulator, which
// coalesces a rapid burst into a single transcode re-open and computes the
// target from a stable base rather than the laggy live epoch (#1253).
if (widget.isLive && widget.onLiveSeekBy != null) {
widget.onLiveSeekBy!(delta.inSeconds);
return;
}
final target = widget.player.state.position + delta;
@@ -241,9 +241,14 @@ class PlexVideoControls extends StatefulWidget {
/// Current playback position as absolute epoch seconds (for live TV)
final int? currentPositionEpoch;
/// Seek callback for live TV time-shift (epoch seconds)
/// Seek callback for live TV time-shift (absolute epoch seconds; scrubber)
final ValueChanged<int>? onLiveSeek;
/// Relative live-TV skip callback (delta seconds). The owning screen
/// accumulates rapid presses and debounces the transcode re-open, so skip
/// buttons/dpad/remote keys must use this rather than `onLiveSeek` (#1253).
final ValueChanged<int>? onLiveSeekBy;
/// Jump to live edge callback
final VoidCallback? onJumpToLive;
@@ -306,6 +311,7 @@ class PlexVideoControls extends StatefulWidget {
this.streamStartEpoch = 0,
this.currentPositionEpoch,
this.onLiveSeek,
this.onLiveSeekBy,
this.onJumpToLive,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
+1 -1
View File
@@ -384,7 +384,7 @@ packages:
source: hosted
version: "4.0.3"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+2 -1
View File
@@ -1,7 +1,7 @@
name: plezy
description: "A beautiful Plex and Jellyfin client for Flutter"
publish_to: "none"
version: 2.4.1+106
version: 2.4.1+107
environment:
sdk: ">=3.12.0 <4.0.0"
@@ -82,6 +82,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
fake_async: ^1.3.3
build_runner: ^2.13.0
json_serializable: ^6.7.1
slang_build_runner: ^4.14.0
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/live_seek_accumulator.dart';
void main() {
group('LiveSeekAccumulator', () {
late List<int> seeks; // recorded re-open targets
late int currentEpoch; // mutable "live" epoch (streamStart + position)
late int positionSeconds; // mutable player position, drives settle
late LiveSeekBounds? window; // mutable seekable window
late int changes; // onChanged call count
late bool seekThrows; // make the seek re-open fail
Completer<void>? gate; // optionally stalls a seek mid-flight
LiveSeekAccumulator build() => LiveSeekAccumulator(
seek: (target) async {
seeks.add(target);
if (gate != null) await gate!.future;
if (seekThrows) throw Exception('seek failed');
},
currentEpoch: () => currentEpoch,
positionSeconds: () => positionSeconds,
bounds: () => window,
onChanged: () => changes++,
debounce: const Duration(milliseconds: 300),
settleCeiling: const Duration(milliseconds: 1500),
settlePoll: const Duration(milliseconds: 100),
);
setUp(() {
seeks = [];
currentEpoch = 1000;
positionSeconds = 0; // re-opened stream settles immediately by default
window = (start: 0, end: 1000000);
changes = 0;
seekThrows = false;
gate = null;
});
test('coalesces a rapid burst into a single seek at the summed target', () {
fakeAsync((async) {
final acc = build();
for (var i = 0; i < 14; i++) {
acc.seekBy(15);
}
// Nothing fires while the burst is still arriving.
expect(seeks, isEmpty);
async.elapse(const Duration(milliseconds: 300));
// 14 presses of 15s from epoch 1000 => one re-open at 1000 + 210.
expect(seeks, [1210]);
acc.dispose();
});
});
test('accumulates off the pending target, not the laggy live epoch', () {
fakeAsync((async) {
final acc = build();
acc.seekBy(15); // base 1000 -> 1015
expect(acc.pendingEpoch, 1015);
// Simulate the post-reopen overshoot: the raw live epoch jumps wildly.
// The next press must still compound off the pending target.
currentEpoch = 99999;
acc.seekBy(15); // 1015 -> 1030, NOT 99999 + 15
expect(acc.pendingEpoch, 1030);
async.elapse(const Duration(milliseconds: 300));
expect(seeks, [1030]);
acc.dispose();
});
});
test('clamps the accumulated target to the live edge', () {
fakeAsync((async) {
window = (start: 950, end: 1050);
final acc = build();
acc.seekBy(100); // 1000 -> 1100, clamped to 1050
expect(acc.pendingEpoch, 1050);
acc.seekBy(100); // stays at the edge
expect(acc.pendingEpoch, 1050);
async.elapse(const Duration(milliseconds: 300));
expect(seeks, [1050]);
acc.dispose();
});
});
test('clamps backward skips to the window start', () {
fakeAsync((async) {
window = (start: 950, end: 1050);
final acc = build();
acc.seekBy(-100); // 1000 -> 900, clamped to 950
expect(acc.pendingEpoch, 950);
acc.dispose();
});
});
test('flushes the newer target when a press lands during the seek', () {
fakeAsync((async) {
gate = Completer<void>();
final acc = build();
acc.seekBy(15); // pending 1015
async.elapse(const Duration(milliseconds: 300));
expect(seeks, [1015]); // first seek in flight, awaiting the gate
acc.seekBy(15); // pending 1030 while the first seek is still open
gate!.complete(); // first seek resolves
gate = null; // later seeks resolve immediately
async.flushMicrotasks();
// The re-entrant flush picks up the newer target — no waiting for a
// second debounce, no lost press.
expect(seeks, [1015, 1030]);
acc.dispose();
});
});
test('unpins the pending target once the re-opened stream settles', () {
fakeAsync((async) {
positionSeconds = 0; // settled
final acc = build();
acc.seekBy(15);
async.elapse(const Duration(milliseconds: 300));
expect(acc.pendingEpoch, 1015); // still pinned right after the re-open
async.elapse(const Duration(milliseconds: 100)); // settle poll
expect(acc.pendingEpoch, isNull);
acc.dispose();
});
});
test('unpins via the ceiling if the position never settles', () {
fakeAsync((async) {
positionSeconds = 100; // never below the settle threshold
final acc = build();
acc.seekBy(15);
async.elapse(const Duration(milliseconds: 300));
expect(acc.pendingEpoch, 1015);
async.elapse(const Duration(milliseconds: 1500)); // ceiling
expect(acc.pendingEpoch, isNull);
acc.dispose();
});
});
test('a fresh burst after settling re-seeds off the live epoch', () {
fakeAsync((async) {
final acc = build();
acc.seekBy(15); // 1000 -> 1015
async.elapse(const Duration(milliseconds: 300));
async.elapse(const Duration(milliseconds: 100)); // settle clears pending
expect(acc.pendingEpoch, isNull);
// New stream origin: raw epoch now reflects the previous target.
currentEpoch = 1015;
acc.seekBy(15); // base 1015 -> 1030
async.elapse(const Duration(milliseconds: 300));
expect(seeks, [1015, 1030]);
acc.dispose();
});
});
test('releases the pending pin when the re-open fails', () {
fakeAsync((async) {
seekThrows = true;
final acc = build();
acc.seekBy(15);
expect(acc.pendingEpoch, 1015);
async.elapse(const Duration(milliseconds: 300));
expect(seeks, [1015]); // the re-open was attempted
expect(acc.pendingEpoch, isNull); // pin released despite the failure
acc.dispose();
});
});
test('cancel drops the pending target and prevents the debounced seek', () {
fakeAsync((async) {
final acc = build();
acc.seekBy(15);
expect(acc.pendingEpoch, 1015);
acc.cancel();
expect(acc.pendingEpoch, isNull);
async.elapse(const Duration(milliseconds: 300));
expect(seeks, isEmpty);
acc.dispose();
});
});
test('is a no-op when there is no seekable window', () {
fakeAsync((async) {
window = null;
final acc = build();
acc.seekBy(15);
expect(acc.pendingEpoch, isNull);
async.elapse(const Duration(milliseconds: 300));
expect(seeks, isEmpty);
acc.dispose();
});
});
test('notifies onChanged when the target changes and when it clears', () {
fakeAsync((async) {
positionSeconds = 0;
final acc = build();
acc.seekBy(15);
expect(changes, 1); // accumulate
async.elapse(const Duration(milliseconds: 300));
async.elapse(const Duration(milliseconds: 100)); // settle clears
expect(changes, 2); // clear
acc.dispose();
});
});
});
}