diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/cache/imageCache.ts | 248 | ||||
| -rw-r--r-- | src/lib/cache/index.ts | 2 | ||||
| -rw-r--r-- | src/lib/components/CachedThumbnail.svelte | 67 | ||||
| -rw-r--r-- | src/lib/components/ImageList.svelte | 8 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 31 | ||||
| -rw-r--r-- | src/main.ts | 4 |
6 files changed, 354 insertions, 6 deletions
diff --git a/src/lib/cache/imageCache.ts b/src/lib/cache/imageCache.ts new file mode 100644 index 0000000..d06143f --- /dev/null +++ b/src/lib/cache/imageCache.ts @@ -0,0 +1,248 @@ +import type { ThumbnailMeta } from '../flashair/types'; + +const DB_NAME = 'speedsync-cache'; +const DB_VERSION = 1; +const STORE_NAME = 'images'; + +/** 30 days in milliseconds. */ +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export interface CachedImage { + /** The file path on the SD card, used as the primary key. */ + readonly path: string; + /** 'thumbnail' or 'full' — separates the two image sizes. */ + readonly kind: 'thumbnail' | 'full'; + /** The image data. */ + readonly blob: Blob; + /** EXIF metadata (only for thumbnails). */ + readonly meta: ThumbnailMeta | undefined; + /** Unix timestamp (ms) when this entry was stored. */ + readonly storedAt: number; +} + +type CacheKey = `${'thumbnail' | 'full'}:${string}`; + +function makeCacheKey(kind: 'thumbnail' | 'full', path: string): CacheKey { + return `${kind}:${path}`; +} + +function openDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(new Error(`Failed to open IndexedDB: ${request.error?.message ?? 'unknown error'}`)); + }; + }); +} + +interface StoredRecord { + readonly key: CacheKey; + readonly path: string; + readonly kind: 'thumbnail' | 'full'; + readonly blob: Blob; + readonly meta: ThumbnailMeta | undefined; + readonly storedAt: number; +} + +function isExpired(storedAt: number): boolean { + return Date.now() - storedAt > CACHE_TTL_MS; +} + +export const imageCache = { + /** + * Retrieve a cached image. Returns undefined if not found or expired. + */ + async get(kind: 'thumbnail' | 'full', path: string): Promise<CachedImage | undefined> { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return undefined; + } + + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const key = makeCacheKey(kind, path); + const request = store.get(key); + + request.onsuccess = () => { + const record = request.result as StoredRecord | undefined; + if (record === undefined || record === null) { + resolve(undefined); + return; + } + if (isExpired(record.storedAt)) { + // Expired — remove in background, return undefined + void imageCache.delete(kind, path); + resolve(undefined); + return; + } + resolve({ + path: record.path, + kind: record.kind, + blob: record.blob, + meta: record.meta, + storedAt: record.storedAt, + }); + }; + + request.onerror = () => { + resolve(undefined); + }; + + tx.oncomplete = () => { + db.close(); + }; + }); + }, + + /** + * Store an image in the cache. + */ + async put(kind: 'thumbnail' | 'full', path: string, blob: Blob, meta?: ThumbnailMeta): Promise<void> { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return; + } + + const record: StoredRecord = { + key: makeCacheKey(kind, path), + path, + kind, + blob, + meta, + storedAt: Date.now(), + }; + + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put(record); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + + tx.onerror = () => { + db.close(); + resolve(); + }; + }); + }, + + /** + * Delete a single entry from the cache. + */ + async delete(kind: 'thumbnail' | 'full', path: string): Promise<void> { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return; + } + + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(makeCacheKey(kind, path)); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + + tx.onerror = () => { + db.close(); + resolve(); + }; + }); + }, + + /** + * Remove all expired entries from the cache. Call periodically or on startup. + */ + async pruneExpired(): Promise<void> { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return; + } + + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.openCursor(); + const keysToDelete: IDBValidKey[] = []; + + request.onsuccess = () => { + const cursor = request.result; + if (cursor !== null) { + const record = cursor.value as StoredRecord; + if (isExpired(record.storedAt)) { + keysToDelete.push(cursor.key); + } + cursor.continue(); + } else { + for (const key of keysToDelete) { + store.delete(key); + } + } + }; + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + + tx.onerror = () => { + db.close(); + resolve(); + }; + }); + }, + + /** + * Clear the entire cache. + */ + async clear(): Promise<void> { + let db: IDBDatabase; + try { + db = await openDb(); + } catch { + return; + } + + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.clear(); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + + tx.onerror = () => { + db.close(); + resolve(); + }; + }); + }, +} as const; diff --git a/src/lib/cache/index.ts b/src/lib/cache/index.ts new file mode 100644 index 0000000..7ce52dc --- /dev/null +++ b/src/lib/cache/index.ts @@ -0,0 +1,2 @@ +export { imageCache } from './imageCache'; +export type { CachedImage } from './imageCache'; diff --git a/src/lib/components/CachedThumbnail.svelte b/src/lib/components/CachedThumbnail.svelte new file mode 100644 index 0000000..f0fe8bf --- /dev/null +++ b/src/lib/components/CachedThumbnail.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { flashair } from '../flashair'; + import { imageCache } from '../cache'; + + interface Props { + path: string; + alt: string; + } + + let { path, alt }: Props = $props(); + + let blobUrl = $state<string | undefined>(undefined); + let rawBlobUrl: string | undefined; + + $effect(() => { + const currentPath = path; + blobUrl = undefined; + + void loadThumbnail(currentPath); + + return () => { + if (rawBlobUrl !== undefined) { + URL.revokeObjectURL(rawBlobUrl); + rawBlobUrl = undefined; + } + }; + }); + + async function loadThumbnail(filePath: string) { + // Try cache first + const cached = await imageCache.get('thumbnail', filePath); + if (cached !== undefined) { + const url = URL.createObjectURL(cached.blob); + rawBlobUrl = url; + blobUrl = url; + return; + } + + // Fetch from card + const thumbUrl = flashair.thumbnailUrl(filePath); + if (thumbUrl === undefined) return; + + try { + const { blob, meta } = await flashair.fetchThumbnail(filePath); + // Store in cache (fire-and-forget) + void imageCache.put('thumbnail', filePath, blob, meta); + const url = URL.createObjectURL(blob); + rawBlobUrl = url; + blobUrl = url; + } catch { + // Thumbnail fetch failed — show placeholder + } + } +</script> + +{#if blobUrl !== undefined} + <img + src={blobUrl} + {alt} + class="w-full aspect-square rounded object-cover" + draggable="false" + /> +{:else} + <div class="w-full aspect-square rounded bg-base-300 flex items-center justify-center"> + <span class="loading loading-spinner loading-sm"></span> + </div> +{/if} diff --git a/src/lib/components/ImageList.svelte b/src/lib/components/ImageList.svelte index f338670..532cd3b 100644 --- a/src/lib/components/ImageList.svelte +++ b/src/lib/components/ImageList.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { flashair } from '../flashair'; import type { FlashAirFileEntry } from '../flashair'; + import CachedThumbnail from './CachedThumbnail.svelte'; interface Props { images: FlashAirFileEntry[]; @@ -29,12 +30,7 @@ onclick={() => onSelect(file)} > {#if thumbUrl} - <img - src={thumbUrl} - alt={file.filename} - class="w-full aspect-square rounded object-cover" - loading="lazy" - /> + <CachedThumbnail path={file.path} alt={file.filename} /> {:else} <div class="w-full aspect-square rounded bg-base-300 flex items-center justify-center"> <span class="text-lg">🖼️</span> diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte index 08be8ac..4a59d13 100644 --- a/src/lib/components/ImagePreview.svelte +++ b/src/lib/components/ImagePreview.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { flashair } from '../flashair'; import type { FlashAirFileEntry } from '../flashair'; + import { imageCache } from '../cache'; interface Props { file: FlashAirFileEntry | undefined; @@ -277,8 +278,23 @@ const url = flashair.thumbnailUrl(entry.path); if (url === undefined) return; + // Try cache first + const cached = await imageCache.get('thumbnail', entry.path); + if (cached !== undefined) { + const blobUrl = URL.createObjectURL(cached.blob); + rawThumbnailUrl = blobUrl; + thumbnailBlobUrl = blobUrl; + + if (cached.meta !== undefined && cached.meta.width !== undefined && cached.meta.height !== undefined && cached.meta.width > 0 && cached.meta.height > 0) { + imageAspectRatio = `${String(cached.meta.width)} / ${String(cached.meta.height)}`; + } + return; + } + try { const { blob, meta } = await flashair.fetchThumbnail(entry.path); + // Store in cache (fire-and-forget) + void imageCache.put('thumbnail', entry.path, blob, meta); const blobUrl = URL.createObjectURL(blob); rawThumbnailUrl = blobUrl; thumbnailBlobUrl = blobUrl; @@ -310,6 +326,17 @@ progress = 0; loadError = undefined; + // Try cache first + const cached = await imageCache.get('full', entry.path); + if (cached !== undefined && !abort.signal.aborted) { + const objectUrl = URL.createObjectURL(cached.blob); + rawObjectUrl = objectUrl; + fullObjectUrl = objectUrl; + progress = 1; + downloading = false; + return; + } + const url = flashair.fileUrl(entry.path); const totalBytes = entry.size; @@ -323,6 +350,8 @@ if (reader === undefined) { const blob = await res.blob(); if (abort.signal.aborted) return; + // Store in cache (fire-and-forget) + void imageCache.put('full', entry.path, blob); const objectUrl = URL.createObjectURL(blob); rawObjectUrl = objectUrl; fullObjectUrl = objectUrl; @@ -345,6 +374,8 @@ if (abort.signal.aborted) return; const blob = new Blob(chunks); + // Store in cache (fire-and-forget) + void imageCache.put('full', entry.path, blob); const objectUrl = URL.createObjectURL(blob); rawObjectUrl = objectUrl; fullObjectUrl = objectUrl; diff --git a/src/main.ts b/src/main.ts index 664a057..3717eb7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,10 @@ import { mount } from 'svelte' import './app.css' import App from './App.svelte' +import { imageCache } from './lib/cache' + +// Prune expired cache entries on startup (fire-and-forget) +void imageCache.pruneExpired(); const app = mount(App, { target: document.getElementById('app')!, |
