mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(media playback): stop hidden and offscreen media (#1164)
* fix(media playback): stop hidden and offscreen media * fix(media playback): keep offscreen embeds suspended
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { observeOffscreenMediaPlayback, restoreSuspendedMediaPlayback } from '../media-playback-utils';
|
||||
|
||||
class MockIntersectionObserver {
|
||||
static instances: MockIntersectionObserver[] = [];
|
||||
|
||||
callback: IntersectionObserverCallback;
|
||||
observedElements = new Set<Element>();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
MockIntersectionObserver.instances.push(this);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observedElements.clear();
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.observedElements.add(element);
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
this.observedElements.delete(element);
|
||||
}
|
||||
|
||||
trigger(target: Element, isIntersecting: boolean) {
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
intersectionRatio: isIntersecting ? 1 : 0,
|
||||
isIntersecting,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
this as unknown as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextMicrotask = () => Promise.resolve();
|
||||
|
||||
const createExpandedMedia = () => {
|
||||
const container = document.createElement('span');
|
||||
container.dataset.expandedMedia = 'true';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.src = 'https://cdn.example.com/video.webm';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = 'https://player.example.com/embed';
|
||||
const srcdocIframe = document.createElement('iframe');
|
||||
srcdocIframe.srcdoc = '<p>embedded player</p>';
|
||||
|
||||
container.append(video, iframe, srcdocIframe);
|
||||
|
||||
return { container, iframe, srcdocIframe, video };
|
||||
};
|
||||
|
||||
describe('media-playback-utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
MockIntersectionObserver.instances = [];
|
||||
vi.stubGlobal('innerHeight', 600);
|
||||
vi.stubGlobal('innerWidth', 800);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('suspends expanded media after it stays offscreen for the delay and restores iframe embeds when it returns', async () => {
|
||||
const root = document.createElement('div');
|
||||
const { container, iframe, srcdocIframe, video } = createExpandedMedia();
|
||||
const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {});
|
||||
root.append(container);
|
||||
document.body.append(root);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(true);
|
||||
|
||||
observer.trigger(container, false);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
|
||||
expect(pauseMock).not.toHaveBeenCalled();
|
||||
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(pauseMock).toHaveBeenCalledTimes(1);
|
||||
expect(iframe.getAttribute('src')).toBe('about:blank');
|
||||
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
||||
|
||||
observer.trigger(container, true);
|
||||
|
||||
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
|
||||
|
||||
cleanup();
|
||||
root.remove();
|
||||
});
|
||||
|
||||
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();
|
||||
vi.spyOn(video, 'pause').mockImplementation(() => {});
|
||||
vi.spyOn(iframe, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 900,
|
||||
height: 300,
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 600,
|
||||
width: 400,
|
||||
x: 0,
|
||||
y: 600,
|
||||
toJSON: () => '',
|
||||
});
|
||||
vi.spyOn(srcdocIframe, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 900,
|
||||
height: 300,
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 600,
|
||||
width: 400,
|
||||
x: 0,
|
||||
y: 600,
|
||||
toJSON: () => '',
|
||||
});
|
||||
root.append(container);
|
||||
document.body.append(root);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
observer.trigger(container, false);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
restoreSuspendedMediaPlayback(container, { visibleOnly: true });
|
||||
|
||||
expect(iframe.getAttribute('src')).toBe('about:blank');
|
||||
expect(srcdocIframe.getAttribute('srcdoc')).toBeNull();
|
||||
|
||||
observer.trigger(container, true);
|
||||
|
||||
expect(iframe.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||
expect(srcdocIframe.getAttribute('srcdoc')).toBe('<p>embedded player</p>');
|
||||
|
||||
cleanup();
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it('cancels the offscreen suspend when expanded media returns before the delay passes', async () => {
|
||||
const root = document.createElement('div');
|
||||
const { container, iframe, video } = createExpandedMedia();
|
||||
const pauseMock = vi.spyOn(video, 'pause').mockImplementation(() => {});
|
||||
root.append(container);
|
||||
document.body.append(root);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
observer.trigger(container, false);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
observer.trigger(container, true);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
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);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const { container } = createExpandedMedia();
|
||||
|
||||
root.append(container);
|
||||
await nextMicrotask();
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(true);
|
||||
|
||||
cleanup();
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it('observes playable media inserted into an expanded container after startup', async () => {
|
||||
const root = document.createElement('div');
|
||||
const container = document.createElement('span');
|
||||
container.dataset.expandedMedia = 'true';
|
||||
root.append(container);
|
||||
document.body.append(root);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const video = document.createElement('video');
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(false);
|
||||
|
||||
container.append(video);
|
||||
await nextMicrotask();
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(true);
|
||||
|
||||
cleanup();
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it('observes media when an existing playable container becomes expanded', async () => {
|
||||
const root = document.createElement('div');
|
||||
const { container } = createExpandedMedia();
|
||||
container.removeAttribute('data-expanded-media');
|
||||
root.append(container);
|
||||
document.body.append(root);
|
||||
|
||||
const cleanup = observeOffscreenMediaPlayback(root);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(false);
|
||||
|
||||
container.dataset.expandedMedia = 'true';
|
||||
await nextMicrotask();
|
||||
|
||||
expect(observer.observedElements.has(container)).toBe(true);
|
||||
|
||||
cleanup();
|
||||
root.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
const BLANK_IFRAME_SRC = 'about:blank';
|
||||
const EXPANDED_MEDIA_ATTRIBUTE = 'data-expanded-media';
|
||||
const EXPANDED_MEDIA_SELECTOR = `[${EXPANDED_MEDIA_ATTRIBUTE}="true"]`;
|
||||
const PLAYABLE_MEDIA_SELECTOR = 'video, audio, iframe';
|
||||
const OFFSCREEN_MEDIA_SUSPEND_DELAY_MS = 1000;
|
||||
|
||||
interface SuspendedIframeState {
|
||||
src: string | null;
|
||||
srcdoc: string | null;
|
||||
}
|
||||
|
||||
export interface SuspendedMediaPlaybackStats {
|
||||
audioVideoCount: number;
|
||||
iframeCount: number;
|
||||
iframeSrcClearedCount: number;
|
||||
iframeSrcdocClearedCount: number;
|
||||
}
|
||||
|
||||
interface OffscreenMediaPlaybackOptions {
|
||||
delayMs?: number;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
interface RestoreSuspendedMediaPlaybackOptions {
|
||||
visibleOnly?: boolean;
|
||||
}
|
||||
|
||||
const suspendedIframes = new WeakMap<HTMLIFrameElement, SuspendedIframeState>();
|
||||
|
||||
const isElementInViewport = (element: Element): boolean => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < viewportHeight && rect.left < viewportWidth;
|
||||
};
|
||||
|
||||
export const suspendMediaPlayback = (container: ParentNode | null): SuspendedMediaPlaybackStats => {
|
||||
const stats = {
|
||||
audioVideoCount: 0,
|
||||
iframeCount: 0,
|
||||
iframeSrcClearedCount: 0,
|
||||
iframeSrcdocClearedCount: 0,
|
||||
};
|
||||
|
||||
if (!container) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
for (const media of container.querySelectorAll<HTMLMediaElement>('video, audio')) {
|
||||
stats.audioVideoCount += 1;
|
||||
media.pause();
|
||||
}
|
||||
|
||||
for (const iframe of container.querySelectorAll<HTMLIFrameElement>('iframe')) {
|
||||
stats.iframeCount += 1;
|
||||
const src = iframe.getAttribute('src');
|
||||
const srcdoc = iframe.getAttribute('srcdoc');
|
||||
|
||||
if (!suspendedIframes.has(iframe)) {
|
||||
suspendedIframes.set(iframe, { src, srcdoc });
|
||||
}
|
||||
|
||||
if (src && src !== BLANK_IFRAME_SRC) {
|
||||
iframe.setAttribute('src', BLANK_IFRAME_SRC);
|
||||
stats.iframeSrcClearedCount += 1;
|
||||
}
|
||||
|
||||
if (srcdoc) {
|
||||
iframe.removeAttribute('srcdoc');
|
||||
stats.iframeSrcdocClearedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export const restoreSuspendedMediaPlayback = (container: ParentNode | null, options: RestoreSuspendedMediaPlaybackOptions = {}) => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const iframe of container.querySelectorAll<HTMLIFrameElement>('iframe')) {
|
||||
if (options.visibleOnly && !isElementInViewport(iframe)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suspendedState = suspendedIframes.get(iframe);
|
||||
if (!suspendedState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (suspendedState.src) {
|
||||
iframe.setAttribute('src', suspendedState.src);
|
||||
} else {
|
||||
iframe.removeAttribute('src');
|
||||
}
|
||||
|
||||
if (suspendedState.srcdoc) {
|
||||
iframe.setAttribute('srcdoc', suspendedState.srcdoc);
|
||||
} else {
|
||||
iframe.removeAttribute('srcdoc');
|
||||
}
|
||||
|
||||
suspendedIframes.delete(iframe);
|
||||
}
|
||||
};
|
||||
|
||||
const hasPlayableMedia = (element: Element): boolean => Boolean(element.querySelector(PLAYABLE_MEDIA_SELECTOR));
|
||||
|
||||
export const observeOffscreenMediaPlayback = (root: ParentNode | null, options: OffscreenMediaPlaybackOptions = {}) => {
|
||||
if (!root || typeof window === 'undefined' || !window.IntersectionObserver || !window.MutationObserver) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const delayMs = options.delayMs ?? OFFSCREEN_MEDIA_SUSPEND_DELAY_MS;
|
||||
const selector = options.selector ?? EXPANDED_MEDIA_SELECTOR;
|
||||
const observedElements = new Set<Element>();
|
||||
const pendingSuspends = new WeakMap<Element, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const clearPendingSuspend = (element: Element) => {
|
||||
const timeout = pendingSuspends.get(element);
|
||||
if (timeout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
pendingSuspends.delete(element);
|
||||
};
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const target = entry.target;
|
||||
clearPendingSuspend(target);
|
||||
|
||||
if (entry.isIntersecting || entry.intersectionRatio > 0) {
|
||||
restoreSuspendedMediaPlayback(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
pendingSuspends.delete(target);
|
||||
if (target.isConnected) {
|
||||
suspendMediaPlayback(target);
|
||||
}
|
||||
}, delayMs);
|
||||
pendingSuspends.set(target, timeout);
|
||||
}
|
||||
});
|
||||
|
||||
const observeElement = (element: Element) => {
|
||||
if (observedElements.has(element) || !hasPlayableMedia(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
observedElements.add(element);
|
||||
intersectionObserver.observe(element);
|
||||
};
|
||||
|
||||
const syncElementObservation = (element: Element) => {
|
||||
if (element.matches(selector) && hasPlayableMedia(element)) {
|
||||
observeElement(element);
|
||||
return;
|
||||
}
|
||||
|
||||
unobserveElement(element);
|
||||
};
|
||||
|
||||
const syncClosestMediaContainer = (element: Element) => {
|
||||
const mediaElement = element.matches(selector) ? element : element.closest(selector);
|
||||
if (mediaElement) {
|
||||
syncElementObservation(mediaElement);
|
||||
}
|
||||
};
|
||||
|
||||
const observeTree = (node: ParentNode) => {
|
||||
if (node instanceof Element && node.matches(selector)) {
|
||||
observeElement(node);
|
||||
}
|
||||
|
||||
for (const element of node.querySelectorAll(selector)) {
|
||||
observeElement(element);
|
||||
}
|
||||
};
|
||||
|
||||
const unobserveElement = (element: Element) => {
|
||||
clearPendingSuspend(element);
|
||||
if (!observedElements.delete(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
intersectionObserver.unobserve(element);
|
||||
};
|
||||
|
||||
const unobserveTree = (node: ParentNode) => {
|
||||
if (node instanceof Element && node.matches(selector)) {
|
||||
unobserveElement(node);
|
||||
}
|
||||
|
||||
for (const element of node.querySelectorAll(selector)) {
|
||||
unobserveElement(element);
|
||||
}
|
||||
};
|
||||
|
||||
observeTree(root);
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'attributes' && mutation.target instanceof Element) {
|
||||
syncElementObservation(mutation.target);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node instanceof Element) {
|
||||
observeTree(node);
|
||||
syncClosestMediaContainer(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node instanceof Element) {
|
||||
unobserveTree(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (mutation.target instanceof Element) {
|
||||
syncClosestMediaContainer(mutation.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
mutationObserver.observe(root, { attributeFilter: [EXPANDED_MEDIA_ATTRIBUTE], attributes: true, childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
for (const element of observedElements) {
|
||||
clearPendingSuspend(element);
|
||||
intersectionObserver.unobserve(element);
|
||||
}
|
||||
observedElements.clear();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user