diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 01c8f2da..dbdd4893 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -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 diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index ccd393cc..a804e24d 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -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)); diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 9844ff66..866e86e7 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -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 _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 _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 _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 _jumpToLiveEdge() async { if (_captureBuffer == null) return; - await _seekLivePosition(_captureBuffer!.seekableEndEpoch); + await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch); } Future _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(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 35dd63e8..bb78aaaf 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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 with WidgetsBindin _stillWatchingTimer?.cancel(); + _liveSeek.dispose(); + _playNextCancelFocusNode.dispose(); _playNextConfirmFocusNode.dispose(); diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index abdcedae..89ca90e2 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -190,6 +190,7 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onZoomReset, int? currentPositionEpoch, ValueChanged? onLiveSeek, + ValueChanged? onLiveSeekBy, Future 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? onLiveSeek, + ValueChanged? onLiveSeekBy, Future 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)); diff --git a/lib/services/live_seek_accumulator.dart b/lib/services/live_seek_accumulator.dart new file mode 100644 index 00000000..c207ad68 --- /dev/null +++ b/lib/services/live_seek_accumulator.dart @@ -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 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 _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(); + } +} diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 9a1ac10c..b3e90128 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -74,6 +74,9 @@ class DesktopVideoControls extends StatefulWidget { final double streamStartEpoch; final int? currentPositionEpoch; final ValueChanged? onLiveSeek; + + /// Relative live-TV skip callback (delta seconds); parent accumulates+debounces. + final ValueChanged? 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 { 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; } diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 3310b26c..36343a81 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -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; diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 4a0de339..8a131927 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -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, diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index 6be92c21..4d933ca2 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -63,9 +63,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { } Future _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; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f5fb2efc..19aac987 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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? 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? 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, diff --git a/pubspec.lock b/pubspec.lock index e9357a84..5c380c73 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -384,7 +384,7 @@ packages: source: hosted version: "4.0.3" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index cea45336..aab921a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/services/live_seek_accumulator_test.dart b/test/services/live_seek_accumulator_test.dart new file mode 100644 index 00000000..012e9ad5 --- /dev/null +++ b/test/services/live_seek_accumulator_test.dart @@ -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 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? 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(); + 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(); + }); + }); + }); +}