diff --git a/extension/content.js b/extension/content.js index b96816d..9a41c01 100644 --- a/extension/content.js +++ b/extension/content.js @@ -681,12 +681,22 @@ const view = element.ownerDocument?.defaultView; const viewportWidth = Number(view?.innerWidth); const viewportHeight = Number(view?.innerHeight); + const scrollX = Number(view?.scrollX) || 0; + const scrollY = Number(view?.scrollY) || 0; + const scrollWidth = Number(element.ownerDocument?.documentElement?.scrollWidth); + const scrollHeight = Number(element.ownerDocument?.documentElement?.scrollHeight); if (Number.isFinite(viewportWidth) && Number.isFinite(viewportHeight) && viewportWidth > 0 && viewportHeight > 0) { - return rect.bottom > 0 - && rect.right > 0 - && rect.top < viewportHeight - && rect.left < viewportWidth; + const layoutWidth = Number.isFinite(scrollWidth) && scrollWidth > 0 + ? Math.max(viewportWidth, scrollWidth) + : viewportWidth; + const layoutHeight = Number.isFinite(scrollHeight) && scrollHeight > 0 + ? Math.max(viewportHeight, scrollHeight) + : viewportHeight; + return rect.bottom + scrollY > 0 + && rect.right + scrollX > 0 + && rect.top + scrollY < layoutHeight + && rect.left + scrollX < layoutWidth; } return true; } diff --git a/extension/media-frame-monitor.js b/extension/media-frame-monitor.js index 040546f..74da5fc 100644 --- a/extension/media-frame-monitor.js +++ b/extension/media-frame-monitor.js @@ -44,12 +44,16 @@ const browserReportsVisible = typeof element.checkVisibility === 'function' ? element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : true; + const layoutWidth = Math.max(window.innerWidth, document.documentElement?.scrollWidth || 0); + const layoutHeight = Math.max(window.innerHeight, document.documentElement?.scrollHeight || 0); + const scrollX = Number(window.scrollX) || 0; + const scrollY = Number(window.scrollY) || 0; const visible = rect.width > 0 && rect.height > 0 - && rect.bottom > 0 - && rect.right > 0 - && rect.top < window.innerHeight - && rect.left < window.innerWidth + && rect.bottom + scrollY > 0 + && rect.right + scrollX > 0 + && rect.top + scrollY < layoutHeight + && rect.left + scrollX < layoutWidth && browserReportsVisible && elementStylesAllowRendering(element) && style.display !== 'none' diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index a4a92c7..a75e3b0 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -61,10 +61,14 @@ export function inspectMediaFrame(expectedVisibilityToken = null) { && !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) { return false; } - return rect.bottom > 0 - && rect.right > 0 - && rect.top < view.innerHeight - && rect.left < view.innerWidth; + const layoutWidth = Math.max(view.innerWidth, element.ownerDocument?.documentElement?.scrollWidth || 0); + const layoutHeight = Math.max(view.innerHeight, element.ownerDocument?.documentElement?.scrollHeight || 0); + const scrollX = Number(view.scrollX) || 0; + const scrollY = Number(view.scrollY) || 0; + return rect.bottom + scrollY > 0 + && rect.right + scrollX > 0 + && rect.top + scrollY < layoutHeight + && rect.left + scrollX < layoutWidth; }; const collectVideos = (doc, depth = 0, ancestorVisible = true, videos = [], seen = new Set()) => { @@ -252,15 +256,19 @@ export function dispatchParentFrameVisibilityProbe(token) { const rect = frame.getBoundingClientRect(); const style = window.getComputedStyle(frame); const area = Math.max(0, rect.width) * Math.max(0, rect.height); - const intersectsViewport = rect.bottom > 0 - && rect.right > 0 - && rect.top < window.innerHeight - && rect.left < window.innerWidth; + const layoutWidth = Math.max(window.innerWidth, document.documentElement?.scrollWidth || 0); + const layoutHeight = Math.max(window.innerHeight, document.documentElement?.scrollHeight || 0); + const scrollX = Number(window.scrollX) || 0; + const scrollY = Number(window.scrollY) || 0; + const intersectsLayout = rect.bottom + scrollY > 0 + && rect.right + scrollX > 0 + && rect.top + scrollY < layoutHeight + && rect.left + scrollX < layoutWidth; const browserReportsVisible = typeof frame.checkVisibility === 'function' ? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : true; const directlyVisible = area > 0 - && intersectsViewport + && intersectsLayout && browserReportsVisible && style.display !== 'none' && style.visibility !== 'hidden' diff --git a/scripts/test-content-video-finder.cjs b/scripts/test-content-video-finder.cjs index 0a42805..42643a0 100644 --- a/scripts/test-content-video-finder.cjs +++ b/scripts/test-content-video-finder.cjs @@ -127,16 +127,25 @@ assert.strictEqual( 'a hidden single candidate is not returned as an active player' ); -function attachRenderEnvironment(documentNode, elements, { frameElement = null } = {}) { +function attachRenderEnvironment(documentNode, elements, { + frameElement = null, + scrollWidth = 1000, + scrollHeight = 700, + scrollX = 0, + scrollY = 0 +} = {}) { const view = { innerWidth: 1000, innerHeight: 700, + scrollX, + scrollY, frameElement, getComputedStyle(element) { return element._style || { display: 'block', visibility: 'visible', opacity: '1' }; } }; documentNode.defaultView = view; + documentNode.documentElement = { scrollWidth, scrollHeight }; for (const element of elements) { element.ownerDocument = documentNode; element.getBoundingClientRect = () => element._rect || { @@ -175,6 +184,57 @@ assert.strictEqual( 'a hidden playing preload must not outrank the visible paused player' ); +const belowFoldPlayer = makeVideo('below-fold-player', 800, 450, { + controls: true, + duration: 1200 +}); +belowFoldPlayer._rect = { + width: 800, height: 450, top: 900, left: 0, right: 800, bottom: 1350 +}; +const offscreenDecoy = makeVideo('offscreen-decoy', 900, 506, { + controls: true, + paused: false, + duration: 1200 +}); +offscreenDecoy._rect = { + width: 900, height: 506, top: 0, left: -2000, right: -1100, bottom: 506 +}; +const scrollableDocument = { + querySelectorAll(selector) { + if (selector === 'video') return [offscreenDecoy, belowFoldPlayer]; + return []; + } +}; +attachRenderEnvironment(scrollableDocument, [offscreenDecoy, belowFoldPlayer], { scrollHeight: 1500 }); +assert.strictEqual( + findVideo(scrollableDocument), + belowFoldPlayer, + 'a player below the fold remains eligible while a positioned offscreen decoy does not' +); + +const scrolledPastPlayer = makeVideo('scrolled-past-player', 800, 450, { + controls: true, + duration: 1200 +}); +scrolledPastPlayer._rect = { + width: 800, height: 450, top: -800, left: 0, right: 800, bottom: -350 +}; +const scrolledDocument = { + querySelectorAll(selector) { + if (selector === 'video') return [scrolledPastPlayer]; + return []; + } +}; +attachRenderEnvironment(scrolledDocument, [scrolledPastPlayer], { + scrollHeight: 1500, + scrollY: 900 +}); +assert.strictEqual( + findVideo(scrolledDocument), + scrolledPastPlayer, + 'a player above the current viewport remains eligible when it is inside the document layout' +); + const framedHiddenVideo = makeVideo('framed-hidden', 900, 506, { controls: true, paused: false, diff --git a/tests/e2e/helpers/content-source.mjs b/tests/e2e/helpers/content-source.mjs index beb09e3..66a57c7 100644 --- a/tests/e2e/helpers/content-source.mjs +++ b/tests/e2e/helpers/content-source.mjs @@ -35,12 +35,16 @@ export function extractFunction(name, source = fs.readFileSync(contentPath, 'utf export const VIDEO_FINDER_EXPORTS = [ 'findVideo', 'collectVideoCandidates', + 'getElementRenderBox', + 'elementStylesAllowRendering', + 'isElementRendered', 'getRenderedVideoArea', 'getVideoSizeBucket', 'isVideoRendered', 'hasPlayableVideoSource', 'isBackgroundVideo', 'isVideoPlaying', + 'isShortUncontrolledVideo', 'compareVideoRanks', 'pickBestVideo' ];