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 { useEffect } from 'react';
|
||||||
import { observeOffscreenMediaPlayback } from '../lib/utils/media-playback-utils';
|
import { observeExclusiveVideoPlayback, observeOffscreenMediaPlayback } from '../lib/utils/media-playback-utils';
|
||||||
|
|
||||||
const useSuspendOffscreenMediaPlayback = () => {
|
const useSuspendOffscreenMediaPlayback = () => {
|
||||||
useEffect(() => observeOffscreenMediaPlayback(document.body), []);
|
useEffect(() => {
|
||||||
|
const cleanupOffscreenPlayback = observeOffscreenMediaPlayback(document.body);
|
||||||
|
const cleanupExclusiveVideoPlayback = observeExclusiveVideoPlayback(document);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupExclusiveVideoPlayback();
|
||||||
|
cleanupOffscreenPlayback();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useSuspendOffscreenMediaPlayback;
|
export default useSuspendOffscreenMediaPlayback;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 {
|
class MockIntersectionObserver {
|
||||||
static instances: MockIntersectionObserver[] = [];
|
static instances: MockIntersectionObserver[] = [];
|
||||||
@@ -44,6 +44,34 @@ class MockIntersectionObserver {
|
|||||||
|
|
||||||
const nextMicrotask = () => Promise.resolve();
|
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 createExpandedMedia = () => {
|
||||||
const container = document.createElement('span');
|
const container = document.createElement('span');
|
||||||
container.dataset.expandedMedia = 'true';
|
container.dataset.expandedMedia = 'true';
|
||||||
@@ -140,6 +168,66 @@ describe('media-playback-utils', () => {
|
|||||||
root.remove();
|
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 () => {
|
it('leaves offscreen iframe embeds suspended during visible-only restore until they intersect', async () => {
|
||||||
const root = document.createElement('div');
|
const root = document.createElement('div');
|
||||||
const { container, iframe, srcdocIframe, video } = createExpandedMedia();
|
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 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 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;
|
||||||
@@ -245,3 +247,41 @@ export const observeOffscreenMediaPlayback = (root: ParentNode | null, options:
|
|||||||
intersectionObserver.disconnect();
|
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