mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
perf(app): reuse pooled gif videos instead of caching blobs (#1928)
This commit is contained in:
@@ -40,36 +40,34 @@ export function safePause(video: HTMLVideoElement): void {
|
||||
}
|
||||
|
||||
class ElementPool<T> {
|
||||
private _elements: Array<T>;
|
||||
private _createElement: () => T;
|
||||
private _cleanElement: (element: T) => void;
|
||||
private idleElements: Array<T>;
|
||||
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<string>;
|
||||
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<string, HTMLVideoElement>();
|
||||
const MAX_ELEMENTS = 16;
|
||||
const blobCache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const MAX_BLOBS = 32;
|
||||
const activeElements = new Set<HTMLVideoElement>();
|
||||
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<string> => {
|
||||
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 <GifVideoPoolContext.Provider value={videoPool}>{children}</GifVideoPoolContext.Provider>;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string>;
|
||||
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<HTMLVideoElement | null>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user