Fix Disney+ force sync, seek, and HCM regressions for v2.5.3

The v2.5.2 Disney+ page-API integration leaked blob-relative <video>
time into force sync, seeks, and heartbeats when the page-API bridge
had no fresh data, so force sync on Disney+ appeared broken.

- getSyncCurrentTime/getSyncDuration now refuse native values on Disney+
  (return null/0) so stale bridge data degrades to a clean no-op instead
  of broadcasting garbage to peers. The get_current_time handler and
  episode/lobby/hcmIsLive paths are routed through the same accessor.
- Validate FORCE_SYNC_PREPARE/SEEK payloads as finite before relaying;
  the internal coercion no longer treats '' as 0.
- Stop double-routing FORCE_SYNC_PREPARE from the popup path (the
  generic popup route now covers only play/pause/seek).
- popup force-sync: exclude null/empty peer times from the jump-to-others
  median (Number(null)===0 was dragging the target to 0), guard against
  NaN end-to-end, clear the dangling reset timer on failure, and retry
  without re-injecting when the content script responds but the Disney+
  bridge has not yet delivered a finite time.
- hcmIsLive skips the native-duration live signal on Disney+ only,
  preserving YouTube/Twitch Infinity-duration live detection.

Disney-specific logic remains strictly gated to disneyplus.com; no
Netflix/YouTube/Twitch/generic path is affected.
This commit is contained in:
Timo
2026-07-02 16:57:48 +02:00
parent 6ccaf45f8e
commit 869c64171b
5 changed files with 126 additions and 51 deletions
+13
View File
@@ -8,6 +8,19 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.5.3] — 2026-07-02
### Fixed
- **Extension: Disney+ force sync and seek reliability** — The v2.5.2 Disney+ page-API integration leaked blob-relative `<video>` time into force sync, seeks, and heartbeats when the page-API bridge had no fresh data, which presented as "force sync does nothing on Disney+". Time/duration accessors now refuse to return native values on Disney+ (returning null/0) so stale bridge data degrades to a clean no-op instead of broadcasting garbage. `FORCE_SYNC_PREPARE` and `SEEK` payloads are now validated as finite before being relayed, and the popup's force-sync flow fails cleanly with a clear error rather than sending NaN through.
- **Extension: Force-sync no longer routed twice** — A popup-initiated `FORCE_SYNC_PREPARE` was being delivered to the content script twice (once by the generic popup route and once by the force-sync-specific route), causing a double seek. The generic route is now scoped to play/pause/seek only.
- **Extension: Disney+ Host Control Mode classification** — `hcmIsLive` previously read the native `<video>` duration to detect live streams, which on Disney+ is blob-relative garbage and falsely classified every stream as live (disabling snap-back and the desync dialog). The native-duration live signal is now skipped on Disney+ while YouTube/Twitch live detection via `Infinity` duration is preserved.
- **Extension: Disney+ episode auto-sync and lobby readiness** — Episode-transition detection and the episode-lobby "ready" poll now read the playhead through the gated time accessor, so they no longer rely on blob-relative `currentTime` on Disney+.
- **Extension: Force-sync median no longer skewed by peers without a known time** — A peer broadcasting `currentTime: null` (e.g. a Disney+ peer whose page-API bridge was not yet ready, or a freshly joined peer) was coerced to `0` by the jump-to-others median calculation, dragging the sync target toward the start of the video. Null and empty peer times are now excluded before the median.
- **Extension: Force-sync `jump-to-me` retry on Disney+** — When the content script responds but the page-API bridge has not yet delivered a finite time (typical during the first ~250 ms after a Disney+ player loads), the popup now retries once without redundantly re-injecting the content script.
- **Extension: Empty-string seek payload rejection** — The internal seek-time coercion no longer treats `''` as `0`; an empty-string `targetTime`/`currentTime` is rejected as invalid.
---
## [v2.5.2] — 2026-07-02
### Added
+33 -2
View File
@@ -2227,12 +2227,40 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
const payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
const payloadNumber = (value) => value !== undefined && value !== null && value !== '' ? Number(value) : NaN;
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
const targetTime = payloadNumber(payload.targetTime);
if (!Number.isFinite(targetTime)) {
sendResponse({ status: 'invalid_params' });
return;
}
payload.targetTime = targetTime;
} else if (message.action === EVENTS.SEEK) {
const targetTime = payloadNumber(payload.targetTime !== undefined ? payload.targetTime : payload.currentTime);
if (!Number.isFinite(targetTime)) {
sendResponse({ status: 'invalid_params' });
return;
}
payload.currentTime = targetTime;
payload.targetTime = targetTime;
}
const timestamp = Date.now();
localSeq++;
chrome.storage.session.set({ localSeq });
updateLastAction(message.action, 'You', timestamp);
const payload = message.payload || {};
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
if (!isNaN(tabId)) {
const state = await getReadyTabVideoState(tabId);
if (state && !state.error && state.found && Number.isFinite(state.currentTime)) {
payload.currentTime = state.currentTime;
}
}
}
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
if (storageInitialized) chrome.storage.session.set({ lastActionState });
@@ -2246,6 +2274,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
});
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK)) {
routeToContent(message.action, message.payload);
}
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
@@ -2299,7 +2331,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'error' });
});
} else {
routeToContent(message.action, message.payload);
processEvent().catch(err => {
addLog('Content event privacy error: ' + err.message, 'error');
sendResponse({ status: 'error' });
+37 -31
View File
@@ -134,18 +134,20 @@
function hostMatchesUrl(host, url) {
const normalized = String(url || '')
.replace(/^https?:\/\//i, '')
.split('/')[0]
.toLowerCase();
return normalized && (host === normalized || host.endsWith(`.${normalized}`));
}
function matchesPlayerUrls(urls) {
const host = window.location.hostname.toLowerCase();
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
}
function isDisneyPlusHost() {
.replace(/^https?:\/\//i, '')
.split('/')[0]
.toLowerCase();
return normalized && (host === normalized || host.endsWith(`.${normalized}`));
}
function matchesPlayerUrls(urls) {
const host = window.location.hostname.toLowerCase();
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
}
function isDisneyPlusHost() {
return matchesPlayerUrls(['disneyplus.com']);
}
function getDisneyPlusTimeline() {
if (!isDisneyPlusHost()) return null;
@@ -283,7 +285,7 @@
// --- Episode Auto-Sync State ---
let lastKnownMediaTitle = null;
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
@@ -746,17 +748,19 @@
btnRow.append(soloBtn, stayBtn);
wrap.append(title, body, btnRow);
root.appendChild(wrap);
document.body.appendChild(host);
root.appendChild(wrap);
document.body.appendChild(host);
hcmDialogHost = host;
let settled = false;
// Re-query the host's current position on click instead of using the
// potentially stale target captured at HOST_BLOCKED time (M-1).
const stay = () => {
@@ -786,13 +790,15 @@
hcmDesynced = true;
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
// Notify background so it can relay our desynced state to the host via
// Notify background so it can relay our desynced state to the host via
// heartbeats — the host's UI then knows we're not following commands
// instead of appearing silently un-ACK'd.
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
hcmShowBadge();
}
@@ -941,12 +947,12 @@
// Scan likely media hosts even when light-DOM videos exist; many players
// Scan likely media hosts even when light-DOM videos exist; many players
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
+38 -17
View File
@@ -1588,8 +1588,9 @@ elements.forceSyncBtn.addEventListener('click', async () => {
}
const peers = status.peers || [];
const otherTimes = peers
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && !isNaN(p.currentTime))
.map(p => p.currentTime);
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && p.currentTime !== '')
.map(p => Number(p.currentTime))
.filter(Number.isFinite);
if (otherTimes.length === 0) {
showError(getMessage('ERR_NO_PEERS_TIME'));
@@ -1618,37 +1619,57 @@ elements.forceSyncBtn.addEventListener('click', async () => {
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const failForceSyncTime = () => {
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
};
const sendForceSync = (time) => {
if (time === null || time === undefined) {
failForceSyncTime();
return;
}
const target = Number(time);
if (!Number.isFinite(target)) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
payload: { targetTime: parseFloat(time) }
payload: { targetTime: target }
});
};
if (mode === 'jump-to-me') {
const retryQueryTime = () => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
return;
}
sendForceSync(retryResponse.currentTime);
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime);
return;
}
if (chrome.runtime.lastError || !response) {
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
failForceSyncTime();
return;
}
setTimeout(() => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError) return;
if (retryResponse && retryResponse.currentTime !== undefined) {
sendForceSync(retryResponse.currentTime);
}
});
}, 500);
setTimeout(retryQueryTime, 500);
});
return;
}
sendForceSync(response.currentTime);
setTimeout(retryQueryTime, 500);
});
} else {
sendForceSync(targetTime);
+5 -1
View File
@@ -122,6 +122,10 @@ const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9);
assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800);
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 39);
assert.equal(disneyFns.getSyncCurrentTime(makeVideo('disney-native-broken', 1920, 1080, {
currentTime: Number.NaN,
duration: 0
})), 9);
const disneyNoPageApiFns = loadTimelineFns('www.disneyplus.com');
const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
@@ -129,7 +133,7 @@ const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
duration: 0,
seekable: makeSeekable([[20, 10820]])
});
assert.equal(disneyNoPageApiFns.getSyncCurrentTime(disneyOffsetVideo), 29);
assert.equal(disneyNoPageApiFns.getSyncCurrentTime(disneyOffsetVideo), null);
assert.equal(disneyNoPageApiFns.getSyncDuration(disneyOffsetVideo), 0);
assert.equal(disneyNoPageApiFns.toNativeSeekTime(disneyOffsetVideo, 39), 39);