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);
|
this.observedElements.delete(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger(target: Element, isIntersecting: boolean) {
|
trigger(target: Element, isIntersecting: boolean, intersectionRatio = isIntersecting ? 1 : 0) {
|
||||||
this.callback(
|
this.callback(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
intersectionRatio: isIntersecting ? 1 : 0,
|
intersectionRatio,
|
||||||
isIntersecting,
|
isIntersecting,
|
||||||
target,
|
target,
|
||||||
} as IntersectionObserverEntry,
|
} as IntersectionObserverEntry,
|
||||||
@@ -60,6 +60,37 @@ const createExpandedMedia = () => {
|
|||||||
return { container, iframe, srcdocIframe, video };
|
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', () => {
|
describe('media-playback-utils', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -99,6 +130,7 @@ describe('media-playback-utils', () => {
|
|||||||
expect(iframe.getAttribute('src')).toBe('about:blank');
|
expect(iframe.getAttribute('src')).toBe('about:blank');
|
||||||
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
||||||
|
|
||||||
|
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
|
||||||
observer.trigger(container, true);
|
observer.trigger(container, true);
|
||||||
|
|
||||||
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
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(iframe.getAttribute('src')).toBe('about:blank');
|
||||||
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
||||||
|
|
||||||
|
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
|
||||||
observer.trigger(container, true);
|
observer.trigger(container, true);
|
||||||
|
|
||||||
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||||
@@ -169,6 +202,7 @@ describe('media-playback-utils', () => {
|
|||||||
|
|
||||||
observer.trigger(container, false);
|
observer.trigger(container, false);
|
||||||
await vi.advanceTimersByTimeAsync(999);
|
await vi.advanceTimersByTimeAsync(999);
|
||||||
|
vi.spyOn(video, 'getBoundingClientRect').mockReturnValue(VISIBLE_RECT);
|
||||||
observer.trigger(container, true);
|
observer.trigger(container, true);
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
@@ -179,6 +213,27 @@ describe('media-playback-utils', () => {
|
|||||||
root.remove();
|
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 () => {
|
it('observes expanded media added after the offscreen observer starts', async () => {
|
||||||
const root = document.createElement('div');
|
const root = document.createElement('div');
|
||||||
document.body.append(root);
|
document.body.append(root);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface RestoreSuspendedMediaPlaybackOptions {
|
|||||||
|
|
||||||
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
|
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
|
||||||
|
|
||||||
|
const getPlayableMediaElements = (element: Element): Element[] => [...element.querySelectorAll(PLAYABLE_MEDIA_SELECTOR)];
|
||||||
|
|
||||||
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;
|
||||||
@@ -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 = {}) => {
|
export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => {
|
||||||
if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) {
|
if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) {
|
||||||
@@ -133,14 +137,14 @@ export const observeOffscreenMediaPlayback = (root: ParentNode | null, options:
|
|||||||
const target = entry.target;
|
const target = entry.target;
|
||||||
clearPendingSuspend(target);
|
clearPendingSuspend(target);
|
||||||
|
|
||||||
if (entry.isIntersecting || entry.intersectionRatio > 0) {
|
if (hasVisiblePlayableMedia(target)) {
|
||||||
restoreSuspendedMediaPlayback(target);
|
restoreSuspendedMediaPlayback(target);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
pendingSuspends.delete(target);
|
pendingSuspends.delete(target);
|
||||||
if (target.isConnected) {
|
if (target.isConnected && !hasVisiblePlayableMedia(target)) {
|
||||||
suspendMediaPlayback(target);
|
suspendMediaPlayback(target);
|
||||||
}
|
}
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
|
|||||||
Reference in New Issue
Block a user