fix(extension): harden Netflix page API sync

This commit is contained in:
KoalaDev
2026-08-30 00:50:35 +02:00
parent 8e4f64566b
commit 2ab3cbf170
6 changed files with 359 additions and 103 deletions
+3 -2
View File
@@ -45,8 +45,9 @@ the room/revision and optional privacy-sanitized media title, respects Host Cont
solo mode and Episode Lobby, then queues an internal solo mode and Episode Lobby, then queues an internal
`APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer `APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer
live commands. live commands.
That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, That path reuses frame election, Netflix page-API play/pause/seek, Disney+
the 2-second drift tolerance, and programmatic-event suppression. Recovery only page-API seeks, native controls on other services, the 2-second drift tolerance,
and programmatic-event suppression. Recovery only
completes after playback state and position verification. Transient failures completes after playback state and position verification. Transient failures
retry after 250, 750, 1500, and 3000 ms, while target, heartbeat, and content-boot retry after 250, 750, 1500, and 3000 ms, while target, heartbeat, and content-boot
signals can retrigger a pending attempt within that bound. A pending playing signals can retrigger a pending attempt within that bound. A pending playing
+80 -32
View File
@@ -3049,14 +3049,14 @@ async function devRemoteToolsAllowed() {
return data.username === 'KoalaDev'; return data.username === 'KoalaDev';
} }
function shouldUsePageApiSeek(url) { function shouldUsePageApiPlayer(url) {
return typeof globalThis.koalaFindPageApiSeekProvider === 'function' && return typeof globalThis.koalaFindPageApiSeekProvider === 'function' &&
!!globalThis.koalaFindPageApiSeekProvider(url); !!globalThis.koalaFindPageApiSeekProvider(url);
} }
function installPageApiSeekBridge() { function installPageApiPlayerBridge() {
if (window.__koalaPageApiSeekBridge?.activate) { if (window.__koalaPageApiPlayerBridge?.activate) {
window.__koalaPageApiSeekBridge.activate(); window.__koalaPageApiPlayerBridge.activate();
return; return;
} }
@@ -3076,36 +3076,81 @@ function installPageApiSeekBridge() {
return el && el.mediaPlayer ? el.mediaPlayer : null; return el && el.mediaPlayer ? el.mediaPlayer : null;
} }
function seekWithPageApi(time) { function netflixPlayer() {
const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.()?.videoPlayer;
const ids = videoPlayer?.getAllPlayerSessionIds?.();
if (!Array.isArray(ids) || ids.length === 0) return null;
// Netflix may expose preview/billboard sessions beside the actual title.
// Prefer the watch session, retaining the single/first-session fallback
// for older player builds whose identifiers used a different shape.
const sessionId = ids.find(id => typeof id === 'string' && /(?:^|-)watch(?:-|$)/i.test(id))
|| ids.find(id => typeof id === 'string' && id.toLowerCase().includes('watch'))
|| ids[0];
return sessionId ? videoPlayer.getVideoPlayerBySessionId?.(sessionId) : null;
}
async function applyPageApiAction(action, time) {
const match = currentMatch(); const match = currentMatch();
if (!match) return; if (!match || !Array.isArray(match.actions) || !match.actions.includes(action)) {
return { ok: false, reason: 'unsupported_action' };
}
try { try {
if (match.provider === 'netflix') { if (match.provider === 'netflix') {
const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer; const player = netflixPlayer();
const ids = videoPlayer?.getAllPlayerSessionIds?.(); if (!player) return { ok: false, reason: 'player_unavailable' };
const sessionId = ids ? ids[0] : null; if (action === 'seek') {
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null; if (!Number.isFinite(time) || typeof player.seek !== 'function') {
player?.seek(Math.round(time * 1000)); return { ok: false, reason: 'seek_unavailable' };
}
await player.seek(Math.round(time * 1000));
} else if (action === 'play') {
if (typeof player.play !== 'function') return { ok: false, reason: 'play_unavailable' };
await player.play();
} else if (action === 'pause') {
if (typeof player.pause !== 'function') return { ok: false, reason: 'pause_unavailable' };
await player.pause();
}
} else if (match.provider === 'disney') { } else if (match.provider === 'disney') {
const mp = disneyMediaPlayer(); const mp = disneyMediaPlayer();
if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000)); if (action !== 'seek' || !Number.isFinite(time) || !mp || typeof mp.seek !== 'function') {
return { ok: false, reason: 'seek_unavailable' };
}
await mp.seek(Math.round(time * 1000));
} else {
return { ok: false, reason: 'provider_unavailable' };
} }
} catch (_e) { return { ok: true };
// Player not ready or private API changed; the next sync tick can retry. } catch (_error) {
return { ok: false, reason: 'player_api_error' };
} }
} }
function postResult(requestId, action, result) {
window.postMessage({
__koalaPageApiPlayer: 1,
kind: 'result',
requestId,
action,
ok: result?.ok === true,
reason: result?.ok === true ? null : (result?.reason || 'player_api_error')
}, '*');
}
function handleBridgeMessage(event) { function handleBridgeMessage(event) {
if (event.source !== window) return; if (event.source !== window) return;
const data = event.data; const data = event.data;
if (!data || data.__koalaPageApiSeek !== 1) return; if (!data || data.__koalaPageApiPlayer !== 1) return;
if (data.kind === 'destroy') { if (data.kind === 'destroy') {
destroy(); destroy();
return; return;
} }
if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return; if (!active || data.kind !== 'command'
seekWithPageApi(data.time); || typeof data.requestId !== 'string'
|| !['play', 'pause', 'seek'].includes(data.action)) return;
Promise.resolve(applyPageApiAction(data.action, data.time))
.then(result => postResult(data.requestId, data.action, result))
.catch(() => postResult(data.requestId, data.action, { ok: false, reason: 'player_api_error' }));
} }
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so // Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
@@ -3136,11 +3181,11 @@ function installPageApiSeekBridge() {
active = false; active = false;
clearInterval(timelineInterval); clearInterval(timelineInterval);
window.removeEventListener('message', handleBridgeMessage); window.removeEventListener('message', handleBridgeMessage);
delete window.__koalaPageApiSeekBridge; delete window.__koalaPageApiPlayerBridge;
} }
window.addEventListener('message', handleBridgeMessage); window.addEventListener('message', handleBridgeMessage);
window.__koalaPageApiSeekBridge = { window.__koalaPageApiPlayerBridge = {
activate() { activate() {
active = true; active = true;
}, },
@@ -3148,8 +3193,8 @@ function installPageApiSeekBridge() {
}; };
} }
function setPageApiSeekEnabled(enabled) { function setPageApiPlayerEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true; window.KOALA_PAGE_API_PLAYER_ENABLED = enabled === true;
} }
async function deactivateMediaFrameMonitors(tabId) { async function deactivateMediaFrameMonitors(tabId) {
@@ -3391,8 +3436,8 @@ async function injectContentScript(tabId, {
const normalizedTabId = normalizeTabId(tabId); const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) throw new Error('Invalid tab ID'); if (normalizedTabId === null) throw new Error('Invalid tab ID');
tabId = normalizedTabId; tabId = normalizedTabId;
let needsPageApiSeek = false; let needsPageApiPlayer = false;
let pageApiSeekReady = false; let pageApiPlayerReady = false;
let access = null; let access = null;
let contentTarget = { let contentTarget = {
frameId: 0, frameId: 0,
@@ -3404,7 +3449,7 @@ async function injectContentScript(tabId, {
try { try {
access = await inspectTabHostAccess(chrome, tabId); access = await inspectTabHostAccess(chrome, tabId);
const url = access.url || ''; const url = access.url || '';
needsPageApiSeek = shouldUsePageApiSeek(url); needsPageApiPlayer = shouldUsePageApiPlayer(url);
contentTarget = await resolveMediaContentTarget(chrome, tabId, { contentTarget = await resolveMediaContentTarget(chrome, tabId, {
knownFrameIds: listKnownFrameIds(tabId) knownFrameIds: listKnownFrameIds(tabId)
}); });
@@ -3449,21 +3494,24 @@ async function injectContentScript(tabId, {
} }
throw createTargetActivationSupersededError(); throw createTargetActivationSupersededError();
} }
if (needsPageApiSeek) { if (needsPageApiPlayer) {
try { try {
await executeScriptWithTimeout({ await executeScriptWithTimeout({
target: scriptTarget, // Netflix/Disney expose their private player on the top page.
// Keep this bridge independent of iframe election so a nested
// player refactor can never redirect it into the wrong realm.
target: { tabId },
world: 'MAIN', world: 'MAIN',
files: ['page-api-seek-overrides.js'] files: ['page-api-seek-overrides.js']
}); });
await executeScriptWithTimeout({ await executeScriptWithTimeout({
target: scriptTarget, target: { tabId },
world: 'MAIN', world: 'MAIN',
func: installPageApiSeekBridge func: installPageApiPlayerBridge
}); });
pageApiSeekReady = true; pageApiPlayerReady = true;
} catch (err) { } catch (err) {
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn'); addLog(`Page API player bridge injection failed: ${err.message}`, 'warn');
} }
} }
@@ -3473,8 +3521,8 @@ async function injectContentScript(tabId, {
}); });
await executeScriptWithTimeout({ await executeScriptWithTimeout({
target: scriptTarget, target: scriptTarget,
func: setPageApiSeekEnabled, func: setPageApiPlayerEnabled,
args: [pageApiSeekReady] args: [pageApiPlayerReady]
}); });
// The chat overlay is standalone page UI and carries its own runtime // The chat overlay is standalone page UI and carries its own runtime
// message listener, so it is installed in the top document regardless of // message listener, so it is installed in the top document regardless of
+120 -62
View File
@@ -136,21 +136,35 @@
let seekDebounceTimer = null; // debounce timer for rapid seek events let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks let expectedSeekTime = null; // strictly track programmatic seeks
const PAGE_API_SEEK_BRIDGE = 1; const PAGE_API_PLAYER_BRIDGE = 1;
const PAGE_API_ACTION_TIMEOUT_MS = 2000;
const pendingPageApiActions = new Map();
let pageApiActionSequence = 0;
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge // Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
// (background.js installPageApiSeekBridge). The isolated content world // (background.js installPageApiPlayerBridge). The isolated content world
// can't read the page's media player directly. // can't read the page's media player directly.
let disneyPageApiTime = null; let disneyPageApiTime = null;
function handlePageApiTime(event) { function handlePageApiMessage(event) {
if (destroyed || event.source !== window) return; if (destroyed || event.source !== window) return;
const data = event.data; const data = event.data;
if (data && data.__koalaPlayerTime === 1 && data.provider === 'disney' if (data && data.__koalaPlayerTime === 1 && data.provider === 'disney'
&& Number.isFinite(data.position) && Number.isFinite(data.duration) && data.duration > 0) { && Number.isFinite(data.position) && Number.isFinite(data.duration) && data.duration > 0) {
disneyPageApiTime = { position: data.position, duration: data.duration, at: Date.now() }; disneyPageApiTime = { position: data.position, duration: data.duration, at: Date.now() };
return;
} }
if (!data || data.__koalaPageApiPlayer !== PAGE_API_PLAYER_BRIDGE
|| data.kind !== 'result' || typeof data.requestId !== 'string') return;
const pending = pendingPageApiActions.get(data.requestId);
if (!pending || pending.action !== data.action) return;
pendingPageApiActions.delete(data.requestId);
clearTimeout(pending.timeout);
if (data.ok !== true) {
reportLog(`Page API ${pending.action} failed: ${data.reason || 'unknown_error'}`, 'warn');
}
pending.resolve(data.ok === true);
} }
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('message', handlePageApiTime); window.addEventListener('message', handlePageApiMessage);
} }
function hostMatchesUrl(host, url) { function hostMatchesUrl(host, url) {
@@ -239,22 +253,56 @@
return Math.max(min, Math.min(max, nativeTarget)); return Math.max(min, Math.min(max, nativeTarget));
} }
function shouldUsePageApiSeek() { function getPageApiPlayerProvider() {
return window.KOALA_PAGE_API_SEEK_ENABLED === true && return typeof window.koalaFindPageApiSeekProvider === 'function'
typeof window.koalaFindPageApiSeekProvider === 'function' && ? window.koalaFindPageApiSeekProvider(window.location.hostname)
!!window.koalaFindPageApiSeekProvider(window.location.hostname); : null;
}
function pageApiActionRequired(action) {
const provider = getPageApiPlayerProvider();
return !!provider && Array.isArray(provider.actions) && provider.actions.includes(action);
}
function requestPageApiAction(action, time = null) {
if (window.KOALA_PAGE_API_PLAYER_ENABLED !== true) {
reportLog(`Page API ${action} unavailable; refusing unsafe native fallback`, 'warn');
return Promise.resolve(false);
}
const requestId = `${Date.now()}:${++pageApiActionSequence}`;
return new Promise(resolve => {
const timeout = setTimeout(() => {
pendingPageApiActions.delete(requestId);
reportLog(`Page API ${action} timed out`, 'warn');
resolve(false);
}, PAGE_API_ACTION_TIMEOUT_MS);
pendingPageApiActions.set(requestId, { action, resolve, timeout });
try {
window.postMessage({
__koalaPageApiPlayer: PAGE_API_PLAYER_BRIDGE,
kind: 'command',
requestId,
action,
time
}, '*');
} catch (_error) {
clearTimeout(timeout);
pendingPageApiActions.delete(requestId);
resolve(false);
}
});
} }
function seekVideo(video, targetTime) { function seekVideo(video, targetTime) {
// Prefer a precise page-level seek API when available (Netflix, Disney+); // Netflix/Disney must never fall through to video.currentTime when their
// for those players the DOM/button seek path is imprecise or impossible. // page bridge is unavailable: Netflix treats that as player tampering.
if (shouldUsePageApiSeek()) { if (pageApiActionRequired(EVENTS.SEEK)) {
expectedSeekTime = targetTime; expectedSeekTime = targetTime;
window.postMessage({ __koalaPageApiSeek: PAGE_API_SEEK_BRIDGE, kind: 'seek', time: targetTime }, '*'); return requestPageApiAction(EVENTS.SEEK, targetTime);
return;
} }
expectedSeekTime = targetTime; expectedSeekTime = targetTime;
video.currentTime = toNativeSeekTime(video, targetTime); video.currentTime = toNativeSeekTime(video, targetTime);
return true;
} }
// --- Play/Pause Coalescing (leading + trailing) --- // --- Play/Pause Coalescing (leading + trailing) ---
@@ -1121,13 +1169,11 @@
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle) if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& current !== null && video.readyState >= 1) { && current !== null && video.readyState >= 1) {
if (current >= 5) { if (current >= 5) {
expectedSeekTime = 0; Promise.resolve(tryMediaAction(EVENTS.SEEK, { targetTime: 0 })).catch(() => {});
video.currentTime = 0;
} }
// Match! Pause at start and report ready. // Match! Pause at start and report ready.
if (!video.paused) { if (!video.paused) {
_setSuppress('paused'); Promise.resolve(tryMediaAction(EVENTS.PAUSE)).catch(() => {});
video.pause();
} }
stopLobbyPoll(); stopLobbyPoll();
runtimeMessage({ runtimeMessage({
@@ -1219,6 +1265,17 @@
} }
try { try {
if (pageApiActionRequired(action)) {
const suppression = action === EVENTS.PLAY ? 'playing'
: (action === EVENTS.PAUSE ? 'paused' : 'seek');
_setSuppress(suppression);
const targetTime = action === EVENTS.SEEK ? data.targetTime : null;
return requestPageApiAction(action, targetTime).then(applied => {
if (!applied) _clearSuppress(suppression);
return applied;
});
}
const actionFix = getActivePlayerActionFix(); const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) { if (tryPlayerActionFix(actionFix, action, video, data)) {
return true; return true;
@@ -1241,8 +1298,7 @@
video.pause(); video.pause();
return true; return true;
} else if (action === EVENTS.SEEK) { } else if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta); return seekVideo(video, data.targetTime, data.delta);
return true;
} }
return false; return false;
} catch (e) { } catch (e) {
@@ -1251,6 +1307,23 @@
} }
} }
function applyServerMediaAction(action, payload, message) {
Promise.resolve(tryMediaAction(action, payload)).then(applied => {
if (!applied) {
reportLog(`Remote ${action} was not applied`, 'warn');
return;
}
runtimeMessage({
type: 'CMD_ACK',
actionTimestamp: message.actionTimestamp,
commandSenderId: message.commandSenderId
}).catch(() => {});
scheduleProactiveHeartbeat();
}).catch(error => {
reportLog(`Remote ${action} failed: ${error.message}`, 'warn');
});
}
function beginCanonicalMediaApply() { function beginCanonicalMediaApply() {
canonicalMediaApplyGeneration++; canonicalMediaApplyGeneration++;
canonicalSupersedingLocalState = null; canonicalSupersedingLocalState = null;
@@ -1293,21 +1366,20 @@
if (!state || !video || destroyed || video.isConnected === false) return; if (!state || !video || destroyed || video.isConnected === false) return;
if (state.playbackState === 'paused' && !video.paused) { if (state.playbackState === 'paused' && !video.paused) {
_setSuppress('paused'); await tryMediaAction(EVENTS.PAUSE);
video.pause();
} }
if (Number.isFinite(state.currentTime)) { if (Number.isFinite(state.currentTime)) {
const currentTime = getSyncCurrentTime(video); const currentTime = getSyncCurrentTime(video);
if (currentTime === null || Math.abs(currentTime - state.currentTime) >= MIN_SEEK_DELTA) { if (currentTime === null || Math.abs(currentTime - state.currentTime) >= MIN_SEEK_DELTA) {
_setSuppress('seek'); await tryMediaAction(EVENTS.SEEK, { targetTime: state.currentTime });
seekVideo(video, state.currentTime);
} }
} }
if (state.playbackState === 'playing' && video.paused) { if (state.playbackState === 'playing' && video.paused) {
_setSuppress('playing'); _setSuppress('playing');
const playSuppression = holdCanonicalRestorePlaySuppression(); const playSuppression = holdCanonicalRestorePlaySuppression();
try { try {
await video.play(); const restored = await tryMediaAction(EVENTS.PLAY);
if (!restored) throw new Error('player rejected play');
} catch (error) { } catch (error) {
_clearSuppress('playing'); _clearSuppress('playing');
reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn'); reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn');
@@ -1561,8 +1633,6 @@
if (message.type === 'SERVER_COMMAND') { if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message; const { action, payload } = message;
let actionCompleted = false;
// Host Control Mode: while watching on our own (desynced), don't apply // Host Control Mode: while watching on our own (desynced), don't apply
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's // 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 // force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
@@ -1601,17 +1671,11 @@
} }
if (action === EVENTS.PLAY) { if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY); applyServerMediaAction(EVENTS.PLAY, payload, message);
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.PAUSE) { } else if (action === EVENTS.PAUSE) {
tryMediaAction(EVENTS.PAUSE); applyServerMediaAction(EVENTS.PAUSE, payload, message);
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.SEEK) { } else if (action === EVENTS.SEEK) {
tryMediaAction(EVENTS.SEEK, payload); applyServerMediaAction(EVENTS.SEEK, payload, message);
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
} else if (action === EVENTS.FORCE_SYNC_PREPARE) { } else if (action === EVENTS.FORCE_SYNC_PREPARE) {
if (!payload || payload.targetTime === undefined) return; if (!payload || payload.targetTime === undefined) return;
const video = findVideo(); const video = findVideo();
@@ -1620,31 +1684,21 @@
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error'); reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
return; return;
} }
_setSuppress('paused'); Promise.resolve(tryMediaAction(EVENTS.PAUSE))
video.pause(); .then(paused => paused ? tryMediaAction(EVENTS.SEEK, payload) : false)
try { .then(seeked => seeked ? pollSeekReady(payload.targetTime) : false)
seekVideo(video, payload.targetTime); .then((ready) => {
} catch (e) { runtimeMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
reportLog(`Force Sync Seek Error: ${e.message}`, 'error'); if (ready) {
} scheduleProactiveHeartbeat();
pollSeekReady(payload.targetTime).then((ready) => { } else {
runtimeMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); reportLog('Force Sync: player action failed or timed out, proceeding anyway', 'warn');
if (ready) { }
scheduleProactiveHeartbeat(); }).catch(() => {});
} else {
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
}
}).catch(() => {});
} }
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) { } else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
stopLobbyPoll(); stopLobbyPoll();
tryMediaAction(EVENTS.PLAY); applyServerMediaAction(EVENTS.PLAY, payload, message);
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
actionCompleted = true;
}
if (actionCompleted) {
scheduleProactiveHeartbeat();
} }
} }
@@ -1679,8 +1733,7 @@
if (message.type === 'PAUSE_FOR_LOBBY') { if (message.type === 'PAUSE_FOR_LOBBY') {
const video = findVideo(); const video = findVideo();
if (video && !video.paused) { if (video && !video.paused) {
_setSuppress('paused'); Promise.resolve(tryMediaAction(EVENTS.PAUSE)).catch(() => {});
video.pause();
} }
// Start lobby poll now that we know the feature is enabled // Start lobby poll now that we know the feature is enabled
if (message.expectedTitle) { if (message.expectedTitle) {
@@ -2367,9 +2420,14 @@
destroyed = true; destroyed = true;
try { try {
window.postMessage({ __koalaPageApiSeek: PAGE_API_SEEK_BRIDGE, kind: 'destroy' }, '*'); window.postMessage({ __koalaPageApiPlayer: PAGE_API_PLAYER_BRIDGE, kind: 'destroy' }, '*');
} catch (_e) { /* page is already tearing down */ } } catch (_e) { /* page is already tearing down */ }
window.KOALA_PAGE_API_SEEK_ENABLED = false; window.KOALA_PAGE_API_PLAYER_ENABLED = false;
for (const pending of pendingPageApiActions.values()) {
clearTimeout(pending.timeout);
pending.resolve(false);
}
pendingPageApiActions.clear();
for (const timer of Object.values(_suppressTimers)) clearTimeout(timer); for (const timer of Object.values(_suppressTimers)) clearTimeout(timer);
_suppressTimers = {}; _suppressTimers = {};
@@ -2410,7 +2468,7 @@
window.removeEventListener('pagehide', handlePageHide); window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('pageshow', handlePageShow); window.removeEventListener('pageshow', handlePageShow);
window.removeEventListener('resize', handleMediaFrameResize); window.removeEventListener('resize', handleMediaFrameResize);
window.removeEventListener('message', handlePageApiTime); window.removeEventListener('message', handlePageApiMessage);
if (hcmBadgeDomReadyHandler) { if (hcmBadgeDomReadyHandler) {
document.removeEventListener('DOMContentLoaded', hcmBadgeDomReadyHandler); document.removeEventListener('DOMContentLoaded', hcmBadgeDomReadyHandler);
hcmBadgeDomReadyHandler = null; hcmBadgeDomReadyHandler = null;
+146
View File
@@ -0,0 +1,146 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const providerSource = fs.readFileSync(path.join(extensionDir, 'page-api-seek-overrides.js'), 'utf8');
function extractFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
expect(start).toBeGreaterThan(-1);
const bodyStart = source.indexOf('{', start);
let depth = 0;
for (let index = bodyStart; index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}') depth--;
if (depth === 0) return source.slice(start, index + 1);
}
throw new Error(`${name} body did not terminate`);
}
function createBridgeHarness({ sessionIds = ['watch-main'], players = {} } = {}) {
const listeners = new Set();
const posted = [];
const fakeWindow = {
location: { hostname: 'www.netflix.com' },
koalaFindPageApiSeekProvider: () => ({
provider: 'netflix',
actions: ['play', 'pause', 'seek']
}),
netflix: {
appContext: {
state: {
playerApp: {
getAPI: () => ({
videoPlayer: {
getAllPlayerSessionIds: () => sessionIds,
getVideoPlayerBySessionId: id => players[id] || null
}
})
}
}
}
},
addEventListener(type, listener) {
if (type === 'message') listeners.add(listener);
},
removeEventListener(type, listener) {
if (type === 'message') listeners.delete(listener);
},
postMessage(data) {
posted.push(data);
}
};
const installSource = extractFunction(backgroundSource, 'installPageApiPlayerBridge');
const install = Function(
'window',
'document',
'setInterval',
'clearInterval',
`'use strict'; ${installSource}; return installPageApiPlayerBridge;`
)(
fakeWindow,
{ querySelector: () => null },
() => 1,
() => {}
);
install();
return {
fakeWindow,
posted,
dispatch(data) {
for (const listener of [...listeners]) listener({ source: fakeWindow, data });
}
};
}
async function sendCommand(harness, action, time = null) {
const requestId = `request-${action}`;
harness.dispatch({
__koalaPageApiPlayer: 1,
kind: 'command',
requestId,
action,
time
});
await vi.waitFor(() => {
expect(harness.posted.some(message => message.kind === 'result' && message.requestId === requestId)).toBe(true);
});
return harness.posted.find(message => message.kind === 'result' && message.requestId === requestId);
}
describe('page API player bridge', () => {
it('declares every Netflix write as bridge-only while Disney remains seek-only', () => {
const root = {};
vm.runInNewContext(providerSource, { globalThis: root, URL });
expect(root.koalaFindPageApiSeekProvider('https://www.netflix.com/watch/1')).toMatchObject({
provider: 'netflix',
actions: ['play', 'pause', 'seek']
});
expect(root.koalaFindPageApiSeekProvider('www.disneyplus.com')).toMatchObject({
provider: 'disney',
actions: ['seek']
});
});
it('prefers the Netflix watch session and confirms play, pause, and seek', async () => {
const preview = { seek: vi.fn(), play: vi.fn(), pause: vi.fn() };
const player = { seek: vi.fn(), play: vi.fn(), pause: vi.fn() };
const harness = createBridgeHarness({
sessionIds: ['motion-billboard-1', 'watch-main'],
players: { 'motion-billboard-1': preview, 'watch-main': player }
});
await expect(sendCommand(harness, 'seek', 12.345)).resolves.toMatchObject({ ok: true });
await expect(sendCommand(harness, 'pause')).resolves.toMatchObject({ ok: true });
await expect(sendCommand(harness, 'play')).resolves.toMatchObject({ ok: true });
expect(player.seek).toHaveBeenCalledWith(12345);
expect(player.pause).toHaveBeenCalledOnce();
expect(player.play).toHaveBeenCalledOnce();
expect(preview.seek).not.toHaveBeenCalled();
});
it('returns an explicit failure when the Netflix player is unavailable', async () => {
const harness = createBridgeHarness({ sessionIds: ['watch-missing'], players: {} });
await expect(sendCommand(harness, 'seek', 10)).resolves.toMatchObject({
ok: false,
reason: 'player_unavailable'
});
});
it('keeps provider actions fail-closed and ACKs only applied remote commands', () => {
const seekVideo = extractFunction(contentSource, 'seekVideo');
const request = extractFunction(contentSource, 'requestPageApiAction');
const remote = extractFunction(contentSource, 'applyServerMediaAction');
expect(seekVideo.indexOf('pageApiActionRequired(EVENTS.SEEK)'))
.toBeLessThan(seekVideo.indexOf('video.currentTime ='));
expect(seekVideo).toContain('return requestPageApiAction(EVENTS.SEEK, targetTime)');
expect(request).toContain('refusing unsafe native fallback');
expect(remote.indexOf('if (!applied)')).toBeLessThan(remote.indexOf("type: 'CMD_ACK'"));
expect(backgroundSource).toContain('target: { tabId },\n world: \'MAIN\'');
});
});
+4 -2
View File
@@ -3,12 +3,14 @@
{ {
name: 'netflix-page-api-seek', name: 'netflix-page-api-seek',
urls: ['netflix.com'], urls: ['netflix.com'],
provider: 'netflix' provider: 'netflix',
actions: ['play', 'pause', 'seek']
}, },
{ {
name: 'disney-page-api-seek', name: 'disney-page-api-seek',
urls: ['disneyplus.com'], urls: ['disneyplus.com'],
provider: 'disney' provider: 'disney',
actions: ['seek']
} }
]; ];
+6 -5
View File
@@ -595,7 +595,11 @@ test('newer remote pause wins after a stale restoration play settles late', asyn
const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
video.dataset.koalaDelayedPlayAttempts = String(attempt); video.dataset.koalaDelayedPlayAttempts = String(attempt);
if (attempt === 1) { if (attempt === 1) {
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200))); // Keep the fixture paused without dispatching a native
// pause event that could overwrite the synthetic local
// PLAY intent below. This first delayed call represents
// the stale recovery attempt, not settled playback.
return new Promise(resolve => setTimeout(resolve, 200));
} }
return new Promise((resolve, reject) => setTimeout(() => { return new Promise((resolve, reject) => setTimeout(() => {
nativePlay().then(resolve, reject); nativePlay().then(resolve, reject);
@@ -615,11 +619,8 @@ test('newer remote pause wins after a stale restoration play settles late', asyn
await expect.poll(() => page.locator('#player').evaluate(video => await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1); Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1);
// Capture a superseding play intent while leaving the fixture paused, so // Capture a superseding play intent while the fixture is still paused, so
// stale recovery has to enter its delayed restoration play path. // stale recovery has to enter its delayed restoration play path.
await page.locator('#player').evaluate(video => {
video.pause();
});
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate( await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
selectedTabId => chrome.tabs.sendMessage(selectedTabId, { selectedTabId => chrome.tabs.sendMessage(selectedTabId, {
type: 'CANCEL_CANONICAL_MEDIA_STATE', type: 'CANCEL_CANONICAL_MEDIA_STATE',