fix(extension): pre-test self-audit fixes for host control mode

- popup: lock remote-control buttons (Play/Pause/SYNC) for guests in host-only
  mode so clicks don't silently get gated and leave the button stuck; backstop
  guards in the handlers and the 2.5s safety reset respects the lock.
- content: rebuild the desync dialog + resync badge with the DOM API inside a
  Shadow DOM. innerHTML inline style="" attributes are stripped by strict
  style-src CSP (Netflix/YouTube/Disney+); CSSOM .style is CSP-safe and the
  shadow root isolates from page CSS.
- content: detect live-DVR in hcmIsLive() via a sliding seekable window
  (seekable.start(0) > 1), not just duration === Infinity (EC-15).

Records remaining audit items (snap-back thrash, join race, dialog i18n) in the
edge-case log for the device-testing pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-26 09:54:45 +02:00
parent b1ea280f0b
commit af9cf34f0b
3 changed files with 106 additions and 32 deletions
+26 -2
View File
@@ -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.
---
+52 -27
View File
@@ -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 =
'<div style="font-weight:600;margin-bottom:6px">KoalaSync · Host controls this room</div>' +
`<div style="margin-bottom:12px;color:#d1d5db">You ${verb} your player. Only the host can control the group. Keep watching together, or watch on your own?</div>` +
'<div style="display:flex;gap:8px;justify-content:flex-end">' +
`<button id="${HCM_UI_ID}-solo" style="background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer">Watch on my own</button>` +
`<button id="${HCM_UI_ID}-stay" style="background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600">Stay in sync</button>` +
'</div>';
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 = '<span>● Watching on your own</span><span style="text-decoration:underline">Resync</span>';
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() {
+28 -3
View File
@@ -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;
}