mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user