fix(media playback): stop hidden and offscreen media (#1164)

* fix(media playback): stop hidden and offscreen media

* fix(media playback): keep offscreen embeds suspended
This commit is contained in:
Tommaso Casaburi
2026-06-08 22:55:30 +07:00
committed by GitHub
parent 075b917c36
commit 245d3164c3
7 changed files with 665 additions and 36 deletions
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { observeOffscreenMediaPlayback, restoreSuspendedMediaPlayback } from '../media-playback-utils';
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];
callback: IntersectionObserverCallback;
observedElements = new Set<Element>();
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
MockIntersectionObserver.instances.push(this);
}
disconnect() {
this.observedElements.clear();
}
observe(element: Element) {
this.observedElements.add(element);
}
takeRecords() {
return [];
}
unobserve(element: Element) {
this.observedElements.delete(element);
}
trigger(target: Element, isIntersecting: boolean) {
this.callback(
[
{
intersectionRatio: isIntersecting ? 1 : 0,
isIntersecting,
target,
} as IntersectionObserverEntry,
],
this as unknown as IntersectionObserver,
);
}
}
const nextMicrotask = () => Promise.resolve();
const createExpandedMedia = () => {
const container = document.createElement('span');
container.dataset.expandedMedia = 'true';
const video = document.createElement('video');
video.src = 'https://cdn.example.com/video.webm';
const iframe = document.createElement('iframe');
iframe.src = 'https://player.example.com/embed';
const srcdocIframe = document.createElement('iframe');
srcdocIframe.srcdoc = '<p>embedded player</p>';
container.append(video, iframe, srcdocIframe);
return { container, iframe, srcdocIframe, video };
};
describe('media-playback-utils', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
MockIntersectionObserver.instances = [];
vi.stubGlobal('innerHeight', 600);
vi.stubGlobal('innerWidth', 800);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('suspends expanded media after it stays offscreen for the delay and restores iframe embeds when it returns', async () => {
const root = document.createElement('div');
const { container, iframe, srcdocIframe, video } = createExpandedMedia();
const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {});
root.append(container);
document.body.append(root);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
expect(observer.observedElements.has(container)).toBe(true);
observer.trigger(container, false);
await vi.advanceTimersByTimeAsync(999);
expect(pauseMock).not.toHaveBeenCalled();
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
await vi.advanceTimersByTimeAsync(1);
expect(pauseMock).toHaveBeenCalledTimes(1);
expect(iframe.getAttribute('src')).toBe('about:blank');
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
observer.trigger(container, true);
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
cleanup();
root.remove();
});
it('leaves offscreen iframe embeds suspended during visible-only restore until they intersect', async () => {
const root = document.createElement('div');
const { container, iframe, srcdocIframe, video } = createExpandedMedia();
vi.spyOn(video, 'pause').mockImplementation(() => {});
vi.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({
bottom: 900,
height: 300,
left: 0,
right: 400,
top: 600,
width: 400,
x: 0,
y: 600,
toJSON: () => '',
});
vi.spyOn(srcdocIframe, 'getBoundingClientRect').mockReturnValue({
bottom: 900,
height: 300,
left: 0,
right: 400,
top: 600,
width: 400,
x: 0,
y: 600,
toJSON: () => '',
});
root.append(container);
document.body.append(root);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
observer.trigger(container, false);
await vi.advanceTimersByTimeAsync(1000);
restoreSuspendedMediaPlayback(container, { visibleOnly: true });
expect(iframe.getAttribute('src')).toBe('about:blank');
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
observer.trigger(container, true);
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
cleanup();
root.remove();
});
it('cancels the offscreen suspend when expanded media returns before the delay passes', async () => {
const root = document.createElement('div');
const { container, iframe, video } = createExpandedMedia();
const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {});
root.append(container);
document.body.append(root);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
observer.trigger(container, false);
await vi.advanceTimersByTimeAsync(999);
observer.trigger(container, true);
await vi.advanceTimersByTimeAsync(1);
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);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
const { container } = createExpandedMedia();
root.append(container);
await nextMicrotask();
expect(observer.observedElements.has(container)).toBe(true);
cleanup();
root.remove();
});
it('observes playable media inserted into an expanded container after startup', async () => {
const root = document.createElement('div');
const container = document.createElement('span');
container.dataset.expandedMedia = 'true';
root.append(container);
document.body.append(root);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
const video = document.createElement('video');
expect(observer.observedElements.has(container)).toBe(false);
container.append(video);
await nextMicrotask();
expect(observer.observedElements.has(container)).toBe(true);
cleanup();
root.remove();
});
it('observes media when an existing playable container becomes expanded', async () => {
const root = document.createElement('div');
const { container } = createExpandedMedia();
container.removeAttribute('data-expanded-media');
root.append(container);
document.body.append(root);
const cleanup = observeOffscreenMediaPlayback(root);
const observer = MockIntersectionObserver.instances[0];
expect(observer.observedElements.has(container)).toBe(false);
container.dataset.expandedMedia = 'true';
await nextMicrotask();
expect(observer.observedElements.has(container)).toBe(true);
cleanup();
root.remove();
});
});
+243
View File
@@ -0,0 +1,243 @@
const BLANK_IFRAME_SRC = 'about:blank';
const EXPANDED_MEDIA_ATTRIBUTE = 'data-expanded-media';
const EXPANDED_MEDIA_SELECTOR = `[${EXPANDED_MEDIA_ATTRIBUTE}="true"]`;
const PLAYABLE_MEDIA_SELECTOR = 'video, audio, iframe';
const OFFSCREEN_MEDIA_SUSPEND_DELAY_MS = 1000;
interface SuspendedIframeState {
src: string | null;
srcdoc: string | null;
}
export interface SuspendedMediaPlaybackStats {
audioVideoCount: number;
iframeCount: number;
iframeSrcClearedCount: number;
iframeSrcdocClearedCount: number;
}
interface OffscreenMediaPlaybackOptions {
delayMs?: number;
selector?: string;
}
interface RestoreSuspendedMediaPlaybackOptions {
visibleOnly?: boolean;
}
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
const isElementInViewport = (element: Element): boolean => {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < viewportHeight && rect.left < viewportWidth;
};
export const suspendMediaPlayback = (container: ParentNode | null): SuspendedMediaPlaybackStats => {
const stats = {
audioVideoCount: 0,
iframeCount: 0,
iframeSrcClearedCount: 0,
iframeSrcdocClearedCount: 0,
};
if (!container) {
return stats;
}
for (const media of container.querySelectorAll<HTMLMediaElement>('video, audio')) {
stats.audioVideoCount += 1;
media.pause();
}
for (const iframe of container.querySelectorAll<HTMLIFrameElement>('iframe')) {
stats.iframeCount += 1;
const src = iframe.getAttribute('src');
const srcdoc = iframe.getAttribute('srcdoc');
if (!suspendedIframes.has(iframe)) {
suspendedIframes.set(iframe, { src, srcdoc });
}
if (src && src !== BLANK_IFRAME_SRC) {
iframe.setAttribute('src', BLANK_IFRAME_SRC);
stats.iframeSrcClearedCount += 1;
}
if (srcdoc) {
iframe.removeAttribute('srcdoc');
stats.iframeSrcdocClearedCount += 1;
}
}
return stats;
};
export const restoreSuspendedMediaPlayback = (container: ParentNode | null, options: RestoreSuspendedMediaPlaybackOptions = {}) => {
if (!container) {
return;
}
for (const iframe of container.querySelectorAll<HTMLIFrameElement>('iframe')) {
if (options.visibleOnly && !isElementInViewport(iframe)) {
continue;
}
const suspendedState = suspendedIframes.get(iframe);
if (!suspendedState) {
continue;
}
if (suspendedState.src) {
iframe.setAttribute('src', suspendedState.src);
} else {
iframe.removeAttribute('src');
}
if (suspendedState.srcdoc) {
iframe.setAttribute('srcdoc', suspendedState.srcdoc);
} else {
iframe.removeAttribute('srcdoc');
}
suspendedIframes.delete(iframe);
}
};
const hasPlayableMedia = (element: Element): boolean => Boolean(element.querySelector(PLAYABLE_MEDIA_SELECTOR));
export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => {
if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) {
return () => {};
}
const delayMs = options.delayMs ?? OFFSCREEN_MEDIA_SUSPEND_DELAY_MS;
const selector = options.selector ?? EXPANDED_MEDIA_SELECTOR;
const observedElements = new Set<Element>();
const pendingSuspends = new WeakMap<Element, ReturnType<typeof setTimeout>>();
const clearPendingSuspend = (element: Element) => {
const timeout = pendingSuspends.get(element);
if (timeout === undefined) {
return;
}
clearTimeout(timeout);
pendingSuspends.delete(element);
};
const intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
const target = entry.target;
clearPendingSuspend(target);
if (entry.isIntersecting || entry.intersectionRatio > 0) {
restoreSuspendedMediaPlayback(target);
continue;
}
const timeout = setTimeout(() => {
pendingSuspends.delete(target);
if (target.isConnected) {
suspendMediaPlayback(target);
}
}, delayMs);
pendingSuspends.set(target, timeout);
}
});
const observeElement = (element: Element) => {
if (observedElements.has(element) || !hasPlayableMedia(element)) {
return;
}
observedElements.add(element);
intersectionObserver.observe(element);
};
const syncElementObservation = (element: Element) => {
if (element.matches(selector) && hasPlayableMedia(element)) {
observeElement(element);
return;
}
unobserveElement(element);
};
const syncClosestMediaContainer = (element: Element) => {
const mediaElement = element.matches(selector) ? element : element.closest(selector);
if (mediaElement) {
syncElementObservation(mediaElement);
}
};
const observeTree = (node: ParentNode) => {
if (node instanceof Element && node.matches(selector)) {
observeElement(node);
}
for (const element of node.querySelectorAll(selector)) {
observeElement(element);
}
};
const unobserveElement = (element: Element) => {
clearPendingSuspend(element);
if (!observedElements.delete(element)) {
return;
}
intersectionObserver.unobserve(element);
};
const unobserveTree = (node: ParentNode) => {
if (node instanceof Element && node.matches(selector)) {
unobserveElement(node);
}
for (const element of node.querySelectorAll(selector)) {
unobserveElement(element);
}
};
observeTree(root);
const mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target instanceof Element) {
syncElementObservation(mutation.target);
continue;
}
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
observeTree(node);
syncClosestMediaContainer(node);
}
}
for (const node of mutation.removedNodes) {
if (node instanceof Element) {
unobserveTree(node);
}
}
if (mutation.target instanceof Element) {
syncClosestMediaContainer(mutation.target);
}
}
});
mutationObserver.observe(root, { attributeFilter: [EXPANDED_MEDIA_ATTRIBUTE], attributes: true, childList: true, subtree: true });
return () => {
mutationObserver.disconnect();
for (const element of observedElements) {
clearPendingSuspend(element);
intersectionObserver.unobserve(element);
}
observedElements.clear();
intersectionObserver.disconnect();
};
};