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', () => ({
|
vi.mock('../lib/utils/preload-utils', () => ({
|
||||||
preloadReplyModal: vi.fn(),
|
preloadReplyModal: vi.fn(),
|
||||||
preloadThemeAssets: vi.fn(),
|
preloadThemeAssets: vi.fn(),
|
||||||
|
resolveAssetUrl: (path: string) => path,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function makeNamedComponent(name: string) {
|
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'),
|
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
|
||||||
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
|
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
|
||||||
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
|
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/board-header', () => ({
|
vi.mock('../components/board-header/board-header', () => ({
|
||||||
default: makeNamedComponent('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'),
|
default: makeNamedComponent('feed-cache-container'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/post-form', () => ({
|
vi.mock('../components/post-form/post-form', () => ({
|
||||||
default: MockPostForm,
|
default: MockPostForm,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/board-blotter', () => ({
|
vi.mock('../components/board-blotter/board-blotter', () => ({
|
||||||
default: makeNamedComponent('board-blotter'),
|
default: makeNamedComponent('board-blotter'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/boards-bar', () => ({
|
vi.mock('../components/boards-bar/boards-bar', () => ({
|
||||||
default: MockBoardsBar,
|
default: MockBoardsBar,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ vi.mock('../views/board', () => ({
|
|||||||
default: makeNamedComponent('board-view'),
|
default: makeNamedComponent('board-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/blotter', () => ({
|
vi.mock('../views/blotter/blotter', () => ({
|
||||||
default: makeNamedComponent('blotter-view'),
|
default: makeNamedComponent('blotter-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -187,39 +188,39 @@ vi.mock('../views/catalog', () => ({
|
|||||||
default: makeNamedComponent('catalog-view'),
|
default: makeNamedComponent('catalog-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/faq', () => ({
|
vi.mock('../views/faq/faq', () => ({
|
||||||
default: makeNamedComponent('faq-view'),
|
default: makeNamedComponent('faq-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/home', () => ({
|
vi.mock('../views/home/home', () => ({
|
||||||
default: makeNamedComponent('home-view'),
|
default: makeNamedComponent('home-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/mod-queue', () => ({
|
vi.mock('../views/mod-queue/mod-queue', () => ({
|
||||||
default: makeNamedComponent('mod-queue-view'),
|
default: makeNamedComponent('mod-queue-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/not-allowed', () => ({
|
vi.mock('../views/not-allowed/not-allowed', () => ({
|
||||||
default: makeNamedComponent('not-allowed-view'),
|
default: makeNamedComponent('not-allowed-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/not-found', () => ({
|
vi.mock('../views/not-found/not-found', () => ({
|
||||||
default: makeNamedComponent('not-found-view'),
|
default: makeNamedComponent('not-found-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/pass', () => ({
|
vi.mock('../views/pass/pass', () => ({
|
||||||
default: makeNamedComponent('pass-view'),
|
default: makeNamedComponent('pass-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/pending-post', () => ({
|
vi.mock('../views/pending-post/pending-post', () => ({
|
||||||
default: makeNamedComponent('pending-post-view'),
|
default: makeNamedComponent('pending-post-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/post', () => ({
|
vi.mock('../views/post/post', () => ({
|
||||||
default: makeNamedComponent('post-view'),
|
default: makeNamedComponent('post-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../views/rules', () => ({
|
vi.mock('../views/rules/rules', () => ({
|
||||||
default: makeNamedComponent('rules-view'),
|
default: makeNamedComponent('rules-view'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+18
-16
@@ -16,6 +16,7 @@ import { useDirectories } from './hooks/use-directories';
|
|||||||
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
|
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
|
||||||
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address';
|
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address';
|
||||||
import useSafeAccountComment from './hooks/use-safe-account-comment';
|
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 { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
||||||
import {
|
import {
|
||||||
getBoardPath,
|
getBoardPath,
|
||||||
@@ -32,24 +33,24 @@ import {
|
|||||||
isFlashBoardRoute,
|
isFlashBoardRoute,
|
||||||
} from './lib/utils/route-utils';
|
} from './lib/utils/route-utils';
|
||||||
import styles from './app.module.css';
|
import styles from './app.module.css';
|
||||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
|
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons';
|
||||||
import Blotter from './views/blotter';
|
import Blotter from './views/blotter/blotter';
|
||||||
import Faq from './views/faq';
|
import Faq from './views/faq/faq';
|
||||||
import Home from './views/home';
|
import Home from './views/home/home';
|
||||||
import Archive from './views/archive/archive';
|
import Archive from './views/archive/archive';
|
||||||
import Directory from './views/directory/directory';
|
import Directory from './views/directory/directory';
|
||||||
import ModQueueView from './views/mod-queue';
|
import ModQueueView from './views/mod-queue/mod-queue';
|
||||||
import NotAllowed from './views/not-allowed';
|
import NotAllowed from './views/not-allowed/not-allowed';
|
||||||
import NotFound from './views/not-found';
|
import NotFound from './views/not-found/not-found';
|
||||||
import Pass from './views/pass';
|
import Pass from './views/pass/pass';
|
||||||
import PendingPost from './views/pending-post';
|
import PendingPost from './views/pending-post/pending-post';
|
||||||
import Post from './views/post';
|
import Post from './views/post/post';
|
||||||
import Rules from './views/rules';
|
import Rules from './views/rules/rules';
|
||||||
import BoardHeader from './components/board-header';
|
import BoardHeader from './components/board-header/board-header';
|
||||||
import FeedCacheContainer from './components/feed-cache-container';
|
import FeedCacheContainer from './components/feed-cache-container/feed-cache-container';
|
||||||
import PostForm from './components/post-form';
|
import PostForm from './components/post-form/post-form';
|
||||||
import BoardBlotter from './components/board-blotter';
|
import BoardBlotter from './components/board-blotter/board-blotter';
|
||||||
import BoardsBar from './components/boards-bar';
|
import BoardsBar from './components/boards-bar/boards-bar';
|
||||||
import ExternalQuoteStatus from './components/external-quote-status/external-quote-status';
|
import ExternalQuoteStatus from './components/external-quote-status/external-quote-status';
|
||||||
|
|
||||||
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
|
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
|
||||||
@@ -189,6 +190,7 @@ const BoardLayout = () => {
|
|||||||
|
|
||||||
const GlobalLayout = () => {
|
const GlobalLayout = () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
|
useSuspendOffscreenMediaPlayback();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeCid,
|
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 { useLocation } from 'react-router-dom';
|
||||||
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
|
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
|
||||||
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils';
|
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils';
|
||||||
import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils';
|
import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils';
|
||||||
import Board from '../../views/board';
|
import { restoreSuspendedMediaPlayback, suspendMediaPlayback } from '../../lib/utils/media-playback-utils';
|
||||||
import Catalog from '../../views/catalog';
|
import Board from '../../views/board/board';
|
||||||
|
import Catalog from '../../views/catalog/catalog';
|
||||||
import styles from './feed-cache-container.module.css';
|
import styles from './feed-cache-container.module.css';
|
||||||
|
|
||||||
interface FeedContextFromKey {
|
interface FeedContextFromKey {
|
||||||
@@ -41,10 +42,21 @@ interface CachedFeedWrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
|
const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const context = parseFeedKey(feed.key);
|
const context = parseFeedKey(feed.key);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (isVisible) {
|
||||||
|
restoreSuspendedMediaPlayback(container, { visibleOnly: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendMediaPlayback(container);
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isVisible ? styles.visible : styles.hidden}>
|
<div ref={containerRef} className={isVisible ? styles.visible : styles.hidden}>
|
||||||
{feed.type === 'catalog' ? (
|
{feed.type === 'catalog' ? (
|
||||||
<Catalog
|
<Catalog
|
||||||
feedCacheKey={feed.key}
|
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