fix(media): pause other videos when a new one starts playing

Only one inline video should play at a time across the page. Wire exclusive
video playback into the existing offscreen media suspend hook.
This commit is contained in:
Tommaso Casaburi
2026-06-26 15:42:06 +07:00
parent 31197bb7e0
commit 24e340e4b6
3 changed files with 139 additions and 3 deletions
@@ -1,8 +1,16 @@
import { useEffect } from 'react';
import { observeOffscreenMediaPlayback } from '../lib/utils/media-playback-utils';
import { observeExclusiveVideoPlayback, observeOffscreenMediaPlayback } from '../lib/utils/media-playback-utils';
const useSuspendOffscreenMediaPlayback = () => {
useEffect(() => observeOffscreenMediaPlayback(document.body), []);
useEffect(() => {
const cleanupOffscreenPlayback = observeOffscreenMediaPlayback(document.body);
const cleanupExclusiveVideoPlayback = observeExclusiveVideoPlayback(document);
return () => {
cleanupExclusiveVideoPlayback();
cleanupOffscreenPlayback();
};
}, []);
};
export default useSuspendOffscreenMediaPlayback;
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { observeOffscreenMediaPlayback, restoreSuspendedMediaPlayback } from '../media-playback-utils';
import { observeExclusiveVideoPlayback, observeOffscreenMediaPlayback, restoreSuspendedMediaPlayback } from '../media-playback-utils';
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];
@@ -44,6 +44,34 @@ class MockIntersectionObserver {
const nextMicrotask = () => Promise.resolve();
const mockVideoPlayback = (video: HTMLVideoElement, playing = false) => {
let paused = !playing;
Object.defineProperty(video, 'paused', {
configurable: true,
get: () => paused,
});
const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {
paused = true;
});
return {
pauseMock,
dispatchEnded: () => {
paused = true;
video.dispatchEvent(new Event('ended'));
},
dispatchPause: () => {
paused = true;
video.dispatchEvent(new Event('pause'));
},
dispatchPlay: () => {
paused = false;
video.dispatchEvent(new Event('play'));
},
};
};
const createExpandedMedia = () => {
const container = document.createElement('span');
container.dataset.expandedMedia = 'true';
@@ -140,6 +168,66 @@ describe('media-playback-utils', () => {
root.remove();
});
it('pauses the previous video when a different video starts playing', () => {
const root = document.createElement('div');
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
const firstPlayback = mockVideoPlayback(firstVideo);
const secondPlayback = mockVideoPlayback(secondVideo);
root.append(firstVideo, secondVideo);
const cleanup = observeExclusiveVideoPlayback(root);
firstPlayback.dispatchPlay();
secondPlayback.dispatchPlay();
expect(firstPlayback.pauseMock).toHaveBeenCalledTimes(1);
expect(secondPlayback.pauseMock).not.toHaveBeenCalled();
cleanup();
});
it('does not pause the previous video after it becomes inactive', () => {
const root = document.createElement('div');
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
const firstPlayback = mockVideoPlayback(firstVideo);
const secondPlayback = mockVideoPlayback(secondVideo);
root.append(firstVideo, secondVideo);
const cleanup = observeExclusiveVideoPlayback(root);
firstPlayback.dispatchPlay();
firstPlayback.dispatchPause();
secondPlayback.dispatchPlay();
expect(firstPlayback.pauseMock).not.toHaveBeenCalled();
secondPlayback.dispatchEnded();
firstPlayback.dispatchPlay();
expect(secondPlayback.pauseMock).not.toHaveBeenCalled();
cleanup();
});
it('stops enforcing exclusive video playback after cleanup', () => {
const root = document.createElement('div');
const firstVideo = document.createElement('video');
const secondVideo = document.createElement('video');
const firstPlayback = mockVideoPlayback(firstVideo);
const secondPlayback = mockVideoPlayback(secondVideo);
root.append(firstVideo, secondVideo);
const cleanup = observeExclusiveVideoPlayback(root);
firstPlayback.dispatchPlay();
cleanup();
secondPlayback.dispatchPlay();
expect(firstPlayback.pauseMock).not.toHaveBeenCalled();
});
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();
+40
View File
@@ -29,6 +29,8 @@ const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
const getPlayableMediaElements = (element: Element): Element[] => [...element.querySelectorAll(PLAYABLE_MEDIA_SELECTOR)];
const isVideoElement = (target: EventTarget | null): target is HTMLVideoElement => typeof HTMLVideoElement !== 'undefined' && target instanceof HTMLVideoElement;
const isElementInViewport = (element: Element): boolean => {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
@@ -245,3 +247,41 @@ export const observeOffscreenMediaPlayback = (root: ParentNode | null, options:
intersectionObserver.disconnect();
};
};
export const observeExclusiveVideoPlayback = (root: EventTarget | null) => {
if (!root || typeof window === 'undefined') {
return () => {};
}
let activeVideo: HTMLVideoElement | null = null;
const handlePlay = (event: Event) => {
const video = isVideoElement(event.target) ? event.target : null;
if (!video) {
return;
}
if (activeVideo && activeVideo !== video && !activeVideo.paused) {
activeVideo.pause();
}
activeVideo = video;
};
const handleInactive = (event: Event) => {
if (event.target === activeVideo) {
activeVideo = null;
}
};
root.addEventListener('play', handlePlay, true);
root.addEventListener('pause', handleInactive, true);
root.addEventListener('ended', handleInactive, true);
return () => {
root.removeEventListener('play', handlePlay, true);
root.removeEventListener('pause', handleInactive, true);
root.removeEventListener('ended', handleInactive, true);
activeVideo = null;
};
};