feat(video): glide back to live edge at 1.05x when latency drifts (ios)
Engages above 8s measured latency, releases at 6s (hysteresis so noisy readings can't flap the speed). Speed is the only thing touched — never seeks or pauses. Disengages on pause/stall/ad break and before high-latency seek recovery so it can't compound with other recovery actors. Android is excluded: ExoPlayer rate-adjusts natively via LiveConfiguration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
50ef61ee0a
commit
e527b63217
2 changed files with 63 additions and 0 deletions
|
|
@ -111,6 +111,10 @@ abstract class NativeVideoStoreBase
|
|||
/// suspended rather than burning refresh attempts on ad-induced noise.
|
||||
var _isAdBreakActive = false;
|
||||
|
||||
/// True while playback runs at [VideoTimingConstants.catchUpPlaybackRate]
|
||||
/// to glide back toward the live edge. Not observable — no UI reflects it.
|
||||
var _isCatchingUp = false;
|
||||
|
||||
@readonly
|
||||
var _loading = true;
|
||||
|
||||
|
|
@ -271,6 +275,9 @@ abstract class NativeVideoStoreBase
|
|||
|
||||
_adBreakSub = _controller!.adBreakStream.listen((isActive) {
|
||||
_isAdBreakActive = isActive;
|
||||
// Latency polling is suspended during the pod, so an engaged
|
||||
// catch-up would otherwise stay at 1.05x for the whole break.
|
||||
if (isActive) _stopCatchUp();
|
||||
debugPrint(
|
||||
'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}',
|
||||
);
|
||||
|
|
@ -857,6 +864,9 @@ abstract class NativeVideoStoreBase
|
|||
!_loading &&
|
||||
!_userPaused) {
|
||||
_highLatencyCount++;
|
||||
// Seek/refresh intervention takes over from here — 1.05x right after
|
||||
// a seek to the live edge (2s headroom) would invite an instant stall.
|
||||
_stopCatchUp();
|
||||
// Still high after several interventions — the reading can't be
|
||||
// trusted (device clock skew) or the device genuinely can't keep up.
|
||||
// Either way, looping seeks/refreshes forever makes playback worse.
|
||||
|
|
@ -895,6 +905,8 @@ abstract class NativeVideoStoreBase
|
|||
});
|
||||
}
|
||||
|
||||
_maybeAdjustCatchUp(seconds);
|
||||
|
||||
// Skip chat sync when the player isn't actively progressing against
|
||||
// the live edge — a stall/pause leaves the live edge racing ahead and
|
||||
// would inflate syncedChatDelay unbounded.
|
||||
|
|
@ -902,6 +914,43 @@ abstract class NativeVideoStoreBase
|
|||
_chatLatencySync.report(seconds);
|
||||
}
|
||||
|
||||
/// Gently speeds playback when latency drifts above target so the player
|
||||
/// glides back toward the live edge. Never seeks or pauses — speed is the
|
||||
/// only thing touched, and only on iOS: ExoPlayer already rate-adjusts
|
||||
/// natively via its LiveConfiguration, so engaging here would fight it.
|
||||
void _maybeAdjustCatchUp(double latencySeconds) {
|
||||
if (!Platform.isIOS) return;
|
||||
if (_paused || _loading || _userPaused || _isStalled) {
|
||||
_stopCatchUp();
|
||||
return;
|
||||
}
|
||||
if (!_isCatchingUp &&
|
||||
latencySeconds >= VideoTimingConstants.catchUpEngageLatencySeconds) {
|
||||
_isCatchingUp = true;
|
||||
debugPrint(
|
||||
'NativeVideoStore: latency ${latencySeconds.toStringAsFixed(1)}s, '
|
||||
'catching up at ${VideoTimingConstants.catchUpPlaybackRate}x',
|
||||
);
|
||||
_controller?.setSpeed(VideoTimingConstants.catchUpPlaybackRate);
|
||||
} else if (_isCatchingUp &&
|
||||
latencySeconds <=
|
||||
VideoTimingConstants.catchUpDisengageLatencySeconds) {
|
||||
debugPrint(
|
||||
'NativeVideoStore: caught up to '
|
||||
'${latencySeconds.toStringAsFixed(1)}s, back to 1x',
|
||||
);
|
||||
_stopCatchUp();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns playback to 1x if a catch-up is engaged. Safe to call when the
|
||||
/// controller is already gone — a recreated controller starts at 1x.
|
||||
void _stopCatchUp() {
|
||||
if (!_isCatchingUp) return;
|
||||
_isCatchingUp = false;
|
||||
_controller?.setSpeed(1.0);
|
||||
}
|
||||
|
||||
void _scheduleOverlayHide([
|
||||
Duration delay = VideoTimingConstants.overlayAutoHide,
|
||||
]) {
|
||||
|
|
@ -1266,6 +1315,9 @@ abstract class NativeVideoStoreBase
|
|||
_paused = true;
|
||||
_isStalled = false;
|
||||
_isAdBreakActive = false;
|
||||
// Flag only — the controller this reset accompanies is recreated and
|
||||
// starts at 1x, so there's no speed to restore.
|
||||
_isCatchingUp = false;
|
||||
_restrictedStreamQualities = [];
|
||||
_isQualitySwitching = false;
|
||||
_isAudioOnlyMode = false;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,17 @@ class VideoTimingConstants {
|
|||
/// recoveries forever makes playback worse, not better.
|
||||
static const int maxHighLatencyRecoveries = 3;
|
||||
|
||||
/// Latency drift catch-up (iOS only — ExoPlayer does this natively via
|
||||
/// LiveConfiguration's min/maxPlaybackSpeed). Engage above 8s, glide at
|
||||
/// 1.05x, release at 6s: ~1s of correction per 20s of playback — slow
|
||||
/// enough to be inaudible and to never outrun the buffer. The 2s gap
|
||||
/// between engage and release is hysteresis so a noisy reading can't
|
||||
/// flap the speed. Gross drift (≥[highLatencyThresholdSeconds]) is owned
|
||||
/// by seek/refresh recovery, which runs before catch-up is considered.
|
||||
static const int catchUpEngageLatencySeconds = 8;
|
||||
static const int catchUpDisengageLatencySeconds = 6;
|
||||
static const double catchUpPlaybackRate = 1.05;
|
||||
|
||||
/// How long a quality switch may suppress stall classification before the
|
||||
/// flag expires. A resolution-only switch never interrupts playback, so no
|
||||
/// play/pause event arrives to clear it — without this backstop the flag
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue