diff --git a/extension/background.js b/extension/background.js index 7e0f95e..c5a04ee 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1683,6 +1683,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { } emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode }); sendResponse({ status: 'ok' }); + } else if (message.type === 'GET_CONTROL_MODE') { + // content.js asks for current mode/role (e.g. injected after ROOM_DATA). + sendResponse({ controlMode, hostPeerId, amHost: amHost() }); + } else if (message.type === 'REQUEST_HOST_SYNC') { + // content.js resync: hand back the host's extrapolated current position. + sendResponse({ target: getHostSyncTarget() }); } else if (message.type === 'LEAVE_ROOM') { connectIntent = false; reconnectFailed = false; diff --git a/extension/content.js b/extension/content.js index 380ce54..2cffa76 100644 --- a/extension/content.js +++ b/extension/content.js @@ -102,6 +102,155 @@ } }); + // --- Host Control Mode (guest-side) --- + // When a room is in 'host-only' mode and we're a guest, a deliberate local + // pause/seek must not drive the room (background/server already drop it). Here + // we handle the *local* UX: snap back to the host's position, or — if the user + // really wants to — let them go solo (desync) with a resync escape hatch. + let hcmControlMode = 'everyone'; // mirror of room control mode + let hcmAmHost = false; // are we the host? + let hcmDesynced = false; // user chose to go solo + let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back + let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification + let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window + const HCM_USER_GESTURE_MS = 1000; + const HCM_BUFFERING_GRACE_MS = 1500; + const HCM_SNAP_BACK_COOLDOWN_MS = 1000; + + // Track genuine user input so we can tell a deliberate pause/seek from a + // player-/browser-initiated one. Capturing + passive so we never interfere. + const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); }; + document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true }); + document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true }); + + function hcmIsGuestGated() { + return hcmControlMode === 'host-only' && !hcmAmHost; + } + + // EC-9 intent classifier: only a *clearly deliberate* guest action triggers the + // dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab + // refocus, no recent gesture) is treated as involuntary. Bias intentional — + // in host-only the guest never broadcasts anyway, so this only tunes UX. + function hcmClassifyIntent() { + const video = findVideo(); + if (!video) return 'involuntary'; + if (video.duration === Infinity) 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'; + if (Date.now() < visibilityGraceUntil) return 'involuntary'; + if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary'; + return 'deliberate'; + } + + // 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 + hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS; + const video = findVideo(); + if (!video) return; + if (target && Number.isFinite(target.targetTime)) { + tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime }); + } + // Adopt the host's play/pause state (default: resume playing). + if (target && target.playbackState === 'paused') { + tryMediaAction(EVENTS.PAUSE); + } else { + tryMediaAction(EVENTS.PLAY); + } + reportLog('Host-only: snapped back to host position', 'info'); + } + + // Entry point: background told us our local action was blocked in host-only. + function hcmHandleBlocked(action, target) { + if (!hcmIsGuestGated()) return; + if (Date.now() < hcmSnapBackCooldownUntil) return; // EC-4 loop guard + if (hcmDesynced) return; // already solo, nothing to do + + const intent = hcmClassifyIntent(); + if (intent === 'live') return; // EC-15: leave the guest alone on live + if (intent === 'involuntary') { + // Buffering/ads/throttle — silently re-sync, no dialog spam. + hcmSnapBackToHost(target); + return; + } + // Deliberate: offer the choice (Teleparty-style), default = snap back. + 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'; + + function hcmRemoveDialog() { + const el = document.getElementById(HCM_UI_ID); + if (el) el.remove(); + } + + function hcmShowDesyncDialog(action, target) { + if (!document.body) { hcmSnapBackToHost(target); return; } + hcmRemoveDialog(); + const wrap = document.createElement('div'); + wrap.id = HCM_UI_ID; + 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); + + 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); + // EC-18: if the user ignores the prompt, default to staying in sync. + setTimeout(() => { if (!settled) stay(); }, 8000); + } + + function hcmEnterDesync() { + hcmDesynced = true; + reportLog('Host-only: you chose to watch on your own (desynced)', 'warn'); + hcmShowBadge(); + } + + function hcmExitDesync() { + hcmDesynced = false; + hcmRemoveBadge(); + // Resync: ask background for the host's current position and snap to it. + chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { + if (chrome.runtime.lastError) return; + if (res && res.target) hcmSnapBackToHost(res.target); + }); + reportLog('Host-only: resynced with the host', 'info'); + } + + 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'; + b.addEventListener('click', hcmExitDesync); + document.body.appendChild(b); + } + + function hcmRemoveBadge() { + const el = document.getElementById(HCM_BADGE_ID); + if (el) el.remove(); + } + + function hcmReset() { + hcmDesynced = false; + hcmRemoveDialog(); + hcmRemoveBadge(); + } + function reportLog(message, level = 'info') { chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {}); } @@ -356,6 +505,10 @@ reportLog(`Episode transition detected: "${newTitle}"`, 'info'); + // EC-12: a new episode dissolves any solo/desync state — the guest rejoins + // the room for the fresh content rather than staying stuck on the old one. + if (hcmDesynced) hcmReset(); + // Do NOT pause here. We notify background.js first. // Background checks the setting; if enabled it creates a lobby // and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on. @@ -538,6 +691,24 @@ return true; } + // Host Control Mode: room mode/role changed. + if (message.type === 'CONTROL_MODE') { + const wasGated = hcmIsGuestGated(); + hcmControlMode = message.controlMode || 'everyone'; + hcmAmHost = !!message.amHost; + // Leaving host-only, or becoming host, clears any guest-side state. + if (wasGated && !hcmIsGuestGated()) hcmReset(); + sendResponse({ ok: true }); + return true; + } + + // Host Control Mode: background blocked our local action — handle UX locally. + if (message.type === 'HOST_BLOCKED') { + hcmHandleBlocked(message.action, message.target || null); + sendResponse({ ok: true }); + return true; + } + // Background asks for an immediate state push (e.g. the first peer just // joined while we were solo) so the newcomer syncs without waiting. if (message.type === 'REQUEST_HEARTBEAT') { @@ -550,6 +721,20 @@ const { action, payload } = message; let actionCompleted = false; + // Host Control Mode: while watching on our own (desynced), don't apply + // host commands. Still ACK so the host's force-sync doesn't stall on us. + if (hcmDesynced) { + const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]; + if (soloIgnored.includes(action)) { + if (action === EVENTS.FORCE_SYNC_PREPARE) { + chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); + } else if (action !== EVENTS.FORCE_SYNC_EXECUTE) { + chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }).catch(() => {}); + } + return; + } + } + // Guard: Don't execute sync commands if peers are on different episodes. // Only active when autoSyncNextEpisode setting is enabled (default: on). // Only blocks when BOTH sides have parseable S01E01-style IDs that differ. @@ -924,6 +1109,9 @@ const handlePlay = () => reportEvent(EVENTS.PLAY); const handlePause = () => reportEvent(EVENTS.PAUSE); + // Host Control Mode: a 'waiting' (buffering) event opens a grace window so the + // pause it may trigger isn't misread as a deliberate guest action (EC-1). + const handleWaiting = () => { hcmBufferingUntil = Date.now() + HCM_BUFFERING_GRACE_MS; }; // Seek filtering: ignore HLS/DASH buffering micro-seeks. // Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing. @@ -994,13 +1182,15 @@ video.removeEventListener('pause', existing.pause); video.removeEventListener('seeked', existing.seeked); video.removeEventListener('loadeddata', existing.loadeddata); + if (existing.waiting) video.removeEventListener('waiting', existing.waiting); } - video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData }; + video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting }; video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('seeked', handleSeeked); video.addEventListener('loadeddata', handleLoadedData); + video.addEventListener('waiting', handleWaiting); video.dataset.koalaAttached = 'true'; lastVideoSrc = video.currentSrc || video.src || null; @@ -1136,4 +1326,12 @@ } }); + // Host Control Mode: fetch current room mode/role on injection (we may have + // been injected after ROOM_DATA already arrived, missing the broadcast). + chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => { + if (chrome.runtime.lastError || !res) return; + hcmControlMode = res.controlMode || 'everyone'; + hcmAmHost = !!res.amHost; + }); + })();