diff --git a/extension/background.js b/extension/background.js index a8f0aeb..74d9b8d 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2510,7 +2510,7 @@ async function injectMediaFrameMonitors(tabId, contentTarget) { await executeScriptWithTimeout({ target, files: ['media-frame-monitor.js'] - }, 2000); + }, 750); injectedCount++; } catch { // One denied widget frame must not block the selected player. @@ -2528,7 +2528,7 @@ async function injectMediaFrameMonitors(tabId, contentTarget) { await executeScriptWithTimeout({ target, files: ['media-frame-monitor.js'] - }, 2000); + }, 750); injectedCount++; } catch { // Main injection below reports a real selected-target failure. diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 3d7284a..1c8b211 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -4,7 +4,11 @@ export const MEDIA_FRAME_PROBE_TIMEOUT = 'media_frame_probe_timeout'; const MIN_PLAYER_FRAME_AREA = 320 * 180; const MIN_PLAYER_ASPECT_RATIO = 1.15; const MAX_PLAYER_ASPECT_RATIO = 2.6; -const DEFAULT_PROBE_TIMEOUT_MS = 2000; +// inspectMediaFrame is synchronous DOM work: a live frame answers in tens of +// milliseconds, and anything slower is a frame that is navigating or being torn +// down. Waiting seconds for those only delays the answer — a frame dropped here +// is re-probed on the next attempt and reports itself through its monitor. +const DEFAULT_PROBE_TIMEOUT_MS = 750; function normalizeFrameId(value) { return Number.isInteger(value) && value >= 0 ? value : 0; @@ -627,7 +631,14 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } } - if (results.length > 1) { + const candidateCount = results.filter( + entry => entry?.result?.bestVideo?.rendered === true + ).length; + // The visibility handshake only exists to rank and exclude video + // candidates. With no video on the page yet there is nothing to rank, and + // running it anyway cost several seconds on every attempt — the whole + // reason selecting an anime tab before playback felt broken. + if (results.length > 1 && candidateCount > 0) { const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`; // Address each discovered frame on its own from here on. A single // allFrames call is all-or-nothing: one player or ad frame that @@ -653,8 +664,15 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { [token], visibilityTimeoutMs ); - // Four passes match the maximum same-origin recursion depth. - for (let pass = 0; pass < 4; pass++) { + // One pass per nesting level actually present. Four was the + // worst case, not the common one; these players sit two levels + // down and each surplus pass is a full round trip. + const observedDepth = results.reduce((deepest, entry) => Math.max( + deepest, + ...(entry?.result?.embeddedFrames || []).map(frame => frame.depth || 1) + ), 1); + const passes = Math.min(4, Math.max(2, observedDepth)); + for (let pass = 0; pass < passes; pass++) { await executeInAccessibleFrames( chromeApi, frameTargets, @@ -712,6 +730,18 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { if (Number.isFinite(deadlineMs) && deadlineMs > 0 && Date.now() - startedAt >= deadlineMs) { break; } + // Nothing on the page has a video element yet. Spending the retry budget + // cannot change that; the injected monitor reports the player the moment + // it is created, so return now and let selection be instant. + const anyVideo = results.some(entry => (entry?.result?.videoCount || 0) > 0); + if (!anyVideo) { + // Discovery worked and simply found no player yet, so stop: the + // monitor reports one within a fraction of a second once it exists. + // Retry only when the sweep itself came back thin, which is the case + // a second pass can actually fix. + if (results.length > 1) break; + if (attempt >= 1) break; + } if (attempt < attempts - 1) { await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index 759db2e..3695a5d 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -192,12 +192,41 @@ describe('cross-origin media-frame targeting', () => { const visibilityDispatches = executeScript.mock.calls.filter(([options]) => ( options.func?.name === 'dispatchParentFrameVisibilityProbe' )); - // Four passes across both discovered frames, each addressed on its own - // so a frame that never answers cannot cancel the others. - expect(visibilityDispatches).toHaveLength(8); + // Two passes — the floor — across both discovered frames, each addressed + // on its own so a frame that never answers cannot cancel the others. + // These fixtures report no nesting, so the depth-scaled pass count must + // not spend the worst-case four round trips here. + expect(visibilityDispatches).toHaveLength(4); expect(visibilityDispatches.every(([options]) => options.target.allFrames !== true)).toBe(true); }); + it('skips the visibility handshake when no frame has a video yet', async () => { + // Selecting an anime tab before playback must be immediate: there is + // nothing to rank, so the handshake and the retry budget are pure delay. + const results = [ + frame(0, { bestVideo: null, videoCount: 0 }), + frame(3, { bestVideo: null, videoCount: 0 }), + frame(4, { bestVideo: null, videoCount: 0 }) + ]; + const executeScript = vi.fn().mockResolvedValue(results); + await expect(resolveMediaContentTarget( + { scripting: { executeScript } }, + 42, + { probeDelayMs: 0, retryDelayMs: 0 } + )).resolves.toMatchObject({ frameId: 0, hasVideo: false }); + + expect(executeScript.mock.calls.filter(([options]) => ( + options.func?.name === 'dispatchParentFrameVisibilityProbe' + || options.func?.name === 'installParentFrameVisibilityProbe' + ))).toHaveLength(0); + // And it must not burn all eight attempts waiting for a video that no + // frame has: the injected monitor reports one the moment it appears. + const inspections = executeScript.mock.calls.filter(([options]) => ( + options.func?.name === 'inspectMediaFrame' + )); + expect(inspections.length).toBeLessThanOrEqual(4); + }); + it('keeps the top target inactive when the only discovered video is hidden', async () => { const results = [ frame(0, { bestVideo: video({ rendered: false }) }),