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:
+17
-16
@@ -111,6 +111,7 @@ vi.mock('../lib/snow', () => ({
|
||||
vi.mock('../lib/utils/preload-utils', () => ({
|
||||
preloadReplyModal: vi.fn(),
|
||||
preloadThemeAssets: vi.fn(),
|
||||
resolveAssetUrl: (path: string) => path,
|
||||
}));
|
||||
|
||||
function makeNamedComponent(name: string) {
|
||||
@@ -149,29 +150,29 @@ const MockSettingsModal = () => {
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock('../components/board-buttons', () => ({
|
||||
vi.mock('../components/board-buttons/board-buttons', () => ({
|
||||
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
|
||||
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
|
||||
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/board-header', () => ({
|
||||
vi.mock('../components/board-header/board-header', () => ({
|
||||
default: makeNamedComponent('board-header'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/feed-cache-container', () => ({
|
||||
vi.mock('../components/feed-cache-container/feed-cache-container', () => ({
|
||||
default: makeNamedComponent('feed-cache-container'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/post-form', () => ({
|
||||
vi.mock('../components/post-form/post-form', () => ({
|
||||
default: MockPostForm,
|
||||
}));
|
||||
|
||||
vi.mock('../components/board-blotter', () => ({
|
||||
vi.mock('../components/board-blotter/board-blotter', () => ({
|
||||
default: makeNamedComponent('board-blotter'),
|
||||
}));
|
||||
|
||||
vi.mock('../components/boards-bar', () => ({
|
||||
vi.mock('../components/boards-bar/boards-bar', () => ({
|
||||
default: MockBoardsBar,
|
||||
}));
|
||||
|
||||
@@ -179,7 +180,7 @@ vi.mock('../views/board', () => ({
|
||||
default: makeNamedComponent('board-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/blotter', () => ({
|
||||
vi.mock('../views/blotter/blotter', () => ({
|
||||
default: makeNamedComponent('blotter-view'),
|
||||
}));
|
||||
|
||||
@@ -187,39 +188,39 @@ vi.mock('../views/catalog', () => ({
|
||||
default: makeNamedComponent('catalog-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/faq', () => ({
|
||||
vi.mock('../views/faq/faq', () => ({
|
||||
default: makeNamedComponent('faq-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/home', () => ({
|
||||
vi.mock('../views/home/home', () => ({
|
||||
default: makeNamedComponent('home-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/mod-queue', () => ({
|
||||
vi.mock('../views/mod-queue/mod-queue', () => ({
|
||||
default: makeNamedComponent('mod-queue-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/not-allowed', () => ({
|
||||
vi.mock('../views/not-allowed/not-allowed', () => ({
|
||||
default: makeNamedComponent('not-allowed-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/not-found', () => ({
|
||||
vi.mock('../views/not-found/not-found', () => ({
|
||||
default: makeNamedComponent('not-found-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/pass', () => ({
|
||||
vi.mock('../views/pass/pass', () => ({
|
||||
default: makeNamedComponent('pass-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/pending-post', () => ({
|
||||
vi.mock('../views/pending-post/pending-post', () => ({
|
||||
default: makeNamedComponent('pending-post-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/post', () => ({
|
||||
vi.mock('../views/post/post', () => ({
|
||||
default: makeNamedComponent('post-view'),
|
||||
}));
|
||||
|
||||
vi.mock('../views/rules', () => ({
|
||||
vi.mock('../views/rules/rules', () => ({
|
||||
default: makeNamedComponent('rules-view'),
|
||||
}));
|
||||
|
||||
|
||||
+18
-16
@@ -16,6 +16,7 @@ import { useDirectories } from './hooks/use-directories';
|
||||
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
|
||||
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address';
|
||||
import useSafeAccountComment from './hooks/use-safe-account-comment';
|
||||
import useSuspendOffscreenMediaPlayback from './hooks/use-suspend-offscreen-media-playback';
|
||||
import { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
||||
import {
|
||||
getBoardPath,
|
||||
@@ -32,24 +33,24 @@ import {
|
||||
isFlashBoardRoute,
|
||||
} from './lib/utils/route-utils';
|
||||
import styles from './app.module.css';
|
||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
|
||||
import Blotter from './views/blotter';
|
||||
import Faq from './views/faq';
|
||||
import Home from './views/home';
|
||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons';
|
||||
import Blotter from './views/blotter/blotter';
|
||||
import Faq from './views/faq/faq';
|
||||
import Home from './views/home/home';
|
||||
import Archive from './views/archive/archive';
|
||||
import Directory from './views/directory/directory';
|
||||
import ModQueueView from './views/mod-queue';
|
||||
import NotAllowed from './views/not-allowed';
|
||||
import NotFound from './views/not-found';
|
||||
import Pass from './views/pass';
|
||||
import PendingPost from './views/pending-post';
|
||||
import Post from './views/post';
|
||||
import Rules from './views/rules';
|
||||
import BoardHeader from './components/board-header';
|
||||
import FeedCacheContainer from './components/feed-cache-container';
|
||||
import PostForm from './components/post-form';
|
||||
import BoardBlotter from './components/board-blotter';
|
||||
import BoardsBar from './components/boards-bar';
|
||||
import ModQueueView from './views/mod-queue/mod-queue';
|
||||
import NotAllowed from './views/not-allowed/not-allowed';
|
||||
import NotFound from './views/not-found/not-found';
|
||||
import Pass from './views/pass/pass';
|
||||
import PendingPost from './views/pending-post/pending-post';
|
||||
import Post from './views/post/post';
|
||||
import Rules from './views/rules/rules';
|
||||
import BoardHeader from './components/board-header/board-header';
|
||||
import FeedCacheContainer from './components/feed-cache-container/feed-cache-container';
|
||||
import PostForm from './components/post-form/post-form';
|
||||
import BoardBlotter from './components/board-blotter/board-blotter';
|
||||
import BoardsBar from './components/boards-bar/boards-bar';
|
||||
import ExternalQuoteStatus from './components/external-quote-status/external-quote-status';
|
||||
|
||||
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
|
||||
@@ -189,6 +190,7 @@ const BoardLayout = () => {
|
||||
|
||||
const GlobalLayout = () => {
|
||||
useTheme();
|
||||
useSuspendOffscreenMediaPlayback();
|
||||
|
||||
const {
|
||||
activeCid,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useFeedCacheStore from '../../../stores/use-feed-cache-store';
|
||||
import FeedCacheContainer from '../feed-cache-container';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
vi.mock('../../../views/catalog/catalog', () => ({
|
||||
default: () => createElement('div', { 'data-testid': 'catalog' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../views/board/board', () => ({
|
||||
default: ({ feedCacheKey }: { feedCacheKey: string }) =>
|
||||
createElement(
|
||||
'div',
|
||||
{ 'data-testid': `board-${feedCacheKey}` },
|
||||
feedCacheKey === '/a'
|
||||
? createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
createElement('video', { 'data-testid': 'cached-video', src: 'https://cdn.example.com/video.mp4' }),
|
||||
createElement('iframe', { 'data-testid': 'cached-iframe', src: 'https://player.example.com/embed', title: 'remote embed' }),
|
||||
createElement('iframe', { 'data-testid': 'cached-srcdoc-iframe', srcDoc: '<p>srcdoc embed</p>', title: 'srcdoc embed' }),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let iframeRectSpy: ReturnType<typeof vi.spyOn>;
|
||||
let latestPathname = '';
|
||||
let pauseSpy: ReturnType<typeof vi.spyOn>;
|
||||
let root: Root;
|
||||
|
||||
const NavigateButtons = () => {
|
||||
const navigate = useNavigate();
|
||||
return createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
createElement('button', { 'data-testid': 'navigate-a', onClick: () => navigate('/a'), type: 'button' }, 'a'),
|
||||
createElement('button', { 'data-testid': 'navigate-b', onClick: () => navigate('/b'), type: 'button' }, 'b'),
|
||||
);
|
||||
};
|
||||
|
||||
const LocationProbe = () => {
|
||||
latestPathname = useLocation().pathname;
|
||||
return null;
|
||||
};
|
||||
|
||||
const flushEffects = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
describe('FeedCacheContainer', () => {
|
||||
beforeEach(() => {
|
||||
useFeedCacheStore.getState().clearFeeds();
|
||||
iframeRectSpy = vi.spyOn(window.HTMLIFrameElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 240,
|
||||
height: 240,
|
||||
left: 0,
|
||||
right: 320,
|
||||
top: 0,
|
||||
width: 320,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => '',
|
||||
});
|
||||
pauseSpy = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
||||
latestPathname = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
useFeedCacheStore.getState().clearFeeds();
|
||||
iframeRectSpy.mockRestore();
|
||||
pauseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('suspends playable media while a cached feed is hidden and restores iframe embeds when visible again', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(MemoryRouter, { initialEntries: ['/a'] }, createElement(FeedCacheContainer), createElement(NavigateButtons), createElement(LocationProbe)),
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(latestPathname).toBe('/a');
|
||||
expect(container.querySelector<HTMLVideoElement>('[data-testid="cached-video"]')).toBeTruthy();
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('<p>srcdoc embed</p>');
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="navigate-b"]')?.click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(latestPathname).toBe('/b');
|
||||
expect(pauseSpy).toHaveBeenCalled();
|
||||
expect(container.querySelector<HTMLVideoElement>('[data-testid="cached-video"]')).toBeTruthy();
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('about:blank');
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="navigate-a"]')?.click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(latestPathname).toBe('/a');
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed');
|
||||
expect(container.querySelector<HTMLIFrameElement>('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('<p>srcdoc embed</p>');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
|
||||
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils';
|
||||
import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils';
|
||||
import Board from '../../views/board';
|
||||
import Catalog from '../../views/catalog';
|
||||
import { restoreSuspendedMediaPlayback, suspendMediaPlayback } from '../../lib/utils/media-playback-utils';
|
||||
import Board from '../../views/board/board';
|
||||
import Catalog from '../../views/catalog/catalog';
|
||||
import styles from './feed-cache-container.module.css';
|
||||
|
||||
interface FeedContextFromKey {
|
||||
@@ -41,10 +42,21 @@ interface CachedFeedWrapperProps {
|
||||
}
|
||||
|
||||
const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const context = parseFeedKey(feed.key);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (isVisible) {
|
||||
restoreSuspendedMediaPlayback(container, { visibleOnly: true });
|
||||
return;
|
||||
}
|
||||
|
||||
suspendMediaPlayback(container);
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div className={isVisible ? styles.visible : styles.hidden}>
|
||||
<div ref={containerRef} className={isVisible ? styles.visible : styles.hidden}>
|
||||
{feed.type === 'catalog' ? (
|
||||
<Catalog
|
||||
feedCacheKey={feed.key}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { observeOffscreenMediaPlayback } from '../lib/utils/media-playback-utils';
|
||||
|
||||
const useSuspendOffscreenMediaPlayback = () => {
|
||||
useEffect(() => observeOffscreenMediaPlayback(document.body), []);
|
||||
};
|
||||
|
||||
export default useSuspendOffscreenMediaPlayback;
|
||||
@@ -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