diff --git a/fluxer_app/src/features/channel/components/GifVideoPool.tsx b/fluxer_app/src/features/channel/components/GifVideoPool.tsx index cfc985ffb..2b6768ea5 100644 --- a/fluxer_app/src/features/channel/components/GifVideoPool.tsx +++ b/fluxer_app/src/features/channel/components/GifVideoPool.tsx @@ -40,36 +40,34 @@ export function safePause(video: HTMLVideoElement): void { } class ElementPool { - private _elements: Array; - private _createElement: () => T; - private _cleanElement: (element: T) => void; + private idleElements: Array; + private spawnElement: () => T; + private recycleElement: (element: T) => void; - constructor(createElement: () => T, cleanElement: (element: T) => void) { - this._elements = []; - this._createElement = createElement; - this._cleanElement = cleanElement; + constructor(spawnElement: () => T, recycleElement: (element: T) => void) { + this.idleElements = []; + this.spawnElement = spawnElement; + this.recycleElement = recycleElement; } - getElement(): T { - return this._elements.length === 0 ? this._createElement() : this._elements.pop()!; + takeElement(): T { + return this.idleElements.length === 0 ? this.spawnElement() : this.idleElements.pop()!; } - poolElement(element: T): void { - this._cleanElement(element); - this._elements.push(element); + returnElement(element: T): void { + this.recycleElement(element); + this.idleElements.push(element); } - clearPool(): void { - this._elements.length = 0; + dropAllElements(): void { + this.idleElements.length = 0; } } interface PooledVideo { - getElement: (src?: string) => HTMLVideoElement; - poolElement: (element: HTMLVideoElement, src?: string) => void; - clearPool: () => void; - getBlobUrl: (src: string) => Promise; - clearBlobCache: () => void; + takeElement: () => HTMLVideoElement; + returnElement: (element: HTMLVideoElement) => void; + dropAllElements: () => void; registerActive: (element: HTMLVideoElement) => void; unregisterActive: (element: HTMLVideoElement) => void; pauseAll: () => void; @@ -98,101 +96,32 @@ export const GifVideoPoolProvider = ({children}: {children: React.ReactNode}) => return video; }, (video) => { - video.src = ''; video.oncanplay = null; - video.currentTime = 0; + video.removeAttribute('src'); + video.load(); const {parentNode} = video; if (parentNode != null) { parentNode.removeChild(video); } }, ); - const elementCache = new Map(); - const MAX_ELEMENTS = 16; - const blobCache = new Map(); - const inflight = new Map>(); - const MAX_BLOBS = 32; const activeElements = new Set(); let globallyPaused = !getAnimatedMediaPlaybackAllowed(); - const evictOldestBlob = () => { - const oldest = blobCache.keys().next(); - if (!oldest.done) { - const key = oldest.value; - const url = blobCache.get(key); - if (url) { - URL.revokeObjectURL(url); - } - blobCache.delete(key); - } - }; - const getBlobUrl = async (src: string): Promise => { - if (blobCache.has(src)) { - return blobCache.get(src)!; - } - if (inflight.has(src)) { - return inflight.get(src)!; - } - const promise = (async () => { - const response = await fetch(src, {cache: 'force-cache'}); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - if (blobCache.size >= MAX_BLOBS) { - evictOldestBlob(); - } - blobCache.set(src, url); - return url; - })().finally(() => { - inflight.delete(src); - }); - inflight.set(src, promise); - return promise; - }; return { - getElement(src?: string): HTMLVideoElement { - if (src && elementCache.has(src)) { - const el = elementCache.get(src)!; - elementCache.delete(src); - return el; - } - return basePool.getElement(); + takeElement(): HTMLVideoElement { + return basePool.takeElement(); }, - poolElement(element: HTMLVideoElement, src?: string): void { + returnElement(element: HTMLVideoElement): void { activeElements.delete(element); const {parentNode} = element; if (parentNode != null) { parentNode.removeChild(element); } - if (src) { - element.oncanplay = null; - element.pause(); - element.currentTime = 0; - element.src = ''; - if (elementCache.size >= MAX_ELEMENTS) { - const oldestKey = elementCache.keys().next().value as string | undefined; - if (oldestKey) { - const oldest = elementCache.get(oldestKey); - if (oldest) { - basePool.poolElement(oldest); - } - elementCache.delete(oldestKey); - } - } - elementCache.set(src, element); - return; - } - basePool.poolElement(element); + basePool.returnElement(element); }, - clearPool(): void { + dropAllElements(): void { activeElements.clear(); - elementCache.forEach((el) => { - el.src = ''; - el.oncanplay = null; - }); - elementCache.clear(); - basePool.clearPool(); - blobCache.forEach((url) => URL.revokeObjectURL(url)); - blobCache.clear(); - inflight.clear(); + basePool.dropAllElements(); }, registerActive(element: HTMLVideoElement) { activeElements.add(element); @@ -216,16 +145,11 @@ export const GifVideoPoolProvider = ({children}: {children: React.ReactNode}) => isGloballyPaused() { return globallyPaused; }, - getBlobUrl, - clearBlobCache(): void { - blobCache.forEach((url) => URL.revokeObjectURL(url)); - blobCache.clear(); - }, }; }); useEffect(() => { return () => { - videoPool.clearPool(); + videoPool.dropAllElements(); }; }, [videoPool]); return {children}; diff --git a/fluxer_app/src/features/channel/components/pickers/memes/MemeGridItem.tsx b/fluxer_app/src/features/channel/components/pickers/memes/MemeGridItem.tsx index 2e12578e9..aa3492c3e 100644 --- a/fluxer_app/src/features/channel/components/pickers/memes/MemeGridItem.tsx +++ b/fluxer_app/src/features/channel/components/pickers/memes/MemeGridItem.tsx @@ -106,9 +106,13 @@ export const MemeGridItem = observer( const videoPool = useGifVideoPool(); const isAudio = meme.contentType.startsWith('audio/'); const isVideo = meme.contentType.startsWith('video/'); - const shouldAnimateGif = useShouldAnimate({kind: 'gif', isHovering: isVideoPreviewActive}); - const shouldRenderVideoPreview = !isAudio && isVideo && isVideoPreviewActive && shouldAnimateGif; const isGifImage = !isVideo && meme.contentType.toLowerCase().includes('gif'); + const shouldAnimateGif = useShouldAnimate({ + kind: 'gif', + isAnimated: !isAudio && (isVideo || isGifImage), + isHovering: isVideoPreviewActive, + }); + const shouldRenderVideoPreview = !isAudio && isVideo && isVideoPreviewActive && shouldAnimateGif; const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(meme.url) : meme.url; const videoPreviewStartTime = meme.duration && meme.duration > 0 ? meme.duration / 2 : null; usePooledVideo({ @@ -118,7 +122,6 @@ export const MemeGridItem = observer( autoPlay: shouldRenderVideoPreview, enabled: shouldRenderVideoPreview, preload: 'auto', - useBlobCache: false, playbackStartTime: videoPreviewStartTime ?? 0, }); const clearHoverPreviewTimeout = useCallback(() => { diff --git a/fluxer_app/src/features/channel/components/pickers/shared/usePooledVideo.ts b/fluxer_app/src/features/channel/components/pickers/shared/usePooledVideo.ts index b2b8451d0..da5144be4 100644 --- a/fluxer_app/src/features/channel/components/pickers/shared/usePooledVideo.ts +++ b/fluxer_app/src/features/channel/components/pickers/shared/usePooledVideo.ts @@ -1,18 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {safePause, safePlay} from '@app/features/channel/components/GifVideoPool'; -import {Platform} from '@app/features/platform/types/Platform'; import {useEffect, useRef} from 'react'; -const BYPASS_BLOB_URL_PATH = Platform.isIOSWeb; const HAVE_METADATA_READY_STATE = 1; interface GifVideoPoolLike { - getElement: (key: string) => HTMLVideoElement; - getBlobUrl: (key: string) => Promise; + takeElement: () => HTMLVideoElement; registerActive: (video: HTMLVideoElement) => void; unregisterActive: (video: HTMLVideoElement) => void; - poolElement: (video: HTMLVideoElement, key: string) => void; + returnElement: (video: HTMLVideoElement) => void; isGloballyPaused?: () => boolean; } @@ -22,8 +19,7 @@ export function usePooledVideo({ videoPool, autoPlay, enabled = true, - preload = 'auto', - useBlobCache = true, + preload, playbackStartTime = null, }: { src: string | null | undefined; @@ -32,86 +28,59 @@ export function usePooledVideo({ autoPlay: boolean; enabled?: boolean; preload?: HTMLVideoElement['preload']; - useBlobCache?: boolean; playbackStartTime?: number | null; }) { const videoRef = useRef(null); + const effectivePreload = preload ?? (autoPlay ? 'auto' : 'metadata'); useEffect(() => { if (!enabled) return; if (!src) return; const container = containerRef.current; if (!container) return; - let cancelled = false; - let attached = false; - let playOnMetadata: (() => void) | null = null; - const video = videoPool.getElement(src); + const video = videoPool.takeElement(); videoRef.current = video; - video.autoplay = autoPlay && playbackStartTime === null; - if (video.autoplay) { - video.setAttribute('autoplay', ''); - } else { - video.removeAttribute('autoplay'); - } - video.preload = preload; + video.autoplay = false; + video.preload = effectivePreload; + video.src = src; + container.appendChild(video); videoPool.registerActive(video); - attached = true; - const seekToPlaybackStart = () => { - if (playbackStartTime === null || playbackStartTime <= 0) return; - const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null; - const target = duration === null ? playbackStartTime : Math.min(playbackStartTime, Math.max(0, duration - 0.05)); - try { - video.currentTime = target; - } catch {} - }; - const playFromStartTime = () => { - if (cancelled) return; - seekToPlaybackStart(); - void safePlay(video); - }; - const run = async () => { - if (BYPASS_BLOB_URL_PATH || !useBlobCache) { - if (video.src !== src) video.src = src; - } else { - try { - const blobUrl = await videoPool.getBlobUrl(src); - if (cancelled) return; - if (video.src !== blobUrl) video.src = blobUrl; - } catch { - if (cancelled) return; - if (video.src !== src) video.src = src; - } - } - if (cancelled) return; - const currentContainer = containerRef.current; - if (!currentContainer) return; - currentContainer.appendChild(video); - if (videoPool.isGloballyPaused?.() ?? false) { - safePause(video); - } else if (playbackStartTime !== null && playbackStartTime > 0 && video.readyState < HAVE_METADATA_READY_STATE) { - playOnMetadata = playFromStartTime; - video.addEventListener('loadedmetadata', playOnMetadata, {once: true}); - } else if (autoPlay) { - playFromStartTime(); - } - }; - void run(); return () => { - cancelled = true; - if (playOnMetadata) { - video.removeEventListener('loadedmetadata', playOnMetadata); - } - if (attached) { - videoPool.unregisterActive(video); - } - safePause(video); - try { - video.currentTime = 0; - } catch {} - videoPool.poolElement(video, src); + videoPool.unregisterActive(video); + videoPool.returnElement(video); if (videoRef.current === video) { videoRef.current = null; } }; - }, [src, enabled, containerRef, videoPool, autoPlay, preload, useBlobCache, playbackStartTime]); + }, [src, enabled, containerRef, videoPool, effectivePreload]); + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const globallyPaused = videoPool.isGloballyPaused?.() ?? false; + video.autoplay = autoPlay && playbackStartTime === null && !globallyPaused; + if (globallyPaused) return; + if (!autoPlay) { + safePause(video); + return; + } + const seekTarget = playbackStartTime !== null && playbackStartTime > 0 ? playbackStartTime : null; + const playFromStartTime = () => { + if (seekTarget !== null) { + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null; + const target = duration === null ? seekTarget : Math.min(seekTarget, Math.max(0, duration - 0.05)); + try { + video.currentTime = target; + } catch {} + } + void safePlay(video); + }; + if (seekTarget !== null && video.readyState < HAVE_METADATA_READY_STATE) { + video.addEventListener('loadedmetadata', playFromStartTime, {once: true}); + return () => { + video.removeEventListener('loadedmetadata', playFromStartTime); + }; + } + playFromStartTime(); + return; + }, [src, enabled, containerRef, videoPool, effectivePreload, autoPlay, playbackStartTime]); return videoRef; }