mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-04 15:05:20 +00:00
Sync Disney+ via the page media player API (precise time + seek)
Disney+'s <video> is blob-relative (unusable as an absolute clock) and its scrubber aria-value freezes during playback, so DOM scraping lagged and the +/-10s button seek could neither reach far targets nor land precisely. The real player hangs off the <disney-web-player> custom element as `.mediaPlayer`, exposing seek(ms) and timeline.info (playhead/duration ms). Since the isolated content world can't read that page object, route it through the existing MAIN-world page-API bridge (as Netflix already does): - page-api-seek-overrides.js: register a 'disney' provider. - background.js installPageApiSeekBridge: seek Disney via mediaPlayer.seek(), and post the exact playhead/duration (seconds) to the content world every 250ms. Both are gated on provider === 'disney'; Netflix path unchanged. - content.js: cache the pushed playhead, prefer it in getDisneyPlusTimeline (DOM scraping stays as fallback), and check the page-API seek first in seekVideo. Outcome is identical for Netflix and generic sites. Verified live on Disney+: reported time matches the player exactly and seek lands within ~1s of the target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+39
-2
@@ -1663,10 +1663,22 @@ function installPageApiSeekBridge() {
|
|||||||
if (window.__koalaPageApiSeekBridgeInstalled) return;
|
if (window.__koalaPageApiSeekBridgeInstalled) return;
|
||||||
window.__koalaPageApiSeekBridgeInstalled = true;
|
window.__koalaPageApiSeekBridgeInstalled = true;
|
||||||
|
|
||||||
function seekWithPageApi(time) {
|
function currentMatch() {
|
||||||
const match = typeof window.koalaFindPageApiSeekProvider === 'function'
|
return typeof window.koalaFindPageApiSeekProvider === 'function'
|
||||||
? window.koalaFindPageApiSeekProvider(window.location.hostname)
|
? window.koalaFindPageApiSeekProvider(window.location.hostname)
|
||||||
: null;
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disney+ ("hive"/BAM) player: the real media player hangs off the
|
||||||
|
// <disney-web-player> custom element as `.mediaPlayer`, exposing precise
|
||||||
|
// seek(ms) and timeline.info (playhead/duration in ms).
|
||||||
|
function disneyMediaPlayer() {
|
||||||
|
const el = document.querySelector('disney-web-player');
|
||||||
|
return el && el.mediaPlayer ? el.mediaPlayer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekWithPageApi(time) {
|
||||||
|
const match = currentMatch();
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1676,6 +1688,9 @@ function installPageApiSeekBridge() {
|
|||||||
const sessionId = ids ? ids[0] : null;
|
const sessionId = ids ? ids[0] : null;
|
||||||
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null;
|
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null;
|
||||||
player?.seek(Math.round(time * 1000));
|
player?.seek(Math.round(time * 1000));
|
||||||
|
} else if (match.provider === 'disney') {
|
||||||
|
const mp = disneyMediaPlayer();
|
||||||
|
if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000));
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Player not ready or private API changed; the next sync tick can retry.
|
// Player not ready or private API changed; the next sync tick can retry.
|
||||||
@@ -1688,6 +1703,28 @@ function installPageApiSeekBridge() {
|
|||||||
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
|
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
|
||||||
seekWithPageApi(data.time);
|
seekWithPageApi(data.time);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
|
||||||
|
// the isolated-world content script can't read an accurate position. Push
|
||||||
|
// the real playhead/duration (seconds) from the page's media player.
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const match = currentMatch();
|
||||||
|
if (!match || match.provider !== 'disney') return;
|
||||||
|
const mp = disneyMediaPlayer();
|
||||||
|
const info = mp && mp.timeline && mp.timeline.info;
|
||||||
|
if (!info || typeof info.playheadPositionMs !== 'number' || typeof info.programDurationMs !== 'number') return;
|
||||||
|
if (info.programDurationMs <= 0) return;
|
||||||
|
window.postMessage({
|
||||||
|
__koalaPlayerTime: 1,
|
||||||
|
provider: 'disney',
|
||||||
|
position: info.playheadPositionMs / 1000,
|
||||||
|
duration: info.programDurationMs / 1000
|
||||||
|
}, '*');
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore transient errors (player teardown / element swap).
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPageApiSeekEnabled(enabled) {
|
function setPageApiSeekEnabled(enabled) {
|
||||||
|
|||||||
+29
-6
@@ -64,6 +64,20 @@
|
|||||||
|
|
||||||
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
|
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
|
||||||
|
|
||||||
|
// While a timer exists, matching native events are consumed and not relayed.
|
||||||
|
|
||||||
|
// Timers self-clean after 300ms if the native event never fires.
|
||||||
|
|
||||||
|
let _suppressTimers = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function _setSuppress(state) {
|
||||||
|
|
||||||
|
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||||
|
|
||||||
|
_suppressTimers[state] = setTimeout(() => {
|
||||||
|
|
||||||
delete _suppressTimers[state];
|
delete _suppressTimers[state];
|
||||||
|
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -277,6 +291,13 @@
|
|||||||
if (!isDisneyPlusHost() || typeof document === 'undefined') return [];
|
if (!isDisneyPlusHost() || typeof document === 'undefined') return [];
|
||||||
return querySelectorAllShadow('button,[role="button"]')
|
return querySelectorAllShadow('button,[role="button"]')
|
||||||
.map(getElementLabel)
|
.map(getElementLabel)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickDisneyPlusRelativeSeek(delta) {
|
||||||
|
if (!isDisneyPlusHost() || !Number.isFinite(delta) || Math.abs(delta) < 1 || typeof document === 'undefined') return false;
|
||||||
|
const backward = delta < 0;
|
||||||
const candidates = querySelectorAllShadow('button,[role="button"]');
|
const candidates = querySelectorAllShadow('button,[role="button"]');
|
||||||
const backRe = /rewind|backward|back\b|zurück|zurueck|rück|rueck|retour|recul|retroced|atrás|voltar|indietro/i;
|
const backRe = /rewind|backward|back\b|zurück|zurueck|rück|rueck|retour|recul|retroced|atrás|voltar|indietro/i;
|
||||||
const forwardRe = /forward|ahead|skip|vorwärts|vorwaerts|vorsp|weiter|avancer|adelant|avançar|avancar|avanti/i;
|
const forwardRe = /forward|ahead|skip|vorwärts|vorwaerts|vorsp|weiter|avancer|adelant|avançar|avancar|avanti/i;
|
||||||
@@ -399,6 +420,13 @@
|
|||||||
key: 'disneyPlus',
|
key: 'disneyPlus',
|
||||||
urls: ['disneyplus.com'],
|
urls: ['disneyplus.com'],
|
||||||
matches() { return matchesPlayerUrls(this.urls); },
|
matches() { return matchesPlayerUrls(this.urls); },
|
||||||
|
getTimeline: getDisneyPlusTimeline,
|
||||||
|
clickRelativeSeek: clickDisneyPlusRelativeSeek,
|
||||||
|
getDebug(video) {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
key: 'disneyPlus',
|
||||||
|
urls: this.urls,
|
||||||
timeline: getDisneyPlusTimeline(video),
|
timeline: getDisneyPlusTimeline(video),
|
||||||
timelineCandidates: lastDisneyPlusTimelineCandidates,
|
timelineCandidates: lastDisneyPlusTimelineCandidates,
|
||||||
seekButtons: getDisneyPlusSeekButtonLabels()
|
seekButtons: getDisneyPlusSeekButtonLabels()
|
||||||
@@ -411,13 +439,8 @@
|
|||||||
return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null;
|
return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}];
|
|
||||||
function getSiteQuirkTimeline(video) {
|
function getSiteQuirkTimeline(video) {
|
||||||
|
const adapter = getActiveSiteQuirk();
|
||||||
function getActiveSiteQuirk() {
|
|
||||||
return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapter ? adapter.getTimeline(video) : null;
|
return adapter ? adapter.getTimeline(video) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
name: 'netflix-page-api-seek',
|
name: 'netflix-page-api-seek',
|
||||||
urls: ['netflix.com'],
|
urls: ['netflix.com'],
|
||||||
provider: 'netflix'
|
provider: 'netflix'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disney-page-api-seek',
|
||||||
|
urls: ['disneyplus.com'],
|
||||||
|
provider: 'disney'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function loadTimelineFns(hostname, document = makeDocument()) {
|
|||||||
'let lastKnownDisneyPlusStart = 0;',
|
'let lastKnownDisneyPlusStart = 0;',
|
||||||
'let lastDisneyPlusUiCurrent = null;',
|
'let lastDisneyPlusUiCurrent = null;',
|
||||||
'let lastDisneyPlusNativeAtUi = null;',
|
'let lastDisneyPlusNativeAtUi = null;',
|
||||||
|
'let disneyPageApiTime = null;',
|
||||||
extractFunction('scanShadowDom', source),
|
extractFunction('scanShadowDom', source),
|
||||||
extractFunction('querySelectorAllShadow', source),
|
extractFunction('querySelectorAllShadow', source),
|
||||||
extractFunction('hostMatchesUrl', source),
|
extractFunction('hostMatchesUrl', source),
|
||||||
|
|||||||
Reference in New Issue
Block a user