| 1 | import type { VideoThumbnail } from "expo-video"; |
| 2 | |
| 3 | import type { AttachmentPreviewFile } from "./attachmentDownload"; |
| 4 | |
| 5 | const thumbnails = new Map<string, VideoThumbnail>(); |
| 6 | const MAX_CACHED_THUMBNAILS = 32; |
| 7 | let pending: Promise<unknown> = Promise.resolve(); |
| 8 | |
| 9 | export function cachedVideoThumbnail(key: string): VideoThumbnail | null { |
| 10 | return thumbnails.get(key) ?? null; |
| 11 | } |
| 12 | |
| 13 | async 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. */ |
| 48 | export 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 | } |