mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-07 18:03:15 +00:00
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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user