diff --git a/extension/content.js b/extension/content.js index cb5f64d..048defe 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1237,7 +1237,7 @@ return canonicalMediaApplyGeneration; } - function cancelCanonicalMediaApply(action = null, video = null) { + function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) { canonicalMediaApplyGeneration++; if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) { canonicalSupersedingLocalState = { @@ -1246,6 +1246,8 @@ : (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')), currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null }; + } else if (!preserveLocalState) { + canonicalSupersedingLocalState = null; } return canonicalMediaApplyGeneration; } @@ -1495,7 +1497,10 @@ } if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') { - cancelCanonicalMediaApply(); + const preserveLocalState = message.reason === `local ${EVENTS.PLAY}` + || message.reason === `local ${EVENTS.PAUSE}` + || message.reason === `local ${EVENTS.SEEK}`; + cancelCanonicalMediaApply(null, null, preserveLocalState); sendResponse({ status: 'cancelled' }); return true; } @@ -1512,9 +1517,6 @@ if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; - if ([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE].includes(action)) { - cancelCanonicalMediaApply(); - } // 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 @@ -1548,6 +1550,10 @@ return; } } + + if (syncActions.includes(action)) { + cancelCanonicalMediaApply(); + } if (action === EVENTS.PLAY) { tryMediaAction(EVENTS.PLAY); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 6f0bd01..75331ab 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -87,6 +87,18 @@ async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) }, { tabId, mediaState })); } +async function sendContentServerCommand(context, extensionId, tabId, action, payload = {}) { + return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => { + return chrome.tabs.sendMessage(tabId, { + type: 'SERVER_COMMAND', + action, + payload, + actionTimestamp: Date.now(), + commandSenderId: 'e2e-newer-command' + }); + }, { tabId, action, payload })); +} + async function connectLegacyRelayClient(port) { const socket = new NodeWebSocket( `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=3.1.3&token=${OFFICIAL_SERVER_TOKEN}` @@ -496,6 +508,68 @@ test('local media input cancels an in-flight canonical recovery', async ({ conte expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); }); +test('newer server command clears local recovery state before a delayed apply resolves', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/simple-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId } = await selectTargetTab(context, extensionId, url); + await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true'); + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => { + await chrome.scripting.executeScript({ + target: { tabId: selectedTabId }, + world: 'ISOLATED', + func: () => { + const video = document.querySelector('#player'); + if (!video) throw new Error('canonical server-supersession fixture video missing'); + video.pause(); + video.currentTime = 0; + const nativePlay = video.play.bind(video); + video.dataset.koalaDelayedPlayAttempts = '0'; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; + video.dataset.koalaDelayedPlayAttempts = String(attempts); + return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400))); + } + }); + } + }); + }, tabId)); + + let applyResponse = null; + const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, { + revision: 21, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }).then(response => { + applyResponse = response; + return response; + }); + await expect.poll(async () => ({ + attempts: await page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0')), + response: applyResponse + })).toMatchObject({ attempts: 1, response: null }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false); + + await page.locator('#player').evaluate(video => { + video.pause(); + video.currentTime = 10; + }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true); + await sendContentServerCommand(context, extensionId, tabId, 'play'); + + await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' }); + await page.waitForTimeout(700); + expect(await page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2); + expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false); +}); + test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => { test.setTimeout(45_000); const relay = await import('../../server/index.js');