diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 32abd69e..2a0b14b3 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -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'), })); diff --git a/src/app.tsx b/src/app.tsx index b2d768cf..e53fc89f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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, diff --git a/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx b/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx new file mode 100644 index 00000000..e0c57c67 --- /dev/null +++ b/src/components/feed-cache-container/__tests__/feed-cache-container.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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: '

srcdoc embed

', title: 'srcdoc embed' }), + ) + : null, + ), +})); + +let container: HTMLDivElement; +let iframeRectSpy: ReturnType; +let latestPathname = ''; +let pauseSpy: ReturnType; +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('[data-testid="cached-video"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('

srcdoc embed

'); + + await act(async () => { + container.querySelector('[data-testid="navigate-b"]')?.click(); + }); + await flushEffects(); + + expect(latestPathname).toBe('/b'); + expect(pauseSpy).toHaveBeenCalled(); + expect(container.querySelector('[data-testid="cached-video"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('about:blank'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBeNull(); + + await act(async () => { + container.querySelector('[data-testid="navigate-a"]')?.click(); + }); + await flushEffects(); + + expect(latestPathname).toBe('/a'); + expect(container.querySelector('[data-testid="cached-iframe"]')?.getAttribute('src')).toBe('https://player.example.com/embed'); + expect(container.querySelector('[data-testid="cached-srcdoc-iframe"]')?.getAttribute('srcdoc')).toBe('

srcdoc embed

'); + }); +}); diff --git a/src/components/feed-cache-container/feed-cache-container.tsx b/src/components/feed-cache-container/feed-cache-container.tsx index 054612b3..dbb9400e 100644 --- a/src/components/feed-cache-container/feed-cache-container.tsx +++ b/src/components/feed-cache-container/feed-cache-container.tsx @@ -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(null); const context = parseFeedKey(feed.key); + useEffect(() => { + const container = containerRef.current; + if (isVisible) { + restoreSuspendedMediaPlayback(container, { visibleOnly: true }); + return; + } + + suspendMediaPlayback(container); + }, [isVisible]); + return ( -
+
{feed.type === 'catalog' ? ( { + useEffect(() => observeOffscreenMediaPlayback(document.body), []); +}; + +export default useSuspendOffscreenMediaPlayback; diff --git a/src/lib/utils/__tests__/media-playback-utils.test.ts b/src/lib/utils/__tests__/media-playback-utils.test.ts new file mode 100644 index 00000000..76768a3e --- /dev/null +++ b/src/lib/utils/__tests__/media-playback-utils.test.ts @@ -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(); + + 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 = '

embedded player

'; + + 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('

embedded player

'); + + 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('

embedded player

'); + + 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('

embedded player

'); + + 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(); + }); +}); diff --git a/src/lib/utils/media-playback-utils.ts b/src/lib/utils/media-playback-utils.ts new file mode 100644 index 00000000..d55b7d34 --- /dev/null +++ b/src/lib/utils/media-playback-utils.ts @@ -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(); + +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('video, audio')) { + stats.audioVideoCount += 1; + media.pause(); + } + + for (const iframe of container.querySelectorAll('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('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(); + const pendingSuspends = new WeakMap>(); + + 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(); + }; +};