mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-27 19:17:11 +00:00
feat(host-control-mode): buffer-aware deferred snap-back (#3)
Implements the snap-back robustly instead of deferring it to device testing — since the set of video sites is unbounded, "measure which players misbehave, then fix" doesn't scale; the snap-back must be safe on any player. On an involuntary pause/seek, if the player isn't ready to play (readyState<3 or seeking) we no longer snap immediately (which fights the buffer: seek → re-buffer → pause → … = stutter). Instead hcmDeferredSnapBack polls until the player can play (8s cap) and then snaps ONCE to the host's re-queried current position. A player can't play while buffering anyway, so waiting is also more correct, not just safer. Ready players still snap immediately (no regression). Guards: aborts if the user goes solo or is no longer a gated guest; single pending poll (no stacking); cleared in hcmReset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,15 +47,14 @@ to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
|
|||||||
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
||||||
heuristic in `hcmIsLive()`. (content.js)
|
heuristic in `hcmIsLive()`. (content.js)
|
||||||
|
|
||||||
### Pre-test self-audit (open, watch during device testing)
|
### Pre-test self-audit
|
||||||
- **EC-4/EC-1 snap-back thrash:** for *involuntary* events we still actively seek+play,
|
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
|
||||||
which can fight a buffering player for the duration of the stall. NOTE: "just let
|
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
|
||||||
catch-up re-sync" is NOT a valid fix — sync is event-driven, there is no continuous
|
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
|
||||||
catch-up loop, so skipping the snap-back risks leaving the guest stuck paused/behind
|
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
|
||||||
until the host next acts. Correct fix is a **buffer-aware deferred snap-back**: when
|
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
|
||||||
involuntary + buffering, wait for readyState>=3 (à la pollSeekReady) then seek+play
|
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
|
||||||
once — avoids thrash AND guarantees re-sync. Build after device testing reveals which
|
no longer a gated guest; single pending poll (no stacking).
|
||||||
players fire pause() vs only 'waiting'.
|
|
||||||
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
||||||
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
|
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
|
||||||
since background only sends it to gated guests.
|
since background only sends it to gated guests.
|
||||||
|
|||||||
+38
-2
@@ -112,6 +112,7 @@
|
|||||||
let hcmHostPeerId = null; // last known host peerId (room/host identity)
|
let hcmHostPeerId = null; // last known host peerId (room/host identity)
|
||||||
let hcmDesynced = false; // user chose to go solo
|
let hcmDesynced = false; // user chose to go solo
|
||||||
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
|
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
|
||||||
|
let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness
|
||||||
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
|
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
|
||||||
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
|
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
|
||||||
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
|
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
const HCM_USER_GESTURE_MS = 1000;
|
const HCM_USER_GESTURE_MS = 1000;
|
||||||
const HCM_BUFFERING_GRACE_MS = 1500;
|
const HCM_BUFFERING_GRACE_MS = 1500;
|
||||||
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
|
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
|
||||||
|
const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway
|
||||||
|
|
||||||
// Track genuine user input so we can tell a deliberate pause/seek from a
|
// Track genuine user input so we can tell a deliberate pause/seek from a
|
||||||
// player-/browser-initiated one. Capturing + passive so we never interfere.
|
// player-/browser-initiated one. Capturing + passive so we never interfere.
|
||||||
@@ -210,6 +212,34 @@
|
|||||||
tryOnce();
|
tryOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
|
||||||
|
// player buffering — and a player can't actually play while it's stalled. Snapping
|
||||||
|
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
|
||||||
|
// looks like stutter. Instead: if the player isn't ready, wait until it can play
|
||||||
|
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
|
||||||
|
// (re-queried, since the captured target may be stale by the time buffering ends).
|
||||||
|
// Player-agnostic on purpose — we can't enumerate every site, so this must be safe
|
||||||
|
// regardless of whether a given player fires 'pause' or only 'waiting'.
|
||||||
|
function hcmDeferredSnapBack() {
|
||||||
|
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
|
||||||
|
hcmDeferredSnapPending = true;
|
||||||
|
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
|
||||||
|
const poll = () => {
|
||||||
|
// Abort if the reason to snap is gone: user went solo, we're no longer a
|
||||||
|
// gated guest, or the video vanished.
|
||||||
|
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
|
||||||
|
const video = findVideo();
|
||||||
|
const ready = video && video.readyState >= 3 && !video.seeking;
|
||||||
|
if (ready || Date.now() >= deadline) {
|
||||||
|
hcmDeferredSnapPending = false;
|
||||||
|
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(poll, 300);
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
// Entry point: background told us our local action was blocked in host-only.
|
// Entry point: background told us our local action was blocked in host-only.
|
||||||
function hcmHandleBlocked(action, target) {
|
function hcmHandleBlocked(action, target) {
|
||||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||||
@@ -227,9 +257,14 @@
|
|||||||
// cooldown — the deliberate dialog path below must still go through,
|
// cooldown — the deliberate dialog path below must still go through,
|
||||||
// otherwise a second deliberate pause inside the cooldown window leaves
|
// otherwise a second deliberate pause inside the cooldown window leaves
|
||||||
// the user stuck paused with no UI (M-3).
|
// the user stuck paused with no UI (M-3).
|
||||||
if (Date.now() < hcmSnapBackCooldownUntil) return;
|
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
|
||||||
// Buffering/ads/throttle — silently re-sync, no dialog spam.
|
// Buffering/ads/throttle — silently re-sync, no dialog spam.
|
||||||
hcmSnapBackToHost(target);
|
const video = findVideo();
|
||||||
|
if (video && video.readyState >= 3 && !video.seeking) {
|
||||||
|
hcmSnapBackToHost(target); // ready now → snap immediately
|
||||||
|
} else {
|
||||||
|
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||||
@@ -349,6 +384,7 @@
|
|||||||
function hcmReset() {
|
function hcmReset() {
|
||||||
const wasDesynced = hcmDesynced;
|
const wasDesynced = hcmDesynced;
|
||||||
hcmDesynced = false;
|
hcmDesynced = false;
|
||||||
|
hcmDeferredSnapPending = false;
|
||||||
hcmRemoveDialog();
|
hcmRemoveDialog();
|
||||||
hcmRemoveBadge();
|
hcmRemoveBadge();
|
||||||
// If we were desynced, notify background so it stops reporting us as
|
// If we were desynced, notify background so it stops reporting us as
|
||||||
|
|||||||
Reference in New Issue
Block a user