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;
11}
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 // An unreachable environment must not hold up thumbnails for other environments.
28 timeout = setTimeout(cancel, 15_000);
29 const frame = (async () => {
30 await player.replaceAsync({ uri, contentType: "progressive" });
31 if (disposed || signal.aborted) return null;
32 const [thumbnail] = await player.generateThumbnailsAsync([0], {
33 maxWidth: 480,
34 maxHeight: 480,
35 });
36 return thumbnail ?? null;
37 })();
38 return await Promise.race([frame, canceled]);
39 } finally {
40 disposed = true;
41 clearTimeout(timeout);
42 signal.removeEventListener("abort", cancel);
43 player.release();
44 }
45}
46
47/** Serializes frame extraction and releases each temporary player and local-file lease. */
48export function loadVideoThumbnail(
49 key: string,
50 resolveSource: (
51 signal: AbortSignal,
52 ) => Promise<Pick<AttachmentPreviewFile, "uri" | "dispose"> | null>,
53 signal: AbortSignal,
54): Promise<VideoThumbnail | null> {
55 if (signal.aborted) return Promise.resolve(null);
56 const cached = cachedVideoThumbnail(key);
57 if (cached) return Promise.resolve(cached);
58 const load = pending
59 .then(async () => {
60 if (signal.aborted) return null;
61 const cached = cachedVideoThumbnail(key);
62 if (cached) return cached;
63
64 const source = await resolveSource(signal);
65 if (!source) return null;
66 try {
67 const thumbnail = await extractFrame(source.uri, signal);
68 if (!thumbnail || signal.aborted) return null;
69 thumbnails.set(key, thumbnail);
70 if (thumbnails.size > MAX_CACHED_THUMBNAILS) {
71 thumbnails.delete(thumbnails.keys().next().value!);
72 }
73 return thumbnail;
74 } finally {
75 source.dispose();
76 }
77 })
78 .catch(() => null);
79 pending = load;
80 return load;
81}