From 7fe3bd791146d71ab7fb606fc47d00971987d722 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 25 Jun 2026 23:44:32 +0700 Subject: [PATCH] fix(media playback): keep partially visible videos playing --- .../__tests__/media-playback-utils.test.ts | 59 ++++++++++++++++++- src/lib/utils/media-playback-utils.ts | 10 +++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/lib/utils/__tests__/media-playback-utils.test.ts b/src/lib/utils/__tests__/media-playback-utils.test.ts index 76768a3e..bc527079 100644 --- a/src/lib/utils/__tests__/media-playback-utils.test.ts +++ b/src/lib/utils/__tests__/media-playback-utils.test.ts @@ -28,11 +28,11 @@ class MockIntersectionObserver { this.observedElements.delete(element); } - trigger(target: Element, isIntersecting: boolean) { + trigger(target: Element, isIntersecting: boolean, intersectionRatio = isIntersecting ? 1 : 0) { this.callback( [ { - intersectionRatio: isIntersecting ? 1 : 0, + intersectionRatio, isIntersecting, target, } as IntersectionObserverEntry, @@ -60,6 +60,37 @@ const createExpandedMedia = () => { return { container, iframe, srcdocIframe, video }; }; +const createClientRect = ({ bottom, height, left, right, top, width }: Pick) => + ({ + bottom, + height, + left, + right, + top, + width, + x: left, + y: top, + toJSON: () => '', + }) as DOMRect; + +const VISIBLE_RECT = createClientRect({ + bottom: 300, + height: 300, + left: 0, + right: 400, + top: 0, + width: 400, +}); + +const PARTIALLY_VISIBLE_TOP_RECT = createClientRect({ + bottom: 1, + height: 300, + left: 0, + right: 400, + top: -299, + width: 400, +}); + describe('media-playback-utils', () => { beforeEach(() => { vi.useFakeTimers(); @@ -99,6 +130,7 @@ describe('media-playback-utils', () => { expect(iframe.getAttribute('src')).toBe('about:blank'); expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); + vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT); observer.trigger(container, true); expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); @@ -148,6 +180,7 @@ describe('media-playback-utils', () => { expect(iframe.getAttribute('src')).toBe('about:blank'); expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); + vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT); observer.trigger(container, true); expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); @@ -169,6 +202,7 @@ describe('media-playback-utils', () => { observer.trigger(container, false); await vi.advanceTimersByTimeAsync(999); + vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT); observer.trigger(container, true); await vi.advanceTimersByTimeAsync(1); @@ -179,6 +213,27 @@ describe('media-playback-utils', () => { root.remove(); }); + it('keeps expanded media active while the playable element is partially visible', async () => { + const root = document.createElement('div'); + const { container, iframe, video } = createExpandedMedia(); + const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {}); + vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(PARTIALLY_VISIBLE_TOP_RECT); + root.append(container); + document.body.append(root); + + const cleanup = observeOffscreenMediaPlayback(root); + const observer = MockIntersectionObserver.instances[0]; + + observer.trigger(container, false); + await vi.advanceTimersByTimeAsync(1000); + + expect(pauseMock).not.toHaveBeenCalled(); + expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); + + cleanup(); + root.remove(); + }); + it('observes expanded media added after the offscreen observer starts', async () => { const root = document.createElement('div'); document.body.append(root); diff --git a/src/lib/utils/media-playback-utils.ts b/src/lib/utils/media-playback-utils.ts index d55b7d34..c32f544c 100644 --- a/src/lib/utils/media-playback-utils.ts +++ b/src/lib/utils/media-playback-utils.ts @@ -27,6 +27,8 @@ interface RestoreSuspendedMediaPlaybackOptions { const suspendedIframes = new WeakMap(); +const getPlayableMediaElements = (element: Element): Element[] => [...element.querySelectorAll(PLAYABLE_MEDIA_SELECTOR)]; + const isElementInViewport = (element: Element): boolean => { const rect = element.getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight; @@ -106,7 +108,9 @@ export const restoreSuspendedMediaPlayback = (container: ParentNode | null, opti } }; -const hasPlayableMedia = (element: Element): boolean => Boolean(element.querySelector(PLAYABLE_MEDIA_SELECTOR)); +const hasPlayableMedia = (element: Element): boolean => getPlayableMediaElements(element).length > 0; + +const hasVisiblePlayableMedia = (element: Element): boolean => getPlayableMediaElements(element).some(isElementInViewport); export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => { if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) { @@ -133,14 +137,14 @@ export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: const target = entry.target; clearPendingSuspend(target); - if (entry.isIntersecting || entry.intersectionRatio > 0) { + if (hasVisiblePlayableMedia(target)) { restoreSuspendedMediaPlayback(target); continue; } const timeout = setTimeout(() => { pendingSuspends.delete(target); - if (target.isConnected) { + if (target.isConnected && !hasVisiblePlayableMedia(target)) { suspendMediaPlayback(target); } }, delayMs);