import { convertFileSrc, invoke } from '@tauri-apps/api/core' import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue' export type ImageThumbnailSource = { instanceId: string filePath: string path: string } type ThumbnailCacheEntry = { promise: Promise url?: string users: number lastUsed: number } const MAX_CONCURRENT_THUMBNAILS = 4 const MAX_CACHED_THUMBNAILS = 256 const thumbnailCache = new Map() const thumbnailQueue: Array<() => void> = [] let activeThumbnailRequests = 0 function runThumbnailQueue() { while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) { activeThumbnailRequests += 1 thumbnailQueue.shift()?.() } } function enqueueThumbnail(task: () => Promise) { return new Promise((resolve, reject) => { thumbnailQueue.push(() => { void task() .then(resolve, reject) .finally(() => { activeThumbnailRequests -= 1 runThumbnailQueue() }) }) runThumbnailQueue() }) } function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) { if (thumbnailCache.get(cacheKey) !== entry) return thumbnailCache.delete(cacheKey) if (entry.url) { URL.revokeObjectURL(entry.url) } else { void entry.promise.then( (url) => URL.revokeObjectURL(url), () => undefined, ) } } function trimThumbnailCache() { if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return const unused = [...thumbnailCache.entries()] .filter(([, entry]) => entry.users === 0) .sort(([, left], [, right]) => left.lastUsed - right.lastUsed) for (const [cacheKey, entry] of unused) { if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break removeThumbnail(cacheKey, entry) } } function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) { const cacheKey = JSON.stringify([ source.instanceId, source.filePath, maxDimension, revision ?? null, ]) let entry = thumbnailCache.get(cacheKey) if (!entry) { entry = { users: 0, lastUsed: performance.now(), promise: enqueueThumbnail(async () => { const bytes = await invoke('plugin:files|screenshot_thumbnail', { instanceId: source.instanceId, filePath: source.filePath, maxDimension, }) return URL.createObjectURL(new Blob([bytes])) }), } thumbnailCache.set(cacheKey, entry) void entry.promise.then( (url) => { entry!.url = url trimThumbnailCache() }, () => { if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey) }, ) } entry.users += 1 entry.lastUsed = performance.now() return { promise: entry.promise, release: () => { entry!.users = Math.max(0, entry!.users - 1) entry!.lastUsed = performance.now() trimThumbnailCache() }, } } export function useImageThumbnail( source: MaybeRefOrGetter, maxDimension: MaybeRefOrGetter = 512, revision: MaybeRefOrGetter = undefined, ) { const thumbnail = ref('') watch( [() => toValue(source), () => toValue(maxDimension), () => toValue(revision)], ([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => { thumbnail.value = '' if (!currentSource) return let active = true const fallbackUrl = convertFileSrc(currentSource.path) const acquired = acquireThumbnail( currentSource, Math.max(1, Math.round(currentMaxDimension)), currentRevision, ) onCleanup(() => { active = false acquired.release() }) void acquired.promise.then( (url) => { if (active) thumbnail.value = url }, (error) => { console.warn('Could not create image thumbnail', error) if (active) thumbnail.value = fallbackUrl }, ) }, { immediate: true }, ) return thumbnail }