Files
plezy/lib/services/live_seek_accumulator.dart
T

174 lines
6.0 KiB
Dart

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();
}
}