fix(livetv): debounce rapid time-shift skips to stop overshoot
This commit is contained in:
@@ -306,7 +306,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
isAtLiveEdge: _isAtLiveEdge,
|
isAtLiveEdge: _isAtLiveEdge,
|
||||||
streamStartEpoch: _streamStartEpoch,
|
streamStartEpoch: _streamStartEpoch,
|
||||||
currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null,
|
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,
|
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
|
||||||
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
|
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
|
||||||
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
|
onToggleAmbientLighting: _ambientLightingService?.isSupported == true
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
|||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
|
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
|
||||||
if (widget.isLive && _captureBuffer != null) {
|
if (widget.isLive && _captureBuffer != null) {
|
||||||
await _seekLivePosition(_currentPositionEpoch + seekSeconds);
|
_liveSeek.seekBy(seekSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds));
|
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds));
|
||||||
@@ -28,7 +28,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
|||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
|
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
|
||||||
if (widget.isLive && _captureBuffer != null) {
|
if (widget.isLive && _captureBuffer != null) {
|
||||||
await _seekLivePosition(_currentPositionEpoch - seekSeconds);
|
_liveSeek.seekBy(-seekSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds));
|
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds));
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
/// channel directly with a session-less URL, so retry is just re-opening
|
/// channel directly with a session-less URL, so retry is just re-opening
|
||||||
/// that URL — degradation knobs apply only to the Plex transcoder branch.
|
/// that URL — degradation knobs apply only to the Plex transcoder branch.
|
||||||
Future<void> _retryLiveStream() async {
|
Future<void> _retryLiveStream() async {
|
||||||
|
_liveSeek.cancel();
|
||||||
final client = _liveClient;
|
final client = _liveClient;
|
||||||
final ds = _liveStreamFallbackLevel < 1;
|
final ds = _liveStreamFallbackLevel < 1;
|
||||||
final dsa = _liveStreamFallbackLevel < 2;
|
final dsa = _liveStreamFallbackLevel < 2;
|
||||||
@@ -164,8 +165,19 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
await player!.setProperty('force-seekable', 'no');
|
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).
|
/// 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.
|
/// Show "Watch from Start" / "Watch Live" dialog.
|
||||||
/// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
|
/// 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(() {});
|
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.
|
/// Jump to the live edge of the capture buffer.
|
||||||
Future<void> _jumpToLiveEdge() async {
|
Future<void> _jumpToLiveEdge() async {
|
||||||
if (_captureBuffer == null) return;
|
if (_captureBuffer == null) return;
|
||||||
await _seekLivePosition(_captureBuffer!.seekableEndEpoch);
|
await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _switchLiveChannel(int delta) async {
|
Future<void> _switchLiveChannel(int delta) async {
|
||||||
@@ -236,6 +292,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
if (newIndex < 0 || newIndex >= channels.length) return;
|
if (newIndex < 0 || newIndex >= channels.length) return;
|
||||||
|
|
||||||
_isSwitchingChannel = true;
|
_isSwitchingChannel = true;
|
||||||
|
_liveSeek.cancel();
|
||||||
|
|
||||||
// Stop old session heartbeats and notify server
|
// Stop old session heartbeats and notify server
|
||||||
_stopLiveTimelineUpdates();
|
_stopLiveTimelineUpdates();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../media/media_item.dart';
|
|||||||
import '../media/media_item_types.dart';
|
import '../media/media_item_types.dart';
|
||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
import '../services/jellyfin_client.dart';
|
import '../services/jellyfin_client.dart';
|
||||||
|
import '../services/live_seek_accumulator.dart';
|
||||||
import '../services/live_session_tracker.dart';
|
import '../services/live_session_tracker.dart';
|
||||||
import '../services/plex_client.dart';
|
import '../services/plex_client.dart';
|
||||||
import '../utils/session_identifier.dart';
|
import '../utils/session_identifier.dart';
|
||||||
@@ -345,6 +346,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
bool _isAtLiveEdge = true;
|
bool _isAtLiveEdge = true;
|
||||||
String? _transcodeSessionId;
|
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).
|
/// Fallback level for live TV stream errors (mirrors Plex web client behavior).
|
||||||
/// 0 = directStream+directStreamAudio, 1 = no directStream, 2 = no DS + no DS audio.
|
/// 0 = directStream+directStreamAudio, 1 = no directStream, 2 = no DS + no DS audio.
|
||||||
int _liveStreamFallbackLevel = 0;
|
int _liveStreamFallbackLevel = 0;
|
||||||
@@ -1183,6 +1195,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
_stillWatchingTimer?.cancel();
|
_stillWatchingTimer?.cancel();
|
||||||
|
|
||||||
|
_liveSeek.dispose();
|
||||||
|
|
||||||
_playNextCancelFocusNode.dispose();
|
_playNextCancelFocusNode.dispose();
|
||||||
_playNextConfirmFocusNode.dispose();
|
_playNextConfirmFocusNode.dispose();
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
VoidCallback? onZoomReset,
|
VoidCallback? onZoomReset,
|
||||||
int? currentPositionEpoch,
|
int? currentPositionEpoch,
|
||||||
ValueChanged<int>? onLiveSeek,
|
ValueChanged<int>? onLiveSeek,
|
||||||
|
ValueChanged<int>? onLiveSeekBy,
|
||||||
Future<void> Function(Duration position)? onSeekRequested,
|
Future<void> Function(Duration position)? onSeekRequested,
|
||||||
}) {
|
}) {
|
||||||
final isRepeat = event is KeyRepeatEvent;
|
final isRepeat = event is KeyRepeatEvent;
|
||||||
@@ -276,6 +277,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
onZoomReset: onZoomReset,
|
onZoomReset: onZoomReset,
|
||||||
currentPositionEpoch: currentPositionEpoch,
|
currentPositionEpoch: currentPositionEpoch,
|
||||||
onLiveSeek: onLiveSeek,
|
onLiveSeek: onLiveSeek,
|
||||||
|
onLiveSeekBy: onLiveSeekBy,
|
||||||
onSeekRequested: onSeekRequested,
|
onSeekRequested: onSeekRequested,
|
||||||
);
|
);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
@@ -304,11 +306,14 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
VoidCallback? onZoomReset,
|
VoidCallback? onZoomReset,
|
||||||
int? currentPositionEpoch,
|
int? currentPositionEpoch,
|
||||||
ValueChanged<int>? onLiveSeek,
|
ValueChanged<int>? onLiveSeek,
|
||||||
|
ValueChanged<int>? onLiveSeekBy,
|
||||||
Future<void> Function(Duration position)? onSeekRequested,
|
Future<void> Function(Duration position)? onSeekRequested,
|
||||||
}) {
|
}) {
|
||||||
void performSeek(int offsetSeconds) {
|
void performSeek(int offsetSeconds) {
|
||||||
if (onLiveSeek != null && currentPositionEpoch != null) {
|
// Relative live-TV skip: route through the parent accumulator, which
|
||||||
onLiveSeek(currentPositionEpoch + offsetSeconds);
|
// coalesces a rapid burst into one transcode re-open (#1253).
|
||||||
|
if (onLiveSeekBy != null) {
|
||||||
|
onLiveSeekBy(offsetSeconds);
|
||||||
} else {
|
} else {
|
||||||
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
||||||
unawaited((onSeekRequested ?? player.seek)(target));
|
unawaited((onSeekRequested ?? player.seek)(target));
|
||||||
|
|||||||
@@ -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 double streamStartEpoch;
|
||||||
final int? currentPositionEpoch;
|
final int? currentPositionEpoch;
|
||||||
final ValueChanged<int>? onLiveSeek;
|
final ValueChanged<int>? onLiveSeek;
|
||||||
|
|
||||||
|
/// Relative live-TV skip callback (delta seconds); parent accumulates+debounces.
|
||||||
|
final ValueChanged<int>? onLiveSeekBy;
|
||||||
final VoidCallback? onJumpToLive;
|
final VoidCallback? onJumpToLive;
|
||||||
|
|
||||||
/// Whether to use dpad navigation for content strip (TV or keyboard nav mode)
|
/// 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.streamStartEpoch = 0,
|
||||||
this.currentPositionEpoch,
|
this.currentPositionEpoch,
|
||||||
this.onLiveSeek,
|
this.onLiveSeek,
|
||||||
|
this.onLiveSeekBy,
|
||||||
this.onJumpToLive,
|
this.onJumpToLive,
|
||||||
this.useDpadNavigation = false,
|
this.useDpadNavigation = false,
|
||||||
this.serverId,
|
this.serverId,
|
||||||
@@ -530,11 +534,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
|||||||
final isForward = key == LogicalKeyboardKey.arrowRight;
|
final isForward = key == LogicalKeyboardKey.arrowRight;
|
||||||
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0;
|
final effectiveMultiplier = event is KeyRepeatEvent ? _getSeekMultiplier() : 1.0;
|
||||||
|
|
||||||
// Live TV: epoch-based seeking via onLiveSeek
|
// Live TV: relative epoch-based seeking via the parent accumulator, which
|
||||||
if (_isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
|
// 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 stepSeconds = (widget.seekTimeSmall * effectiveMultiplier).clamp(1, 300).round();
|
||||||
final targetEpoch = widget.currentPositionEpoch! + (isForward ? stepSeconds : -stepSeconds);
|
widget.onLiveSeekBy!(isForward ? stepSeconds : -stepSeconds);
|
||||||
widget.onLiveSeek!(targetEpoch);
|
|
||||||
widget.onFocusActivity?.call();
|
widget.onFocusActivity?.call();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
onZoomReset: widget.onResetVideoZoom,
|
onZoomReset: widget.onResetVideoZoom,
|
||||||
currentPositionEpoch: widget.currentPositionEpoch,
|
currentPositionEpoch: widget.currentPositionEpoch,
|
||||||
onLiveSeek: widget.onLiveSeek,
|
onLiveSeek: widget.onLiveSeek,
|
||||||
|
onLiveSeekBy: widget.onLiveSeekBy,
|
||||||
);
|
);
|
||||||
if (result == KeyEventResult.handled) {
|
if (result == KeyEventResult.handled) {
|
||||||
_focusNode.requestFocus(); // self-heal focus
|
_focusNode.requestFocus(); // self-heal focus
|
||||||
@@ -315,6 +316,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
onZoomReset: widget.onResetVideoZoom,
|
onZoomReset: widget.onResetVideoZoom,
|
||||||
currentPositionEpoch: widget.currentPositionEpoch,
|
currentPositionEpoch: widget.currentPositionEpoch,
|
||||||
onLiveSeek: widget.onLiveSeek,
|
onLiveSeek: widget.onLiveSeek,
|
||||||
|
onLiveSeekBy: widget.onLiveSeekBy,
|
||||||
onSeekRequested: widget.onSeekRequested,
|
onSeekRequested: widget.onSeekRequested,
|
||||||
);
|
);
|
||||||
if (!event.logicalKey.isNavigationKey) return result;
|
if (!event.logicalKey.isNavigationKey) return result;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
|||||||
streamStartEpoch: widget.streamStartEpoch,
|
streamStartEpoch: widget.streamStartEpoch,
|
||||||
currentPositionEpoch: widget.currentPositionEpoch,
|
currentPositionEpoch: widget.currentPositionEpoch,
|
||||||
onLiveSeek: widget.onLiveSeek,
|
onLiveSeek: widget.onLiveSeek,
|
||||||
|
onLiveSeekBy: widget.onLiveSeekBy,
|
||||||
onJumpToLive: widget.onJumpToLive,
|
onJumpToLive: widget.onJumpToLive,
|
||||||
useDpadNavigation: useDpad,
|
useDpadNavigation: useDpad,
|
||||||
serverId: widget.metadata.serverId,
|
serverId: widget.metadata.serverId,
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
|
Future<void> _seekByOffset(Duration delta, {bool notifyCompletion = true}) async {
|
||||||
// Route through live seek callback for time-shifted live TV
|
// Route relative live-TV skips through the parent accumulator, which
|
||||||
if (widget.isLive && widget.onLiveSeek != null && widget.currentPositionEpoch != null) {
|
// coalesces a rapid burst into a single transcode re-open and computes the
|
||||||
widget.onLiveSeek!(widget.currentPositionEpoch! + delta.inSeconds);
|
// target from a stable base rather than the laggy live epoch (#1253).
|
||||||
|
if (widget.isLive && widget.onLiveSeekBy != null) {
|
||||||
|
widget.onLiveSeekBy!(delta.inSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final target = widget.player.state.position + delta;
|
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)
|
/// Current playback position as absolute epoch seconds (for live TV)
|
||||||
final int? currentPositionEpoch;
|
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;
|
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
|
/// Jump to live edge callback
|
||||||
final VoidCallback? onJumpToLive;
|
final VoidCallback? onJumpToLive;
|
||||||
|
|
||||||
@@ -306,6 +311,7 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
this.streamStartEpoch = 0,
|
this.streamStartEpoch = 0,
|
||||||
this.currentPositionEpoch,
|
this.currentPositionEpoch,
|
||||||
this.onLiveSeek,
|
this.onLiveSeek,
|
||||||
|
this.onLiveSeekBy,
|
||||||
this.onJumpToLive,
|
this.onJumpToLive,
|
||||||
this.isAmbientLightingEnabled = false,
|
this.isAmbientLightingEnabled = false,
|
||||||
this.onToggleAmbientLighting,
|
this.onToggleAmbientLighting,
|
||||||
|
|||||||
+1
-1
@@ -384,7 +384,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.3"
|
version: "4.0.3"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
name: plezy
|
name: plezy
|
||||||
description: "A beautiful Plex and Jellyfin client for Flutter"
|
description: "A beautiful Plex and Jellyfin client for Flutter"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.4.1+106
|
version: 2.4.1+107
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.12.0 <4.0.0"
|
sdk: ">=3.12.0 <4.0.0"
|
||||||
@@ -82,6 +82,7 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
fake_async: ^1.3.3
|
||||||
build_runner: ^2.13.0
|
build_runner: ^2.13.0
|
||||||
json_serializable: ^6.7.1
|
json_serializable: ^6.7.1
|
||||||
slang_build_runner: ^4.14.0
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user