mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user