diff --git a/docs/host-control-mode-EDGECASES.md b/docs/host-control-mode-EDGECASES.md index 08df3f2..5a2f1c5 100644 --- a/docs/host-control-mode-EDGECASES.md +++ b/docs/host-control-mode-EDGECASES.md @@ -22,8 +22,32 @@ reliability and fight-loops (EC-4), and the desync/resync flow across players. T intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by reading the code, not yet by watching them behave on each site. -Deferred by decision (see §8): host grace period on disconnect (EC-10), live-DVR -detection beyond `duration === Infinity` (EC-15). +Deferred by decision (see §8): host grace period on disconnect (EC-10). + +### Pre-test self-audit (fixed) +- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC + click was gated server-side but the button stuck on "Playing"/disabled with no + feedback. Now the remote controls are locked (disabled + tooltip) for guests, with + backstop guards in the handlers. (popup.js) +- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline + `style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt + with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS + can't restyle/hide it. (content.js) +- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube + 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. Likely fix: on + involuntary, don't re-play — let catch-up re-sync — or use an exponential cooldown. + Decide after watching it on Netflix/YouTube. +- **Control-mode race at join:** a `HOST_BLOCKED` arriving before content.js learns the + mode is ignored (`hcmIsGuestGated()` false). Fix: trust `HOST_BLOCKED` as + authoritative (background only sends it to gated guests) instead of re-checking local + mode. Small change, deferred pending test. +- **Dialog/badge text is English-only** — content.js has no i18n loader; the in-page + strings aren't localized yet. Follow-up. --- diff --git a/extension/content.js b/extension/content.js index 2cffa76..6514556 100644 --- a/extension/content.js +++ b/extension/content.js @@ -134,7 +134,7 @@ function hcmClassifyIntent() { const video = findVideo(); if (!video) return 'involuntary'; - if (video.duration === Infinity) return 'live'; // EC-15: degrade, don't gate + if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate if (video.readyState < 3) return 'involuntary'; // buffering / not enough data if (video.seeking) return 'involuntary'; if (Date.now() < hcmBufferingUntil) return 'involuntary'; @@ -143,6 +143,18 @@ return 'deliberate'; } + // Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR + // (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its + // seekable window doesn't start at 0, which we use as the DVR signal. + function hcmIsLive(video) { + if (!Number.isFinite(video.duration)) return true; + try { + const s = video.seekable; + if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window + } catch (_e) { /* seekable may throw if empty */ } + return false; + } + // Snap the local player back to the host's current position/state. function hcmSnapBackToHost(target) { if (hcmDesynced) return; // user opted out — never yank them back automatically @@ -178,37 +190,49 @@ hcmShowDesyncDialog(action, target); } - // --- Minimal in-page UI (dialog + persistent desync badge) --- - const HCM_UI_ID = 'koalasync-hcm-dialog'; - const HCM_BADGE_ID = 'koalasync-hcm-badge'; + // --- In-page UI (dialog + persistent desync badge) --- + // Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML + // is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a + // Shadow DOM so the page's CSS can't restyle or hide our controls. + let hcmDialogHost = null; // shadow host element for the dialog + let hcmBadgeHost = null; // shadow host element for the persistent badge + + function hcmEl(tag, css, text) { + const el = document.createElement(tag); + if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP + if (text != null) el.textContent = text; + return el; + } function hcmRemoveDialog() { - const el = document.getElementById(HCM_UI_ID); - if (el) el.remove(); + if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; } } function hcmShowDesyncDialog(action, target) { if (!document.body) { hcmSnapBackToHost(target); return; } hcmRemoveDialog(); - const wrap = document.createElement('div'); - wrap.id = HCM_UI_ID; + const host = hcmEl('div', 'all:initial'); + const root = host.attachShadow({ mode: 'open' }); + + const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151'); wrap.setAttribute('role', 'dialog'); - wrap.style.cssText = 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151'; const verb = action === EVENTS.SEEK ? 'jumped' : 'paused'; - wrap.innerHTML = - '
KoalaSync · Host controls this room
' + - `
You ${verb} your player. Only the host can control the group. Keep watching together, or watch on your own?
` + - '
' + - `` + - `` + - '
'; - document.body.appendChild(wrap); + const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', 'KoalaSync · Host controls this room'); + const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', `You ${verb} your player. Only the host can control the group. Keep watching together, or watch on your own?`); + const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); + const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', 'Watch on my own'); + const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', 'Stay in sync'); + btnRow.append(soloBtn, stayBtn); + wrap.append(title, body, btnRow); + root.appendChild(wrap); + document.body.appendChild(host); + hcmDialogHost = host; let settled = false; const stay = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmSnapBackToHost(target); }; const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); }; - wrap.querySelector(`#${HCM_UI_ID}-stay`).addEventListener('click', stay); - wrap.querySelector(`#${HCM_UI_ID}-solo`).addEventListener('click', solo); + stayBtn.addEventListener('click', stay); + soloBtn.addEventListener('click', solo); // EC-18: if the user ignores the prompt, default to staying in sync. setTimeout(() => { if (!settled) stay(); }, 8000); } @@ -231,18 +255,19 @@ } function hcmShowBadge() { - if (document.getElementById(HCM_BADGE_ID) || !document.body) return; - const b = document.createElement('div'); - b.id = HCM_BADGE_ID; - b.style.cssText = 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px'; - b.innerHTML = '● Watching on your ownResync'; + if (hcmBadgeHost || !document.body) return; + const host = hcmEl('div', 'all:initial'); + const root = host.attachShadow({ mode: 'open' }); + const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px'); + b.append(hcmEl('span', null, '● Watching on your own'), hcmEl('span', 'text-decoration:underline', 'Resync')); b.addEventListener('click', hcmExitDesync); - document.body.appendChild(b); + root.appendChild(b); + document.body.appendChild(host); + hcmBadgeHost = host; } function hcmRemoveBadge() { - const el = document.getElementById(HCM_BADGE_ID); - if (el) el.remove(); + if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; } } function hcmReset() { diff --git a/extension/popup.js b/extension/popup.js index c773c9e..acf24e1 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -310,10 +310,18 @@ function toggleUIState(inRoom) { } // --- Host Control Mode UI --- +// True when we're a guest in a host-only room → remote-control buttons are locked. +let hcmGuestLocked = false; + function updateHostControlUI(controlMode, amHost, inRoom) { const card = elements.hostControlCard; if (!card) return; - if (!inRoom) { card.style.display = 'none'; return; } + if (!inRoom) { + card.style.display = 'none'; + hcmGuestLocked = false; + setRemoteControlsLocked(false); + return; + } card.style.display = 'block'; const hostOnly = controlMode === 'host-only'; if (elements.hostRoleBadge) { @@ -323,6 +331,21 @@ function updateHostControlUI(controlMode, amHost, inRoom) { if (elements.hostControlToggleRow) elements.hostControlToggleRow.style.display = amHost ? 'flex' : 'none'; if (elements.hostControlToggle) elements.hostControlToggle.checked = hostOnly; if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amHost && hostOnly) ? 'block' : 'none'; + + // A guest in host-only mode can't drive the room → lock the remote controls so + // clicks don't silently get gated (and leave the button stuck). + hcmGuestLocked = (!amHost && hostOnly); + setRemoteControlsLocked(hcmGuestLocked); +} + +function setRemoteControlsLocked(locked) { + [elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => { + if (!btn) return; + btn.disabled = locked; + btn.style.opacity = locked ? '0.5' : ''; + btn.style.cursor = locked ? 'not-allowed' : ''; + btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : ''; + }); } if (elements.hostControlToggle) { @@ -1488,6 +1511,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { }); elements.playBtn.addEventListener('click', () => { + if (hcmGuestLocked) return; // guest in host-only room — backstop if (!elements.targetTab.value) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; @@ -1506,7 +1530,7 @@ elements.playBtn.addEventListener('click', () => { }); // Safety reset: restore button after 2.5s in case no peers respond setTimeout(() => { - if (elements.playBtn.disabled) { + if (elements.playBtn.disabled && !hcmGuestLocked) { elements.playBtn.textContent = getMessage('BTN_PLAY'); elements.playBtn.disabled = false; } @@ -1514,6 +1538,7 @@ elements.playBtn.addEventListener('click', () => { }); elements.pauseBtn.addEventListener('click', () => { + if (hcmGuestLocked) return; // guest in host-only room — backstop if (!elements.targetTab.value) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; @@ -1532,7 +1557,7 @@ elements.pauseBtn.addEventListener('click', () => { }); // Safety reset: restore button after 2.5s in case no peers respond setTimeout(() => { - if (elements.pauseBtn.disabled) { + if (elements.pauseBtn.disabled && !hcmGuestLocked) { elements.pauseBtn.textContent = getMessage('BTN_PAUSE'); elements.pauseBtn.disabled = false; }