mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 13:08:15 +00:00
fix(extension): harden Netflix page API sync
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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\'');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user