Skip to content
Source
Sign inSign up

videoThumbnails.ts

79 lines2.6 KB

1import type { VideoThumbnail } from "expo-video";
2​
3import type { AttachmentPreviewFile } from "./attachmentDownload";
4​
5const thumbnails = new Map<string, VideoThumbnail>();
6const MAX_CACHED_THUMBNAILS = 32;
7let pending: Promise<unknown> = Promise.resolve();
8​
9export function cachedVideoThumbnail(key: string): VideoThumbnail | null {
10 return thumbnails.get(key) ?? null;
12​
13async function extractFrame(uri: string, signal: AbortSignal) {
14 const { createVideoPlayer } = await import("expo-video");
15 if (signal.aborted) return null;
16 const player = createVideoPlayer(null);
17 let disposed = false;
18 let cancel = () => {};
19 let timeout: ReturnType<typeof setTimeout> | undefined;
20 try {
21 // Never play or change audio settings: thumbnails must leave the shared audio session alone.
22 player.bufferOptions = { preferredForwardBufferDuration: 1 };
23 const canceled = new Promise<null>((resolve) => {
24 cancel = () => resolve(null);
25 });
26 signal.addEventListener("abort", cancel, { once: true });
27 timeout = setTimeout(cancel, 15_000);
28 const frame = (async () => {
29 await player.replaceAsync({ uri, contentType: "progressive" });
30 if (disposed || signal.aborted) return null;
31 const [thumbnail] = await player.generateThumbnailsAsync([0], {
32 maxWidth: 480,
33 maxHeight: 480,
34 });
35 return thumbnail ?? null;
36 })();
37 return await Promise.race([frame, canceled]);
38 } finally {
39 disposed = true;
40 clearTimeout(timeout);
41 signal.removeEventListener("abort", cancel);
42 player.release();
43 }
45​
46export function loadVideoThumbnail(
47 key: string,
48 resolveSource: (
49 signal: AbortSignal,
50 ) => Promise<Pick<AttachmentPreviewFile, "uri" | "dispose"> | null>,
51 signal: AbortSignal,
52): Promise<VideoThumbnail | null> {
53 if (signal.aborted) return Promise.resolve(null);
54 const cached = cachedVideoThumbnail(key);
55 if (cached) return Promise.resolve(cached);
56 const load = pending
57 .then(async () => {
58 if (signal.aborted) return null;
59 const cached = cachedVideoThumbnail(key);
60 if (cached) return cached;
61​
62 const source = await resolveSource(signal);
63 if (!source) return null;
64 try {
65 const thumbnail = await extractFrame(source.uri, signal);
66 if (!thumbnail || signal.aborted) return null;
67 thumbnails.set(key, thumbnail);
68 if (thumbnails.size > MAX_CACHED_THUMBNAILS) {
69 thumbnails.delete(thumbnails.keys().next().value!);
70 }
71 return thumbnail;
72 } finally {
73 source.dispose();
74 }
75 })
76 .catch(() => null);
77 pending = load;
78 return load;