fix(media playback): keep partially visible videos playing

This commit is contained in:
Tommaso Casaburi
2026-06-25 23:44:32 +07:00
parent 23706a86db
commit 7fe3bd7911
2 changed files with 64 additions and 5 deletions
@@ -28,11 +28,11 @@ class MockIntersectionObserver {
this.observedElements.delete(element); this.observedElements.delete(element);
} }
trigger(target: Element, isIntersecting: boolean) { trigger(target: Element, isIntersecting: boolean, intersectionRatio = isIntersecting ? 1 : 0) {
this.callback( this.callback(
[ [
{ {
intersectionRatio: isIntersecting ? 1 : 0, intersectionRatio,
isIntersecting, isIntersecting,
target, target,
} as IntersectionObserverEntry, } as IntersectionObserverEntry,
@@ -60,6 +60,37 @@ const createExpandedMedia = () => {
return { container, iframe, srcdocIframe, video }; return { container, iframe, srcdocIframe, video };
}; };
const createClientRect = ({ bottom, height, left, right, top, width }: Pick<DOMRect, 'bottom' | 'height' | 'left' | 'right' | 'top' | 'width'>) =>
({
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', () => { describe('media-playback-utils', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -99,6 +130,7 @@ describe('media-playback-utils', () => {
expect(iframe.getAttribute('src')).toBe('about:blank'); expect(iframe.getAttribute('src')).toBe('about:blank');
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
observer.trigger(container, true); observer.trigger(container, true);
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); 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(iframe.getAttribute('src')).toBe('about:blank');
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull(); expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
observer.trigger(container, true); observer.trigger(container, true);
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed'); expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
@@ -169,6 +202,7 @@ describe('media-playback-utils', () => {
observer.trigger(container, false); observer.trigger(container, false);
await vi.advanceTimersByTimeAsync(999); await vi.advanceTimersByTimeAsync(999);
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
observer.trigger(container, true); observer.trigger(container, true);
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
@@ -179,6 +213,27 @@ describe('media-playback-utils', () => {
root.remove(); 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 () => { it('observes expanded media added after the offscreen observer starts', async () => {
const root = document.createElement('div'); const root = document.createElement('div');
document.body.append(root); document.body.append(root);
+7 -3
View File
@@ -27,6 +27,8 @@ interface RestoreSuspendedMediaPlaybackOptions {
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>(); const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
const getPlayableMediaElements = (element: Element): Element[] => [...element.querySelectorAll(PLAYABLE_MEDIA_SELECTOR)];
const isElementInViewport = (element: Element): boolean => { const isElementInViewport = (element: Element): boolean => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight; 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 = {}) => { export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => {
if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) { if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) {
@@ -133,14 +137,14 @@ export const observeOffscreenMediaPlayback = (root: ParentNode | null, options:
const target = entry.target; const target = entry.target;
clearPendingSuspend(target); clearPendingSuspend(target);
if (entry.isIntersecting || entry.intersectionRatio > 0) { if (hasVisiblePlayableMedia(target)) {
restoreSuspendedMediaPlayback(target); restoreSuspendedMediaPlayback(target);
continue; continue;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
pendingSuspends.delete(target); pendingSuspends.delete(target);
if (target.isConnected) { if (target.isConnected && !hasVisiblePlayableMedia(target)) {
suspendMediaPlayback(target); suspendMediaPlayback(target);
} }
}, delayMs); }, delayMs);