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:
Tommaso Casaburi
2026-06-08 22:55:30 +07:00
committed by GitHub
parent 075b917c36
commit 245d3164c3
7 changed files with 665 additions and 36 deletions
+17 -16
View File
@@ -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
View File
@@ -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();
});
});
+243
View File
@@ -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();
};
};