diff --git a/docs/host-control-mode-EDGECASES.md b/docs/host-control-mode-EDGECASES.md index 1233015..a6dfe8f 100644 --- a/docs/host-control-mode-EDGECASES.md +++ b/docs/host-control-mode-EDGECASES.md @@ -47,15 +47,14 @@ to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land. live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window heuristic in `hcmIsLive()`. (content.js) -### Pre-test self-audit (open, watch during device testing) -- **EC-4/EC-1 snap-back thrash:** for *involuntary* events we still actively seek+play, - which can fight a buffering player for the duration of the stall. NOTE: "just let - catch-up re-sync" is NOT a valid fix — sync is event-driven, there is no continuous - catch-up loop, so skipping the snap-back risks leaving the guest stuck paused/behind - until the host next acts. Correct fix is a **buffer-aware deferred snap-back**: when - involuntary + buffering, wait for readyState>=3 (à la pollSeekReady) then seek+play - once — avoids thrash AND guarantees re-sync. Build after device testing reveals which - players fire pause() vs only 'waiting'. +### Pre-test self-audit +- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred + snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't + ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap + ONCE to the host's re-queried position instead of repeatedly fighting the buffer. + Done defensively/player-agnostically — we can't enumerate every site, so this is safe + whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is + no longer a gated guest; single pending poll (no stacking). - ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED` as authoritative (adopts host-only/guest role) instead of re-checking local mode, since background only sends it to gated guests. diff --git a/extension/content.js b/extension/content.js index 2351333..78a8b9d 100644 --- a/extension/content.js +++ b/extension/content.js @@ -112,6 +112,7 @@ let hcmHostPeerId = null; // last known host peerId (room/host identity) let hcmDesynced = false; // user chose to go solo 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 hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window 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_BUFFERING_GRACE_MS = 1500; 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 // player-/browser-initiated one. Capturing + passive so we never interfere. @@ -210,6 +212,34 @@ 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. function hcmHandleBlocked(action, target) { // 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, // otherwise a second deliberate pause inside the cooldown window leaves // 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. - 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; } // Deliberate: offer the choice (Teleparty-style), default = snap back. @@ -349,6 +384,7 @@ function hcmReset() { const wasDesynced = hcmDesynced; hcmDesynced = false; + hcmDeferredSnapPending = false; hcmRemoveDialog(); hcmRemoveBadge(); // If we were desynced, notify background so it stops reporting us as