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);
}
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<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', () => {
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);
+7 -3
View File
@@ -27,6 +27,8 @@ interface RestoreSuspendedMediaPlaybackOptions {
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
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);