Target page without a video element
This page is intentionally selectable.
From e1daeecab113661ccadb11dbccb7306a9fdae3ce Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:06:46 +0200 Subject: [PATCH] fix(extension): harden target frame recovery and switching --- docs/CHANGELOG.md | 14 ++- extension/background.js | 65 +++++++----- extension/content.js | 46 ++++++--- extension/locales/de.json | 1 + extension/locales/en.json | 1 + extension/locales/es.json | 1 + extension/locales/fr.json | 1 + extension/locales/it.json | 1 + extension/locales/ja.json | 1 + extension/locales/ko.json | 1 + extension/locales/nl.json | 1 + extension/locales/pl.json | 1 + extension/locales/pt-BR.json | 1 + extension/locales/pt.json | 1 + extension/locales/ru.json | 1 + extension/locales/tr.json | 1 + extension/locales/uk.json | 1 + extension/locales/zh.json | 1 + extension/media-frame-target.js | 118 ++++++++++++++------- extension/media-frame-target.test.mjs | 126 +++++++++++++++++++++-- extension/popup.js | 61 ++++++----- extension/target-tab-lifecycle.test.mjs | 17 ++- tests/e2e/extension.spec.mjs | 131 +++++++++++++++++++++++- tests/e2e/fixtures/pages/no-video.html | 8 ++ website/build.cjs | 115 ++++++++++----------- 25 files changed, 537 insertions(+), 179 deletions(-) create mode 100644 tests/e2e/fixtures/pages/no-video.html diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 086ee3d..2804ef8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,14 @@ recovery, failed tab switches and short connection interruptions. tab visible across popup close/reopen and retries activation after a dynamic player frame changes, without restoring the removed `webNavigation` permission. +- **Extension: Target selection independent of video detection** — Exposes one + public `targetTabId`, keeps a selected no-video page active, and reports + activation readiness separately instead of treating a missing or ambiguous + player frame as a lost tab. +- **Extension: Defensive frame probing without `webNavigation`** — Isolates + rejected embedded-frame probes, retries known frame IDs individually, and + keeps visible-player discovery working when one child frame blocks an + `allFrames` sweep. - **Extension: Chat visibility persistence** — Remembers the user's manual open/closed state across popup reopen, content refresh and reconnect cycles. @@ -26,9 +34,9 @@ recovery, failed tab switches and short connection interruptions. - **Release gate** — Unit, server/WebSocket, locale, theme, lint, production dependency audit, Chrome/Firefox build, AMO validation and website build pass locally. -- **Browser E2E** — 36 extension and player lifecycle scenarios pass locally, - including popup close/reopen persistence and repeated cross-origin frame - switching. +- **Browser E2E** — 38 extension and player lifecycle scenarios pass locally, + including popup close/reopen persistence, selectable no-video pages, + rejected all-frame recovery and repeated cross-origin frame switching. --- diff --git a/extension/background.js b/extension/background.js index 5d07c48..4d97d25 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,7 +11,6 @@ import { createChatActivityStore } from './chat-activity.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import { MEDIA_FRAME_ACCESS_REQUIRED, - MEDIA_FRAME_AMBIGUOUS, listMediaFrameScriptTargets, resolveMediaContentTarget } from './media-frame-target.js'; @@ -2249,6 +2248,7 @@ function deactivateMediaFrameMonitor() { async function deactivateMediaFrameMonitors(tabId, contentTarget = null) { const targets = uniqueScriptTargets([ contentTarget?.scriptTarget, + ...(contentTarget?.monitorTargets || []), { tabId }, ...listMediaFrameScriptTargets(tabId) ].filter(Boolean)); @@ -2279,7 +2279,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni } : null) || { frameId: 0, documentId: null }; - resetAudioProcessingInTab(normalizedTabId, target); + await resetAudioProcessingInTab(normalizedTabId, target); await sendMessageToFrame( normalizedTabId, target.frameId, @@ -2346,6 +2346,7 @@ async function injectMediaFrameMonitors(tabId, contentTarget) { // selected document explicitly so its lifecycle monitor is guaranteed. const targets = uniqueScriptTargets([ contentTarget?.scriptTarget, + ...(contentTarget?.monitorTargets || []), { tabId }, ...listMediaFrameScriptTargets(tabId) ].filter(Boolean)); @@ -2403,8 +2404,7 @@ async function injectContentScript(tabId, { originPattern: error.originPattern }, requestAdded, error); } - if (error?.code === MEDIA_FRAME_AMBIGUOUS) throw error; - addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn'); + throw error; } const scriptTarget = contentTarget.scriptTarget; @@ -3238,27 +3238,27 @@ function leaveOldRoomIfSwitching(newRoomId) { } } -function resetAudioProcessingInTab(tabId, contentTarget = null) { - if (!tabId) return; +async function resetAudioProcessingInTab(tabId, contentTarget = null) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) return null; if (contentTarget) { - sendMessageToFrame( - tabId, + return sendMessageToFrame( + normalizedTabId, contentTarget.frameId, { action: 'RESET_AUDIO_PROCESSING' }, null, contentTarget.documentId - ).catch(() => {}); - return; + ).catch(() => null); } - if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { - sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); - return; + if (normalizedTabId === normalizeTabId(currentTabId)) { + return sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => null); } - chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); + return chrome.tabs.sendMessage(normalizedTabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => null); } async function applyAudioSettingsToTab(tabId, contentTarget = null) { - if (!tabId) return; + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) return; // Local-only: audioSettings are never read from storage.sync. const data = await chrome.storage.local.get(['audioSettings']); const message = { @@ -3266,20 +3266,20 @@ async function applyAudioSettingsToTab(tabId, contentTarget = null) { settings: data.audioSettings }; if (contentTarget) { - sendMessageToFrame( - tabId, + await sendMessageToFrame( + normalizedTabId, contentTarget.frameId, message, null, contentTarget.documentId - ).catch(() => {}); + ).catch(() => null); return; } - if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { - sendMessageToCurrentContent(message).catch(() => {}); + if (normalizedTabId === normalizeTabId(currentTabId)) { + await sendMessageToCurrentContent(message).catch(() => null); return; } - chrome.tabs.sendMessage(tabId, message).catch(() => {}); + await chrome.tabs.sendMessage(normalizedTabId, message).catch(() => null); } // --- Extension Message Listeners --- @@ -3379,21 +3379,30 @@ async function handleAsyncMessage(message, sender, sendResponse) { let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); // Distinguish the normal "not in a room" resting state from a real drop. if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle'; - const requestedTarget = normalizeTabId(requestedTargetTabId); - const activatingTarget = normalizeTabId(activeTargetActivation?.tabId); + const targetTabId = normalizeTabId(requestedTargetTabId) + ?? normalizeTabId(activeTargetActivation?.tabId) + ?? normalizeTabId(currentTabId); + const targetReady = targetTabId !== null + && normalizeTabId(currentTabId) === targetTabId + && !activeTargetActivation; + const targetActivationState = targetTabId === null + ? 'none' + : targetReady + ? 'ready' + : pendingTarget?.tabId === targetTabId + ? 'access_required' + : 'activating'; sendResponse({ status, peerId, peers: currentRoom ? currentRoom.peers : [], lastActionState, - targetTabId: currentTabId, + targetTabId, targetFrameId: currentTargetFrameId, targetDocumentId: currentTargetDocumentId, targetHasVideo: currentTargetHasVideo, - selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId), - requestedTargetTabId: requestedTarget, - requestedTargetTitle, - activatingTargetTabId: activatingTarget, + targetReady, + targetActivationState, pendingTargetTabId: pendingTarget?.tabId ?? null, pendingTargetHost: pendingTarget?.host ?? null, pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, diff --git a/extension/content.js b/extension/content.js index 9a41c01..ad0c412 100644 --- a/extension/content.js +++ b/extension/content.js @@ -867,19 +867,30 @@ return audioCtx; } - function closeAudioContext() { - if (audioCtx) { - audioCtx.close().catch(() => {}); - audioCtx = null; - } - audioChains = new WeakMap(); - currentAudioVideo = null; - } + function closeAudioContext() { + const closingContext = audioCtx; + if (audioCtx) { + audioCtx.close().catch(() => {}); + audioCtx = null; + } + if (window.__koalaSyncAudioRoute?.audioCtx === closingContext) { + delete window.__koalaSyncAudioRoute; + } + audioChains = new WeakMap(); + currentAudioVideo = null; + } - function setupAudioChain(videoEl) { - if (audioChains.has(videoEl)) return audioChains.get(videoEl); - const ctx = initAudioContext(); - if (!ctx) return null; + function setupAudioChain(videoEl) { + if (audioChains.has(videoEl)) return audioChains.get(videoEl); + const retainedRoute = window.__koalaSyncAudioRoute; + if (retainedRoute?.video === videoEl && retainedRoute.audioCtx && retainedRoute.chain) { + audioCtx = retainedRoute.audioCtx; + audioChains.set(videoEl, retainedRoute.chain); + currentAudioVideo = videoEl; + return retainedRoute.chain; + } + const ctx = initAudioContext(); + if (!ctx) return null; try { const src = ctx.createMediaElementSource(videoEl); @@ -907,8 +918,9 @@ limiter.release.value = 0.1; const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' }; - audioChains.set(videoEl, chain); - currentAudioVideo = videoEl; + audioChains.set(videoEl, chain); + currentAudioVideo = videoEl; + window.__koalaSyncAudioRoute = { video: videoEl, audioCtx: ctx, chain }; return chain; } catch (e) { reportLog(`Audio Processing setup failed: ${e.message}`, 'warn'); @@ -1238,7 +1250,7 @@ function handleRuntimeMessage(message, sender, sendResponse) { if (!message) return; if (message.type === 'TARGET_DEACTIVATE') { - destroyContentScript(); + destroyContentScript({ preserveAudioRoute: true }); sendResponse({ ok: true }); return true; } @@ -2062,7 +2074,7 @@ } } - function destroyContentScript() { + function destroyContentScript({ preserveAudioRoute = false } = {}) { if (destroyed) return; destroyed = true; @@ -2117,7 +2129,7 @@ hcmRemoveDialog(); hcmRemoveBadge(); bypassCurrentAudioProcessing(); - closeAudioContext(); + if (!preserveAudioRoute) closeAudioContext(); try { window.koalaSyncChatOverlay?.destroy?.(); } catch (_e) { /* invalidated chat context */ } try { chrome.storage.onChanged.removeListener(handleStorageChanged); } catch (_e) { /* invalidated context */ } diff --git a/extension/locales/de.json b/extension/locales/de.json index d9e218a..135d6e1 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet", "NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert", "DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.", + "DEBUG_TARGET_ACTIVATING": "Ziel-Tab ausgewählt; Video-Injection wird vorbereitet.", "DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.", "EMPTY_PEERS_TITLE": "Noch keine Teilnehmer", "EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen", diff --git a/extension/locales/en.json b/extension/locales/en.json index d9bc1ec..8babf44 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "started force sync", "NOTIF_FORCE_EXECUTE": "synchronized everyone", "DEBUG_NO_TAB": "No target tab selected.", + "DEBUG_TARGET_ACTIVATING": "Target tab selected; preparing video injection.", "DEBUG_COMM_FAIL": "Could not communicate with tab video.", "EMPTY_PEERS_TITLE": "No peers yet", "EMPTY_PEERS_HINT": "Share your invite link to get started", diff --git a/extension/locales/es.json b/extension/locales/es.json index f512f00..99d9b86 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada", "NOTIF_FORCE_EXECUTE": "ha sincronizado a todos", "DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.", + "DEBUG_TARGET_ACTIVATING": "Pestaña objetivo seleccionada; preparando la inyección de vídeo.", "DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.", "EMPTY_PEERS_TITLE": "Sin participantes aún", "EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index da60a9c..985fdb0 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée", "NOTIF_FORCE_EXECUTE": "synchronisé tout le monde", "DEBUG_NO_TAB": "Aucun onglet cible sélectionné.", + "DEBUG_TARGET_ACTIVATING": "Onglet cible sélectionné ; préparation de l’injection vidéo.", "DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.", "EMPTY_PEERS_TITLE": "Aucun membre pour l'instant", "EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer", diff --git a/extension/locales/it.json b/extension/locales/it.json index ca5cb8d..fafdd6a 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata", "NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti", "DEBUG_NO_TAB": "Nessuna scheda selezionata.", + "DEBUG_TARGET_ACTIVATING": "Scheda target selezionata; preparazione dell’iniezione video.", "DEBUG_COMM_FAIL": "Errore di comunicazione con il video.", "EMPTY_PEERS_TITLE": "Nessun partecipante", "EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 68ad760..48ba1e4 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "強制同期を開始しました", "NOTIF_FORCE_EXECUTE": "全員を同期しました", "DEBUG_NO_TAB": "対象のタブが選択されていません。", + "DEBUG_TARGET_ACTIVATING": "対象のタブを選択しました。動画スクリプトを準備しています。", "DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。", "EMPTY_PEERS_TITLE": "メンバーはまだいません", "EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index 2354f2f..ede88bd 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다", "NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다", "DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.", + "DEBUG_TARGET_ACTIVATING": "대상 탭이 선택되었습니다. 동영상 주입을 준비하는 중입니다.", "DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.", "EMPTY_PEERS_TITLE": "참여자 없음", "EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index 4cac4c9..dc9a581 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart", "NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd", "DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.", + "DEBUG_TARGET_ACTIVATING": "Doeltabblad geselecteerd; video-injectie wordt voorbereid.", "DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.", "EMPTY_PEERS_TITLE": "Nog geen deelnemers", "EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index abf7622..6479e06 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "wymusił synchronizację", "NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich", "DEBUG_NO_TAB": "Nie wybrano karty docelowej.", + "DEBUG_TARGET_ACTIVATING": "Wybrano kartę docelową; przygotowywanie wstrzyknięcia wideo.", "DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.", "EMPTY_PEERS_TITLE": "Brak uczestników", "EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 31645e1..be76600 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada", "NOTIF_FORCE_EXECUTE": "sincronizou todos", "DEBUG_NO_TAB": "Nenhuma aba selecionada.", + "DEBUG_TARGET_ACTIVATING": "Aba de destino selecionada; preparando a injeção de vídeo.", "DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.", "EMPTY_PEERS_TITLE": "Nenhum participante", "EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index e5b5eff..6b127dd 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada", "NOTIF_FORCE_EXECUTE": "sincronizou todos", "DEBUG_NO_TAB": "Nenhum separador selecionado.", + "DEBUG_TARGET_ACTIVATING": "Separador de destino selecionado; a preparar a injeção de vídeo.", "DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.", "EMPTY_PEERS_TITLE": "Nenhum participante", "EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 80db8cb..a2d4c44 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "запустил принудительный синхрон", "NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех", "DEBUG_NO_TAB": "Целевая вкладка не выбрана.", + "DEBUG_TARGET_ACTIVATING": "Целевая вкладка выбрана; подготовка внедрения видео.", "DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.", "EMPTY_PEERS_TITLE": "Участников пока нет", "EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index d9bc14b..afadf12 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı", "NOTIF_FORCE_EXECUTE": "herkesi eşitledi", "DEBUG_NO_TAB": "Hedef sekme seçilmedi.", + "DEBUG_TARGET_ACTIVATING": "Hedef sekme seçildi; video enjeksiyonu hazırlanıyor.", "DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.", "EMPTY_PEERS_TITLE": "Henüz kimse yok", "EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 12b14d7..0357dc9 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "почав примусову синхронізацію", "NOTIF_FORCE_EXECUTE": "синхронізував усіх", "DEBUG_NO_TAB": "Цільова вкладка не вибрана.", + "DEBUG_TARGET_ACTIVATING": "Цільову вкладку вибрано; готується впровадження відео.", "DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.", "EMPTY_PEERS_TITLE": "Учасників ще немає", "EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index b735cae..b87c784 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -190,6 +190,7 @@ "NOTIF_FORCE_PREPARE": "开始强制同步", "NOTIF_FORCE_EXECUTE": "同步所有人", "DEBUG_NO_TAB": "未选择目标选项卡。", + "DEBUG_TARGET_ACTIVATING": "已选择目标标签页;正在准备注入视频脚本。", "DEBUG_COMM_FAIL": "无法与标签视频通信。", "EMPTY_PEERS_TITLE": "还没有同行", "EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用", diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 1af799e..167fd30 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -1,6 +1,4 @@ export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required'; -export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous'; - const MIN_PLAYER_FRAME_AREA = 320 * 180; const MIN_PLAYER_ASPECT_RATIO = 1.15; const MAX_PLAYER_ASPECT_RATIO = 2.6; @@ -407,16 +405,10 @@ function accessRequiredError(access) { return error; } -function ambiguousFrameError() { - const error = new Error('The active embedded video frame could not be identified safely'); - error.code = MEDIA_FRAME_AMBIGUOUS; - return error; -} - -function contentTarget(tabId, selected) { +function contentTarget(tabId, selected, monitorTargets = null) { const frameId = normalizeFrameId(selected?.frameId); const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null; - return { + const target = { frameId, documentId, frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null, @@ -425,16 +417,57 @@ function contentTarget(tabId, selected) { ? { tabId, documentIds: [documentId] } : (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] }) }; + if (Array.isArray(monitorTargets) && monitorTargets.length > 0) { + target.monitorTargets = monitorTargets; + } + return target; } export function listMediaFrameScriptTargets(tabId) { return [{ tabId, allFrames: true }]; } +function listFrameProbeTargets(tabId, embeddedFrameCount = 0) { + // Chromium can reject one all-frames executeScript call when a single + // child frame is browser-owned or temporarily unavailable. Frame IDs are + // not exposed without webNavigation, so probe a bounded range individually + // after the top frame tells us that embedded frames exist. Each rejected + // probe is isolated and cannot hide the other frames. + const maxFrameId = Math.min(64, Math.max(8, (embeddedFrameCount * 4) + 4)); + return Array.from({ length: maxFrameId }, (_, frameId) => ({ + tabId, + frameIds: [frameId] + })); +} + +function frameScriptTarget(tabId, entry) { + const frameId = normalizeFrameId(entry?.frameId); + return typeof entry?.documentId === 'string' && entry.documentId + ? { tabId, documentIds: [entry.documentId] } + : (frameId === 0 ? { tabId, frameIds: [0] } : { tabId, frameIds: [frameId] }); +} + +function mergeFrameResults(...groups) { + const merged = new Map(); + for (const group of groups) { + for (const entry of Array.isArray(group) ? group : []) { + if (!Number.isInteger(entry?.frameId)) continue; + // A frame ID identifies the current slot. If its document changed + // between the broad probe and the exact probe, the exact result + // must replace the stale document rather than create a duplicate + // candidate that can trigger a false ambiguity. + const key = `frame:${entry.frameId}`; + merged.set(key, entry); + } + } + return Array.from(merged.values()); +} + async function executeInAccessibleFrames(chromeApi, targets, func, args) { const settled = await Promise.all(targets.map(async target => { try { - return await chromeApi.scripting.executeScript({ target, func, args }); + const result = await chromeApi.scripting.executeScript({ target, func, args }); + return Array.isArray(result) ? result : []; } catch { return []; } @@ -449,34 +482,44 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } = {}) { let fallback = null; let missingAccess = null; - let ambiguous = false; + let monitorTargets = []; for (let attempt = 0; attempt < attempts; attempt++) { - const scriptTargets = listMediaFrameScriptTargets(tabId); - let results = await executeInAccessibleFrames( + const topResults = await executeInAccessibleFrames( chromeApi, - scriptTargets, + [{ tabId, frameIds: [0] }], inspectMediaFrame, [null] ); - if (results.length === 0) { - try { - results = await chromeApi.scripting.executeScript({ - target: { tabId }, - func: inspectMediaFrame, - args: [null] - }); - } catch { - return contentTarget(tabId, null); - } + const allFrameResults = await executeInAccessibleFrames( + chromeApi, + listMediaFrameScriptTargets(tabId), + inspectMediaFrame, + [null] + ); + let results = mergeFrameResults(topResults, allFrameResults); + const embeddedFrameCount = topResults.reduce( + (count, entry) => Math.max(count, entry?.result?.embeddedFrames?.length || 0), + 0 + ); + if (embeddedFrameCount > 0) { + const individuallyProbed = await executeInAccessibleFrames( + chromeApi, + listFrameProbeTargets(tabId, embeddedFrameCount), + inspectMediaFrame, + [null] + ); + results = mergeFrameResults(results, individuallyProbed); } + if (results.length === 0) return contentTarget(tabId, null); if (results.length > 1) { const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`; + const frameTargets = results.map(entry => frameScriptTarget(tabId, entry)); try { await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, installParentFrameVisibilityProbe, [token] ); @@ -484,7 +527,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { for (let pass = 0; pass < 4; pass++) { await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, dispatchParentFrameVisibilityProbe, [token] ); @@ -492,29 +535,29 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } const inspected = await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, inspectMediaFrame, [token] ); - if (inspected.length > 0) results = inspected; + if (inspected.length > 0) results = mergeFrameResults(results, inspected); } catch { // Initial results remain usable, but equally-ranked unknown // frames will be rejected below rather than guessed. } } + // Rebuild these after the visibility refresh so a frame navigation that + // replaced its document ID cannot leave a stale monitor target behind. + monitorTargets = results.map(entry => frameScriptTarget(tabId, entry)); const selected = selectMediaFrame(results); - const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true - && entry.result.parentFrameVisible !== false); const currentMissingAccess = findMissingPlayerAccess(results); missingAccess = currentMissingAccess; fallback = selected; - ambiguous = !selected && videoCandidates.length > 1; if (selected) { if (selected.result.bestVideo.hasSource && selected.result.bestVideo.rendered && !shouldPreferMissingAccess(currentMissingAccess, selected)) { - return contentTarget(tabId, selected); + return contentTarget(tabId, selected, monitorTargets); } } @@ -524,7 +567,10 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } if (missingAccess) throw accessRequiredError(missingAccess); - if (fallback) return contentTarget(tabId, fallback); - if (ambiguous) throw ambiguousFrameError(); - return contentTarget(tabId, null); + if (fallback) return contentTarget(tabId, fallback, monitorTargets); + // Selecting a tab must not depend on video detection. A page can be a + // valid target before its player exists, and an ambiguous frame layout is + // recoverable through the injected lifecycle monitor. Keep the top-frame + // target active instead of discarding the user's selection. + return contentTarget(tabId, null, monitorTargets); } diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index fd534a9..0f91c2d 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { MEDIA_FRAME_ACCESS_REQUIRED, - MEDIA_FRAME_AMBIGUOUS, inspectMediaFrame, resolveMediaContentTarget, selectMediaFrame @@ -128,12 +127,16 @@ describe('cross-origin media-frame targeting', () => { documentId: 'document-8', frameUrl: 'https://player-8.example/embed', hasVideo: true, - scriptTarget: { tabId: 42, documentIds: ['document-8'] } + scriptTarget: { tabId: 42, documentIds: ['document-8'] }, + monitorTargets: [ + { tabId: 42, documentIds: ['document-0'] }, + { tabId: 42, documentIds: ['document-8'] } + ] }); const visibilityDispatches = executeScript.mock.calls.filter(([options]) => ( options.func?.name === 'dispatchParentFrameVisibilityProbe' )); - expect(visibilityDispatches).toHaveLength(4); + expect(visibilityDispatches.length).toBeGreaterThanOrEqual(4); }); it('keeps the top target inactive when the only discovered video is hidden', async () => { @@ -151,7 +154,11 @@ describe('cross-origin media-frame targeting', () => { documentId: null, frameUrl: null, hasVideo: false, - scriptTarget: { tabId: 42 } + scriptTarget: { tabId: 42 }, + monitorTargets: [ + { tabId: 42, documentIds: ['document-0'] }, + { tabId: 42, documentIds: ['document-6'] } + ] }); }); @@ -165,9 +172,108 @@ describe('cross-origin media-frame targeting', () => { )).resolves.toMatchObject({ frameId: 8, hasVideo: true, - scriptTarget: { tabId: 42, documentIds: ['document-8'] } + scriptTarget: { tabId: 42, documentIds: ['document-8'] }, + monitorTargets: expect.arrayContaining([ + { tabId: 42, documentIds: ['document-8'] } + ]) + }); + expect(executeScript.mock.calls.some(([options]) => ( + options.target?.tabId === 42 && options.target?.allFrames === true + ))).toBe(true); + }); + + it('isolates a rejected all-frame sweep and still finds an embedded player', async () => { + const top = frame(0, { + href: 'https://anime.example/watch', + origin: 'https://anime.example', + bestVideo: null, + videoCount: 0, + embeddedFrames: [{ + href: 'https://player.example/embed', + origin: 'https://player.example', + area: 860 * 490, + width: 860, + height: 490, + visible: true, + mediaHint: true + }] + }); + const player = frame(1, { + href: 'https://player.example/embed' + }); + const executeScript = vi.fn(async options => { + if (options.target?.allFrames === true) throw new Error('one frame rejected'); + const frameId = options.target?.frameIds?.[0]; + if (options.func?.name === 'inspectMediaFrame') { + if (frameId === 0) return [top]; + if (frameId === 1) return [player]; + return []; + } + return []; + }); + + await expect(resolveMediaContentTarget( + { scripting: { executeScript } }, + 47, + { attempts: 1, probeDelayMs: 0 } + )).resolves.toMatchObject({ + frameId: 1, + documentId: 'document-1', + hasVideo: true, + scriptTarget: { tabId: 47, documentIds: ['document-1'] }, + monitorTargets: expect.arrayContaining([ + { tabId: 47, documentIds: ['document-0'] }, + { tabId: 47, documentIds: ['document-1'] } + ]) + }); + }); + + it('sweeps individual frame IDs when an all-frame result is partial', async () => { + const top = frame(0, { + href: 'https://anime.example/watch', + origin: 'https://anime.example', + bestVideo: null, + videoCount: 0, + embeddedFrames: [{ + href: 'https://player.example/embed', + origin: 'https://player.example', + area: 860 * 490, + width: 860, + height: 490, + visible: true, + mediaHint: true + }] + }); + const partialFrame = frame(2, { bestVideo: null, videoCount: 0 }); + const player = frame(3, { href: 'https://player.example/embed' }); + const executeScript = vi.fn(async options => { + if (options.target?.allFrames === true) { + return options.func?.name === 'inspectMediaFrame' ? [top, partialFrame] : []; + } + const frameId = options.target?.frameIds?.[0]; + if (options.func?.name === 'inspectMediaFrame') { + if (frameId === 0) return [top]; + if (frameId === 3) return [player]; + return []; + } + return []; + }); + + await expect(resolveMediaContentTarget( + { scripting: { executeScript } }, + 48, + { attempts: 1, probeDelayMs: 0 } + )).resolves.toMatchObject({ + frameId: 3, + documentId: 'document-3', + hasVideo: true, + scriptTarget: { tabId: 48, documentIds: ['document-3'] }, + monitorTargets: expect.arrayContaining([ + { tabId: 48, documentIds: ['document-0'] }, + { tabId: 48, documentIds: ['document-2'] }, + { tabId: 48, documentIds: ['document-3'] } + ]) }); - expect(executeScript.mock.calls[0][0].target).toEqual({ tabId: 42, allFrames: true }); }); it('does not trust parent visibility from an older probe token', () => { @@ -432,7 +538,7 @@ describe('cross-origin media-frame targeting', () => { )).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } }); }); - it('reports ambiguity rather than controlling an arbitrary equal player', async () => { + it('keeps the tab target active when equal player frames are ambiguous', async () => { const results = [ frame(3, { parentFrameVisible: null }), frame(4, { parentFrameVisible: null }) @@ -442,6 +548,10 @@ describe('cross-origin media-frame targeting', () => { { scripting: { executeScript } }, 45, { attempts: 1, probeDelayMs: 0 } - )).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS }); + )).resolves.toMatchObject({ + frameId: 0, + hasVideo: false, + scriptTarget: { tabId: 45 } + }); }); }); diff --git a/extension/popup.js b/extension/popup.js index 1afee7a..0f4e5f1 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -468,12 +468,12 @@ async function init() { // Keep a denied selection visible while Chrome waits for the user // to grant access; it becomes active automatically after approval. - await populateTabs(res.peers, getSelectedTargetTabId(res)); + await populateTabs(res.peers, res.targetTabId); // Render lobby status if active if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); - if (res.status === 'connected' && !getSelectedTargetTabId(res) && localData.roomId) { + if (res.status === 'connected' && normalizeTabId(res.targetTabId) === null && localData.roomId) { const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]'); if (syncTabBtn) syncTabBtn.click(); showSelectVideoHint(); @@ -647,15 +647,6 @@ function handleTargetTabResponse(response) { return false; } -function getSelectedTargetTabId(status) { - return status?.selectedTargetTabId - ?? status?.requestedTargetTabId - ?? status?.targetTabId - ?? status?.pendingTargetTabId - ?? status?.activatingTargetTabId - ?? null; -} - function selectTargetTab(tabId, tabTitle) { return new Promise(resolve => { chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => { @@ -691,7 +682,7 @@ async function refreshTargetAccessState() { } await populateTabs( status.peers, - getSelectedTargetTabId(status) + status.targetTabId ); } @@ -1231,14 +1222,14 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides()); const isFilterActive = data.filterNoise !== false; - let currentTargetTabId = providedTargetTabId; + let currentTargetTabId = normalizeTabId(providedTargetTabId); if (currentTargetTabId === null) { const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); if (chrome.runtime.lastError) { if (populateTabsToken !== token) return; currentTargetTabId = null; } else { - currentTargetTabId = getSelectedTargetTabId(status); + currentTargetTabId = status?.targetTabId ?? null; } } @@ -1269,7 +1260,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { const filteredTabs = tabs.filter(tab => { if (!tab.url || tab.url.startsWith('chrome://')) return false; - if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) { + if (isFilterActive && currentTargetTabId !== null && tab.id !== currentTargetTabId) { if (isUrlBlacklisted(tab.url, blacklistDomains)) return false; } return true; @@ -1320,7 +1311,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { // Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically const options = Array.from(elements.targetTab.options); const placeholder = options.shift(); - const currentTabId = currentTargetTabId ? parseInt(currentTargetTabId) : null; + const currentTabId = currentTargetTabId; options.sort((a, b) => { const aId = parseInt(a.value); @@ -1340,7 +1331,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { elements.targetTab.appendChild(placeholder); options.forEach(opt => elements.targetTab.appendChild(opt)); - if (currentTargetTabId) { + if (currentTargetTabId !== null) { elements.targetTab.value = currentTargetTabId; } else { const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:')); @@ -1754,7 +1745,7 @@ if (elements.langSelector) { } else { hideSiteAccessNotice(); } - await populateTabs(res.peers, getSelectedTargetTabId(res)); + await populateTabs(res.peers, res.targetTabId); if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { applyConnectionStatus('disconnected'); @@ -2103,7 +2094,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.disabled = true; const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - if (chrome.runtime.lastError || !status || !status.targetTabId) { + if (chrome.runtime.lastError || !status || status.targetReady !== true || normalizeTabId(status.targetTabId) === null) { elements.forceSyncBtn.disabled = false; return; } @@ -2169,7 +2160,8 @@ elements.forceSyncBtn.addEventListener('click', async () => { resolve(false); return; } - resolve(normalizeTabId(currentStatus?.targetTabId) === tabId); + resolve(currentStatus?.targetReady === true + && normalizeTabId(currentStatus?.targetTabId) === tabId); }); }); @@ -2588,8 +2580,9 @@ elements.copyLogs.addEventListener('click', () => { logs = logs || []; history = history || []; - const videoPromise = (status && status.targetTabId) - ? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve)) + const targetTabId = normalizeTabId(status.targetTabId); + const videoPromise = (targetTabId !== null && status.targetReady === true) + ? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: targetTabId }, resolve)) : Promise.resolve(null); videoPromise.then(rawVideo => { @@ -2609,6 +2602,11 @@ elements.copyLogs.addEventListener('click', () => { lines.push(`- **User Agent:** ${userAgent}`); lines.push(''); + lines.push('## Target'); + lines.push(`- **Target Tab ID:** ${targetTabId ?? 'none'}`); + lines.push(`- **Activation:** ${safe(status.targetActivationState, 'unknown')}`); + lines.push(''); + // ── Tab ── if (rawVideo) { lines.push('## Tab'); @@ -2661,7 +2659,9 @@ elements.copyLogs.addEventListener('click', () => { // ── Video ── lines.push('## Video'); if (!rawVideo) { - lines.push('- *No tab selected / communication failed*'); + lines.push(targetTabId !== null + ? '- *Target tab selected; video communication is not ready yet*' + : '- *No tab selected / communication failed*'); } else if (!vs.found) { lines.push('- **Found:** \u274C NO VIDEO ELEMENT'); if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`); @@ -2801,11 +2801,24 @@ function refreshDebugInfo() { if (!devTab || devTab.style.display === 'none') return; chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { - if (!res || !res.targetTabId) { + if (!res || normalizeTabId(res.targetTabId) === null) { + if (res?.targetActivationState === 'activating' || res?.targetActivationState === 'access_required') { + if (elements.videoDebug) { + elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING'); + } + return; + } if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB'); return; } + if (res.targetReady !== true) { + if (elements.videoDebug) { + elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING'); + } + return; + } + // Request direct state from the content script via background chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => { if (!state || (!state.found && state.error)) { diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 7f4770b..4794389 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -34,8 +34,12 @@ describe('target tab lifecycle', () => { it('fully deactivates old and superseded target injections', () => { expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }"); expect(backgroundSource).toContain('target.documentId'); + expect(backgroundSource).toContain('await resetAudioProcessingInTab(normalizedTabId, target);'); + expect(backgroundSource).toContain("{ action: 'RESET_AUDIO_PROCESSING' }"); expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6); expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')"); + expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });'); + expect(contentSource).toContain('window.__koalaSyncAudioRoute'); expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'"); }); @@ -71,12 +75,14 @@ describe('target tab lifecycle', () => { it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => { expect(backgroundSource).toContain('contentTarget?.scriptTarget'); + expect(backgroundSource).toContain('...(contentTarget?.monitorTargets || [])'); expect(backgroundSource).toContain('function uniqueScriptTargets(targets)'); expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()'); expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor'); expect(backgroundSource).toContain('isMissingContentReceiverError(error)'); expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })'); expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'"); + expect(backgroundSource).not.toContain('Media frame probe fell back to the top frame'); }); it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => { @@ -101,10 +107,13 @@ describe('target tab lifecycle', () => { expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);'); expect(backgroundSource).toContain('pendingRequestedActivationCount > 0'); expect(backgroundSource).toContain('await retryRequestedTarget();'); - expect(backgroundSource).toContain('selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId)'); + expect(backgroundSource).toContain('targetTabId,'); + expect(backgroundSource).toContain('targetReady'); + expect(backgroundSource).toContain("targetActivationState"); expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);'); - expect(popupSource).toContain('function getSelectedTargetTabId(status)'); - expect(popupSource).toContain('await populateTabs(res.peers, getSelectedTargetTabId(res));'); + expect(popupSource).not.toContain('getSelectedTargetTabId'); + expect(popupSource).toContain('await populateTabs(res.peers, res.targetTabId);'); + expect(popupSource).toContain('res.targetReady !== true'); }); it('serializes content commands and coalesces target refreshes', () => { @@ -118,7 +127,7 @@ describe('target tab lifecycle', () => { }); it('tears down every persistent content-script resource', () => { - expect(contentSource).toContain('function destroyContentScript()'); + expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})'); expect(contentSource).toContain('observer.disconnect()'); expect(contentSource).toContain('keepAlivePort.disconnect()'); expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);'); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index b61aa9d..e13e07a 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -54,6 +54,62 @@ async function getExtensionState(context, extensionId, message) { )); } +async function setAudioSettings(context, extensionId, settings) { + return withExtensionPage(context, extensionId, page => page.evaluate( + value => chrome.storage.local.set({ audioSettings: value }), + settings + )); +} + +async function getAudioRouteState(context, extensionId, pageUrl) { + return withExtensionPage(context, extensionId, page => page.evaluate(async url => { + const [tab] = await chrome.tabs.query({ url }); + if (!tab) throw new Error(`no tab matched ${url}`); + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const route = window.__koalaSyncAudioRoute; + if (!route) { + return { + hasRoute: false, + contextState: null, + chainActive: null, + hasVideo: false, + signalLevel: 0 + }; + } + let analyser = route.testAnalyser; + if (!analyser) { + analyser = route.audioCtx.createAnalyser(); + analyser.fftSize = 32; + route.chain.limiter.connect(analyser); + route.testAnalyser = analyser; + } + if (!route.testSignal) { + const oscillator = route.audioCtx.createOscillator(); + const gain = route.audioCtx.createGain(); + gain.gain.value = 0.05; + oscillator.frequency.value = 440; + oscillator.connect(gain); + gain.connect(route.chain.limiter); + oscillator.start(); + route.testSignal = { oscillator, gain }; + } + const samples = new Uint8Array(analyser.fftSize); + analyser.getByteTimeDomainData(samples); + return { + hasRoute: true, + contextState: route.audioCtx?.state || null, + chainActive: route.chain?.active ?? null, + hasVideo: !!route.video, + signalLevel: Math.max(...Array.from(samples, sample => Math.abs(sample - 128))) + }; + } + }); + return result?.result || null; + }, pageUrl)); +} + async function getFrameMonitorState(context, extensionId, pageUrl, frameUrlPart) { return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, frameUrlPart }) => { const [tab] = await chrome.tabs.query({ url: pageUrl }); @@ -105,11 +161,82 @@ test('keeps the selected tab after the popup page closes and reopens', async ({ const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); expect(status).toMatchObject({ targetTabId: tabId, - selectedTargetTabId: tabId, - requestedTargetTabId: null + targetReady: true, + targetActivationState: 'ready' }); }); +test('keeps a selected target even when the page has no video element', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/no-video.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId, response } = await selectTargetTab(context, extensionId, url); + expect(response).toMatchObject({ status: 'ok', tabId }); + + const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(status).toMatchObject({ + targetTabId: tabId, + targetReady: true, + targetActivationState: 'ready', + targetHasVideo: false + }); +}); + +test('keeps a previously selected player audible after switching targets', async ({ context, extensionId, baseURL }) => { + const firstUrl = `${baseURL}/pages/simple-player.html`; + const secondUrl = `${baseURL}/pages/no-video.html`; + const first = await context.newPage(); + const second = await context.newPage(); + await first.goto(firstUrl); + await first.waitForFunction(() => window.__fixtureReady === true); + await second.goto(secondUrl); + await second.waitForFunction(() => window.__fixtureReady === true); + + await setAudioSettings(context, extensionId, { + enabled: true, + boostDb: 1, + compressor: { enabled: false, preset: 'recommended', customParams: {} } + }); + const { tabId: firstTabId } = await selectTargetTab(context, extensionId, firstUrl); + await first.evaluate(() => document.getElementById('player').play()); + await expect.poll( + () => getAudioRouteState(context, extensionId, firstUrl), + { message: 'the selected player must have a live audio route' } + ).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true }); + await expect.poll( + () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), + { message: 'the selected player must produce a signal through the audio route' } + ).toBeGreaterThan(1); + + const { response } = await selectTargetTab(context, extensionId, secondUrl); + expect(response).toMatchObject({ status: 'ok' }); + await first.evaluate(() => document.getElementById('player').play()); + await expect.poll(() => first.locator('#player').evaluate(video => ({ + paused: video.paused, + muted: video.muted, + volume: video.volume + }))).toMatchObject({ paused: false, muted: false }); + await expect.poll( + () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), + { message: 'switching targets must not mute the previous audio route' } + ).toBeGreaterThan(1); + + await selectTargetTab(context, extensionId, firstUrl); + await expect.poll( + () => getAudioRouteState(context, extensionId, firstUrl), + { message: 'reselecting the player must reuse its live audio route' } + ).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true }); + await expect.poll( + () => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0), + { message: 'reselecting the player must keep the audio signal alive' } + ).toBeGreaterThan(1); + const playResponse = await sendServerCommand(context, extensionId, firstTabId, 'play'); + expect(playResponse).toMatchObject({ status: 'ok_solo' }); + await expect.poll(() => first.locator('#player').evaluate(video => video.paused)).toBe(false); +}); + test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); diff --git a/tests/e2e/fixtures/pages/no-video.html b/tests/e2e/fixtures/pages/no-video.html new file mode 100644 index 0000000..f78d17a --- /dev/null +++ b/tests/e2e/fixtures/pages/no-video.html @@ -0,0 +1,8 @@ + + +
This page is intentionally selectable.