diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ff67cc..5080630 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer live commands. -That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, -the 2-second drift tolerance, and programmatic-event suppression. Recovery only +That path reuses frame election, Netflix page-API play/pause/seek, Disney+ +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 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 diff --git a/extension/background.js b/extension/background.js index 84e5b80..a6810fc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -3049,14 +3049,14 @@ async function devRemoteToolsAllowed() { return data.username === 'KoalaDev'; } -function shouldUsePageApiSeek(url) { +function shouldUsePageApiPlayer(url) { return typeof globalThis.koalaFindPageApiSeekProvider === 'function' && !!globalThis.koalaFindPageApiSeekProvider(url); } -function installPageApiSeekBridge() { - if (window.__koalaPageApiSeekBridge?.activate) { - window.__koalaPageApiSeekBridge.activate(); +function installPageApiPlayerBridge() { + if (window.__koalaPageApiPlayerBridge?.activate) { + window.__koalaPageApiPlayerBridge.activate(); return; } @@ -3076,36 +3076,81 @@ function installPageApiSeekBridge() { 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(); - if (!match) return; + if (!match || !Array.isArray(match.actions) || !match.actions.includes(action)) { + return { ok: false, reason: 'unsupported_action' }; + } try { if (match.provider === 'netflix') { - const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer; - const ids = videoPlayer?.getAllPlayerSessionIds?.(); - const sessionId = ids ? ids[0] : null; - const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null; - player?.seek(Math.round(time * 1000)); + const player = netflixPlayer(); + if (!player) return { ok: false, reason: 'player_unavailable' }; + if (action === 'seek') { + if (!Number.isFinite(time) || typeof player.seek !== 'function') { + 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') { 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) { - // Player not ready or private API changed; the next sync tick can retry. + return { ok: true }; + } 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) { if (event.source !== window) return; const data = event.data; - if (!data || data.__koalaPageApiSeek !== 1) return; + if (!data || data.__koalaPageApiPlayer !== 1) return; if (data.kind === 'destroy') { destroy(); return; } - if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return; - seekWithPageApi(data.time); + if (!active || data.kind !== 'command' + || 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