diff --git a/extension/background.js b/extension/background.js index c5a04ee..7197e1a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -74,6 +74,10 @@ const lastSeqBySender = {}; // senderId → last received seq (sta // --- Host Control Mode --- let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only' let hostPeerId = null; // peerId of the room host (creator / fallback) +// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed +// in heartbeats so the host's popup UI can show "Solo" instead of silently +// appearing un-ACK'd. +let hcmDesynced = false; function amHost() { return !!peerId && hostPeerId === peerId; } // Room-moving actions a guest may not initiate while in host-only mode. const HOST_ONLY_GATED_ACTIONS = [ @@ -281,6 +285,7 @@ function createPeerData(raw) { currentTime: raw.currentTime != null ? raw.currentTime : null, volume: raw.volume != null ? raw.volume : null, muted: raw.muted != null ? raw.muted : null, + desynced: raw.desynced === true, // HCM: peer is watching on their own lastHeartbeat: Date.now() }; } @@ -465,6 +470,10 @@ async function leaveRoomAfterIdleGrace(reason) { currentRoom = null; controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; + hcmDesynced = false; + // Notify content.js/popup BEFORE currentTabId is cleared so they can reset + // any stale guest-side HCM state (dialog/badge/desync) — H-2. + broadcastControlMode(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -915,7 +924,11 @@ function handleServerEvent(event, data) { // Host Control Mode (receiver-side backstop): in host-only mode, ignore // room-moving events from any non-host peer. The server already drops these, // so this covers old/buggy/modified clients that slipped through. + // Defensive: require a known hostPeerId — if the server ever sends host-only + // without a host (state inconsistency), gate-everyone would lock the host + // out of their own room (L-6). if (controlMode === CONTROL_MODES.HOST_ONLY && + hostPeerId && HOST_ONLY_GATED_ACTIONS.includes(event) && data.senderId && data.senderId !== hostPeerId) { addLog(`Ignored ${event} from non-host ${data.senderId} (host-only)`, 'warn'); @@ -1199,6 +1212,7 @@ function handleServerEvent(event, data) { peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle; peer.volume = data.volume !== undefined ? data.volume : peer.volume; peer.muted = data.muted !== undefined ? data.muted : peer.muted; + peer.desynced = data.desynced === true; const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity; const ignoreStatus = timeSinceReactive < 300; @@ -1553,6 +1567,10 @@ function leaveOldRoomIfSwitching(newRoomId) { currentRoom = null; controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; + hcmDesynced = false; + // Notify content.js/popup so they drop any guest-side HCM state from the + // previous room (badge/dialog/desync) — H-2/H-3. + broadcastControlMode(); if (storageInitialized) chrome.storage.session.set({ currentRoom: null }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -1689,6 +1707,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { } 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 === 'HCM_DESYNC_STATE') { + // content.js tells us whether the local user chose to watch on their own. + // Mirrored into heartbeats so the host's UI can show "Solo" instead of + // silently waiting for ACKs that will never come. + hcmDesynced = !!message.desynced; + sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { connectIntent = false; reconnectFailed = false; @@ -1699,6 +1723,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { currentRoom = null; controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; + hcmDesynced = false; + // Notify content.js/popup BEFORE currentTabId is cleared so they drop any + // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. + broadcastControlMode(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -1822,7 +1850,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Host Control Mode (sender-side): a guest in host-only mode must not // drive the room. Don't broadcast; hand the action back to content.js so // it can snap the local player back (and, in step 4, offer desync). - if (controlMode === CONTROL_MODES.HOST_ONLY && !amHost() && + // Defensive: require a known hostPeerId (L-6) — otherwise the actual + // host would gate themselves if state ever becomes inconsistent. + if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amHost() && HOST_ONLY_GATED_ACTIONS.includes(message.action)) { addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn'); if (sender.tab && sender.tab.id) { @@ -1974,7 +2004,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { markRoomUseful(); getSettings().then(settings => { - const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle }; + const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle, desynced: hcmDesynced }; const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0; if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload); diff --git a/extension/content.js b/extension/content.js index 387a907..4f1b385 100644 --- a/extension/content.js +++ b/extension/content.js @@ -109,10 +109,12 @@ // 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 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 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) const HCM_USER_GESTURE_MS = 1000; const HCM_BUFFERING_GRACE_MS = 1500; const HCM_SNAP_BACK_COOLDOWN_MS = 1000; @@ -147,6 +149,10 @@ // (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) { + // Don't trust duration before metadata has loaded (readyState >= 1) — + // otherwise pre-loaded videos report NaN and get misclassified as live, + // which suppresses the desync dialog (L-2). + if (video.readyState < 1) return false; if (!Number.isFinite(video.duration)) return true; try { const s = video.seekable; @@ -164,10 +170,12 @@ if (target && Number.isFinite(target.targetTime)) { tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime }); } - // Adopt the host's play/pause state (default: resume playing). + // Adopt the host's play/pause state — but ONLY if we actually know it. + // Defaulting to PLAY when the state is unknown would auto-resume a paused + // video against the host's real state (H-1). if (target && target.playbackState === 'paused') { tryMediaAction(EVENTS.PAUSE); - } else { + } else if (target && target.playbackState === 'playing') { tryMediaAction(EVENTS.PLAY); } reportLog('Host-only: snapped back to host position', 'info'); @@ -181,12 +189,16 @@ // (join race, EC-5) — otherwise we'd miss the dialog/snap-back. hcmControlMode = 'host-only'; hcmAmHost = false; - 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') { + // EC-4 loop guard: only the silent auto snap-back is suppressed by the + // 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; // Buffering/ads/throttle — silently re-sync, no dialog spam. hcmSnapBackToHost(target); return; @@ -201,6 +213,7 @@ // 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 + let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4) function hcmEl(tag, css, text) { const el = document.createElement(tag); @@ -210,6 +223,9 @@ } function hcmRemoveDialog() { + // Cancel any pending auto-stay timer so a replaced dialog's stale closure + // can't later remove its successor / snap to an outdated target (H-4). + if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; } if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; } } @@ -234,33 +250,77 @@ hcmDialogHost = host; let settled = false; - const stay = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmSnapBackToHost(target); }; + // Re-query the host's current position on click instead of using the + // potentially stale target captured at HOST_BLOCKED time (M-1). + const stay = () => { + if (settled) return; settled = true; hcmRemoveDialog(); + chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { + if (chrome.runtime.lastError) return; + if (res && res.target) hcmSnapBackToHost(res.target); + }); + }; const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); }; 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); + hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000); } function hcmEnterDesync() { hcmDesynced = true; reportLog('Host-only: you chose to watch on your own (desynced)', 'warn'); + // Notify background so it can relay our desynced state to the host via + // heartbeats — the host's UI then knows we're not following commands + // instead of appearing silently un-ACK'd. + chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {}); hcmShowBadge(); } function hcmExitDesync() { + const wasDesynced = hcmDesynced; hcmDesynced = false; hcmRemoveBadge(); + if (wasDesynced) { + chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); + } // 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); - }); + // Retry briefly if the host's state isn't known yet (e.g. they just paused + // and no heartbeat has propagated) — otherwise the user's "Resync" tap is + // a silent no-op and they think they're synced when they aren't (L-3). + let attempts = 0; + const tryResync = () => { + chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { + if (chrome.runtime.lastError || !res || !res.target) { + if (++attempts < 5) setTimeout(tryResync, 250); + else reportLog('Host-only: resync requested but host state unavailable', 'warn'); + return; + } + hcmSnapBackToHost(res.target); + }); + }; + tryResync(); reportLog('Host-only: resynced with the host', 'info'); } function hcmShowBadge() { - if (hcmBadgeHost || !document.body) return; + if (hcmBadgeHost) return; + if (!document.body) { + // Body not ready yet (very early injection). Defer until DOMReady, + // otherwise the desynced user silently never sees the badge (L-4). + if (!hcmBadgePending) { + hcmBadgePending = true; + const retry = () => { + hcmBadgePending = false; + if (hcmDesynced && !hcmBadgeHost) hcmShowBadge(); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } else { + setTimeout(retry, 50); + } + } + 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'); @@ -276,9 +336,16 @@ } function hcmReset() { + const wasDesynced = hcmDesynced; hcmDesynced = false; hcmRemoveDialog(); hcmRemoveBadge(); + // If we were desynced, notify background so it stops reporting us as + // desynced in heartbeats (otherwise the host's UI keeps showing the + // stale Solo badge until the next state change). + if (wasDesynced) { + chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); + } } function reportLog(message, level = 'info') { @@ -724,10 +791,15 @@ // Host Control Mode: room mode/role changed. if (message.type === 'CONTROL_MODE') { const wasGated = hcmIsGuestGated(); + const prevHostPeerId = hcmHostPeerId; hcmControlMode = message.controlMode || 'everyone'; hcmAmHost = !!message.amHost; - // Leaving host-only, or becoming host, clears any guest-side state. - if (wasGated && !hcmIsGuestGated()) hcmReset(); + hcmHostPeerId = message.hostPeerId || null; + // Reset guest-side state when leaving the gated state, OR when the + // host identity changes (room switch, host-leave fallback, missed + // teardown broadcast) — clears stale desync so a rejoin starts clean (H-3). + const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId; + if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset(); sendResponse({ ok: true }); return true; } @@ -752,14 +824,15 @@ 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. + // host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's + // force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK + // is intentional so the host's UI honestly reflects that we didn't apply + // the command (M-2); sending them would make the host think we're synced. 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; } diff --git a/extension/locales/de.json b/extension/locales/de.json index 927f0b1..cd21b0e 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.", "BADGE_HOST": "Host", "BADGE_GUEST": "Gast", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts", "NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden", "BTN_LEAVE_ROOM": "Raum verlassen", "LABEL_SELECT_VIDEO": "Video auswählen", diff --git a/extension/locales/en.json b/extension/locales/en.json index 5a32d63..358e6c8 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "The host controls playback for everyone.", "BADGE_HOST": "Host", "BADGE_GUEST": "Guest", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored", "NO_PEERS_CONNECTED": "No peers connected", "BTN_LEAVE_ROOM": "Leave Room", "LABEL_SELECT_VIDEO": "Select Video", diff --git a/extension/locales/es.json b/extension/locales/es.json index 2dece79..af95d76 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.", "BADGE_HOST": "Anfitrión", "BADGE_GUEST": "Invitado", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión", "NO_PEERS_CONNECTED": "Sin participantes conectados", "BTN_LEAVE_ROOM": "Salir de la sala", "LABEL_SELECT_VIDEO": "Seleccionar video", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index f0cc0e4..944912c 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.", "BADGE_HOST": "Hôte", "BADGE_GUEST": "Invité", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte", "NO_PEERS_CONNECTED": "Aucun membre connecté", "BTN_LEAVE_ROOM": "Quitter le salon", "LABEL_SELECT_VIDEO": "Choisir une vidéo", diff --git a/extension/locales/it.json b/extension/locales/it.json index 4cc5b53..c911ed4 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.", "BADGE_HOST": "Host", "BADGE_GUEST": "Ospite", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host", "NO_PEERS_CONNECTED": "Nessun partecipante connesso", "BTN_LEAVE_ROOM": "Lascia Stanza", "LABEL_SELECT_VIDEO": "Seleziona Video", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index d7a8777..9b5bcda 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。", "BADGE_HOST": "ホスト", "BADGE_GUEST": "ゲスト", + "BADGE_DESYNCED": "ソロ", + "TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています", "NO_PEERS_CONNECTED": "接続しているメンバーはいません", "BTN_LEAVE_ROOM": "ルームを退室", "LABEL_SELECT_VIDEO": "ビデオを選択", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index fc22f82..595db59 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.", "BADGE_HOST": "호스트", "BADGE_GUEST": "게스트", + "BADGE_DESYNCED": "단독", + "TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다", "NO_PEERS_CONNECTED": "연결된 참여자가 없습니다", "BTN_LEAVE_ROOM": "방 나가기", "LABEL_SELECT_VIDEO": "비디오 선택", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index 845be4d..4c808ac 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.", "BADGE_HOST": "Host", "BADGE_GUEST": "Gast", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host", "NO_PEERS_CONNECTED": "Geen deelnemers verbonden", "BTN_LEAVE_ROOM": "Kamer verlaten", "LABEL_SELECT_VIDEO": "Selecteer video", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index 31ee64a..b5f3480 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.", "BADGE_HOST": "Host", "BADGE_GUEST": "Gość", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta", "NO_PEERS_CONNECTED": "Brak połączonych uczestników", "BTN_LEAVE_ROOM": "Opuść pokój", "LABEL_SELECT_VIDEO": "Wybierz wideo", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index d33caec..a8b2e31 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.", "BADGE_HOST": "Anfitrião", "BADGE_GUEST": "Convidado", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião", "NO_PEERS_CONNECTED": "Sem participantes conectados", "BTN_LEAVE_ROOM": "Sair da sala", "LABEL_SELECT_VIDEO": "Selecionar vídeo", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 4e8833b..693b0ef 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.", "BADGE_HOST": "Anfitrião", "BADGE_GUEST": "Convidado", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião", "NO_PEERS_CONNECTED": "Nenhum participante ligado", "BTN_LEAVE_ROOM": "Sair da Sala", "LABEL_SELECT_VIDEO": "Selecionar Vídeo", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index a749902..c17d3fb 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.", "BADGE_HOST": "Ведущий", "BADGE_GUEST": "Гость", + "BADGE_DESYNCED": "Соло", + "TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего", "NO_PEERS_CONNECTED": "Нет подключенных участников", "BTN_LEAVE_ROOM": "Выйти из комнаты", "LABEL_SELECT_VIDEO": "Выбрать видео", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index 304ae7c..f075493 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.", "BADGE_HOST": "Sunucu", "BADGE_GUEST": "Misafir", + "BADGE_DESYNCED": "Solo", + "TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar", "NO_PEERS_CONNECTED": "Bağlı kimse yok", "BTN_LEAVE_ROOM": "Odadan Ayrıl", "LABEL_SELECT_VIDEO": "Video Seç", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 6f96dbd..40a5bc2 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.", "BADGE_HOST": "Ведучий", "BADGE_GUEST": "Гість", + "BADGE_DESYNCED": "Соло", + "TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого", "NO_PEERS_CONNECTED": "Немає підключених однорангових пристроїв", "BTN_LEAVE_ROOM": "Вийти з кімнати", "LABEL_SELECT_VIDEO": "Виберіть Відео", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index e5bac19..5ba0b55 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -50,6 +50,8 @@ "NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。", "BADGE_HOST": "主持人", "BADGE_GUEST": "访客", + "BADGE_DESYNCED": "单独", + "TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令", "NO_PEERS_CONNECTED": "没有对等点连接", "BTN_LEAVE_ROOM": "离开房间", "LABEL_SELECT_VIDEO": "选择视频", diff --git a/extension/popup.js b/extension/popup.js index acf24e1..70c4f99 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -346,6 +346,15 @@ function setRemoteControlsLocked(locked) { btn.style.cursor = locked ? 'not-allowed' : ''; btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : ''; }); + // When unlocking, also restore the default labels. The action handlers leave + // the text in a transitional state ("Playing..." / "Pausing...") and the 2.5s + // safety reset skips the refresh while we were guest-locked, so without this + // the button can be re-enabled with stale text after the host disables + // host-only (L-1). + if (!locked) { + if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play'; + if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause'; + } } if (elements.hostControlToggle) { @@ -650,6 +659,16 @@ function updatePeerList(peers) { header.appendChild(you); } + // Host Control Mode: show "Solo" badge for peers watching on their own. + if (typeof p === 'object' && p.desynced) { + const solo = document.createElement('span'); + solo.style.cssText = 'font-size:10px; color:#fff; background:#b45309; padding:2px 6px; border-radius:6px; font-weight:600;'; + const soloText = getMessage('BADGE_DESYNCED') || 'Solo'; + solo.textContent = soloText; + solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText; + header.appendChild(solo); + } + peerItem.appendChild(header); // Media Info diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 2aea846..1e87365 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -3,6 +3,7 @@ import http from 'node:http'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); @@ -23,6 +24,9 @@ function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith( async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); } function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } +// Test suite opens >10 connections/min — clear the IP connection counter so the +// connection rate limiter doesn't mask test failures (test-only, never at runtime). +function resetConnectionRate() { connectionCounts.clear(); clearRateLimitMaps(); } try { process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!'; @@ -57,6 +61,7 @@ try { s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left'); close(); + resetConnectionRate(); // --- Host Control Mode --- const hrid = 'host-'+Date.now(); @@ -77,10 +82,31 @@ try { s(h1,'pause',{currentTime:7}); await w(h2,'pause'); h1._m.length = h2._m.length = 0; - // Guest cannot change the control mode -> no broadcast + // desynced flag is relayed through PEER_STATUS heartbeats so the host's UI + // can show "Solo" for guests watching on their own. + s(h2,'peer_status',{status:'heartbeat',desynced:true,currentTime:42,playbackState:'playing'}); + let hbData = null; const hbStart = Date.now(); + while (Date.now()-hbStart < 600 && !hbData) { + for (let i=0;isetTimeout(r,30)); + } + assert.ok(hbData && hbData.desynced === true, 'desynced=true relayed in heartbeat'); + h1._m.length = h2._m.length = 0; + + // Guest cannot change the control mode -> host must NOT receive a broadcast. + // The rejected sender gets a unicast of the *actual* state so any optimistic + // UI reverts (H-5); assert both halves. s(h2,'set_control_mode',{controlMode:'everyone'}); let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; } assert.ok(guestSetBlocked, 'non-host cannot set control mode'); + let rejectSync = null; const rsStart = Date.now(); + while (Date.now()-rsStart < 600 && !rejectSync) { + for (let i=0;isetTimeout(r,30)); + } + assert.ok(rejectSync && rejectSync.controlMode==='host-only' && rejectSync.hostPeerId==='host1', + 'rejected sender is re-synced to actual state'); + h1._m.length = h2._m.length = 0; // Host leaves -> room falls back to 'everyone' and reassigns host to the guest s(h1,'leave_room',{}); @@ -92,6 +118,30 @@ try { assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host'); close(); + // --- M-4: rapid control-mode toggles are debounced per-room --- + const drid = 'debounce-'+Date.now(); + const db1 = await c(), db2 = await c(); + await j(db1, drid, 'dhost'); await j(db2, drid, 'dguest'); db1._m.length = db2._m.length = 0; + + // First toggle (everyone → host-only) goes through. + s(db1,'set_control_mode',{controlMode:'host-only'}); + await w(db1,'control_mode'); await w(db2,'control_mode'); + db1._m.length = db2._m.length = 0; + + // Immediate second toggle (host-only → everyone) should be debounced: + // broadcast goes to neither peer, but sender gets a re-sync unicast. + s(db1,'set_control_mode',{controlMode:'everyone'}); + let dGuestGotIt = false; try { await w(db2,'control_mode',600); } catch { dGuestGotIt = true; } + assert.ok(dGuestGotIt, 'rapid control-mode toggle is debounced (no broadcast)'); + let dSenderResync = null; const dsStart = Date.now(); + while (Date.now()-dsStart < 600 && !dSenderResync) { + for (let i=0;isetTimeout(r,30)); + } + assert.ok(dSenderResync && dSenderResync.controlMode==='host-only', + 'debounced toggle re-syncs sender to actual state'); + close(); + // --- Password room --- const prid = 'pw-'+Date.now(); const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret'); diff --git a/server/index.js b/server/index.js index c7552d7..548c74c 100644 --- a/server/index.js +++ b/server/index.js @@ -161,6 +161,11 @@ const HOST_ONLY_GATED_EVENTS = new Set([ EVENTS.EPISODE_LOBBY_CANCEL ]); +// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly +// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and +// from generating one broadcast per toggle across all peers. +const CONTROL_MODE_MIN_INTERVAL_MS = 500; + function log(type, message, details = '') { const debugLogging = process.env.DEBUG_LOGGING === '1'; const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP'; @@ -361,7 +366,8 @@ io.on('connection', (socket) => { lastActivity: Date.now(), // Host Control Mode: creator (first joiner) is the host. hostPeerId: peerId, - controlMode: CONTROL_MODES.EVERYONE + controlMode: CONTROL_MODES.EVERYONE, + lastControlModeChangeAt: 0 // M-4: per-room debounce for control-mode toggles }; rooms.set(roomId, room); createdByMe = true; @@ -516,6 +522,7 @@ io.on('connection', (socket) => { currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime, volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume, muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted, + desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false), lastSeen: Date.now() }); @@ -531,6 +538,7 @@ io.on('connection', (socket) => { mediaTitle: clamp(data.mediaTitle, 100), volume: clampNum(data.volume, 0, 1), muted: validBool(data.muted), + desynced: validBool(data.desynced), peerId: mapping.peerId, status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined, expectedTitle: clamp(data.expectedTitle, 100), @@ -612,12 +620,27 @@ io.on('connection', (socket) => { // Only the host may change the control mode. if (mapping.peerId !== room.hostPeerId) { log('AUTH', `Non-host ${mapping.peerId} tried to set control mode in ${mapping.roomId.substring(0, 3)}***`); + // Re-sync the sender to the actual room state so any optimistic UI + // (e.g. popup toggle) reverts — covers stale-local-amHost races (H-5). + socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId }); return; } if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop) + // M-4: per-room debounce — reject toggles faster than CONTROL_MODE_MIN_INTERVAL_MS. + // The host still gets the final state via the next legit change; this just + // kills the spam vector. + const now = Date.now(); + if (now - room.lastControlModeChangeAt < CONTROL_MODE_MIN_INTERVAL_MS) { + log('ROOM', `Control mode toggle debounced in ${mapping.roomId.substring(0, 3)}***`); + // Re-sync the sender so any optimistic UI matches the actual (unchanged) state. + socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId }); + return; + } + room.controlMode = mode; - room.lastActivity = Date.now(); + room.lastControlModeChangeAt = now; + room.lastActivity = now; io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, { controlMode: mode, hostPeerId: room.hostPeerId }); log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`); });