mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(media playback): keep partially visible videos playing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user