diff options
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/cache/imageCache.ts | 423 | ||||
| -rw-r--r-- | src/lib/components/CacheDebug.svelte | 85 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 46 |
3 files changed, 388 insertions, 166 deletions
diff --git a/src/lib/cache/imageCache.ts b/src/lib/cache/imageCache.ts index d06143f..2326974 100644 --- a/src/lib/cache/imageCache.ts +++ b/src/lib/cache/imageCache.ts @@ -4,9 +4,6 @@ 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; @@ -26,25 +23,60 @@ 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); +// --------------------------------------------------------------------------- +// In-memory layer — guarantees that a put() is immediately visible to get() +// without waiting for the IndexedDB transaction to commit. This is critical +// because the auto-cache service calls `void imageCache.put(...)` (fire-and- +// forget) and a subsequent get() in ImagePreview must find the data. +// --------------------------------------------------------------------------- +const memoryCache = new Map<CacheKey, CachedImage>(); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: 'key' }); - } - }; +// --------------------------------------------------------------------------- +// IndexedDB persistence layer — survives page refreshes. +// --------------------------------------------------------------------------- - request.onsuccess = () => { - resolve(request.result); - }; +/** Shared DB connection (lazy, created once). */ +let dbInstance: IDBDatabase | undefined; +let dbPromise: Promise<IDBDatabase | undefined> | undefined; - request.onerror = () => { - reject(new Error(`Failed to open IndexedDB: ${request.error?.message ?? 'unknown error'}`)); - }; +function openDb(): Promise<IDBDatabase | undefined> { + if (dbInstance !== undefined) return Promise.resolve(dbInstance); + if (dbPromise !== undefined) return dbPromise; + + dbPromise = new Promise<IDBDatabase | undefined>((resolve) => { + try { + 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 = () => { + dbInstance = request.result; + + // If the connection is unexpectedly closed, reset so we can reopen. + dbInstance.onclose = () => { + dbInstance = undefined; + dbPromise = undefined; + }; + + resolve(dbInstance); + }; + + request.onerror = () => { + dbPromise = undefined; + resolve(undefined); + }; + } catch { + dbPromise = undefined; + resolve(undefined); + } }); + + return dbPromise; } interface StoredRecord { @@ -56,193 +88,272 @@ interface StoredRecord { readonly storedAt: number; } +/** Read a single record from IndexedDB. Returns undefined on any failure. */ +async function idbGet(key: CacheKey): Promise<StoredRecord | undefined> { + const db = await openDb(); + if (db === undefined) return undefined; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(key); + + request.onsuccess = () => { + resolve((request.result as StoredRecord | undefined) ?? undefined); + }; + request.onerror = () => resolve(undefined); + } catch { + resolve(undefined); + } + }); +} + +/** Write a record to IndexedDB (best-effort). */ +async function idbPut(record: StoredRecord): Promise<void> { + const db = await openDb(); + if (db === undefined) return; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +/** Delete a record from IndexedDB by key. */ +async function idbDelete(key: CacheKey): Promise<void> { + const db = await openDb(); + if (db === undefined) return; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +/** 30 days in milliseconds. */ +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + 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. + * Retrieve a cached image. + * + * Checks the in-memory Map first (instant), then falls back to IndexedDB. + * 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; + const key = makeCacheKey(kind, path); + + // Fast path: in-memory + const mem = memoryCache.get(key); + if (mem !== undefined) { + if (isExpired(mem.storedAt)) { + memoryCache.delete(key); + void idbDelete(key); + return undefined; + } + return mem; } - 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); - }; + // Slow path: IndexedDB + const record = await idbGet(key); + if (record === undefined) return undefined; + if (isExpired(record.storedAt)) { + void idbDelete(key); + return undefined; + } - tx.oncomplete = () => { - db.close(); - }; - }); + const cached: CachedImage = { + path: record.path, + kind: record.kind, + blob: record.blob, + meta: record.meta, + storedAt: record.storedAt, + }; + // Promote to memory for future fast lookups + memoryCache.set(key, cached); + return cached; }, /** * Store an image in the cache. + * + * Writes to the in-memory Map synchronously (before the first await), + * so fire-and-forget callers (`void imageCache.put(...)`) make data + * instantly visible to subsequent get() calls. + * IndexedDB persistence happens in the background. */ async put(kind: 'thumbnail' | 'full', path: string, blob: Blob, meta?: ThumbnailMeta): Promise<void> { - let db: IDBDatabase; - try { - db = await openDb(); - } catch { - return; - } + const key = makeCacheKey(kind, path); + const storedAt = Date.now(); - const record: StoredRecord = { - key: makeCacheKey(kind, path), - path, - kind, - blob, - meta, - storedAt: Date.now(), - }; + const cached: CachedImage = { path, kind, blob, meta, storedAt }; - 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(); - }; + // Instant: write to memory + memoryCache.set(key, cached); - tx.onerror = () => { - db.close(); - resolve(); - }; - }); + // Background: persist to IndexedDB + const record: StoredRecord = { key, path, kind, blob, meta, storedAt }; + await idbPut(record); }, /** * 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; + const key = makeCacheKey(kind, path); + memoryCache.delete(key); + await idbDelete(key); + }, + + /** + * Remove all expired entries from the cache. + */ + async pruneExpired(): Promise<void> { + // Prune in-memory + for (const [key, entry] of memoryCache) { + if (isExpired(entry.storedAt)) { + memoryCache.delete(key); + } } - return new Promise((resolve) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - store.delete(makeCacheKey(kind, path)); + // Prune IndexedDB + const db = await openDb(); + if (db === undefined) return; - tx.oncomplete = () => { - db.close(); - resolve(); - }; + return new Promise((resolve) => { + try { + 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 k of keysToDelete) { + store.delete(k); + } + } + }; - tx.onerror = () => { - db.close(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { resolve(); - }; + } }); }, /** - * Remove all expired entries from the cache. Call periodically or on startup. + * Get diagnostic information about the cache. */ - async pruneExpired(): Promise<void> { - let db: IDBDatabase; - try { - db = await openDb(); - } catch { - return; + async getStats(): Promise<{ + entries: number; + fullCount: number; + thumbCount: number; + totalBytes: number; + idbEntries: number; + idbBytes: number; + idbError: string | undefined; + }> { + let fullCount = 0; + let thumbCount = 0; + let totalBytes = 0; + for (const entry of memoryCache.values()) { + if (entry.kind === 'full') fullCount++; + else thumbCount++; + totalBytes += entry.blob.size; } - 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); - } - } - }; + let idbEntries = 0; + let idbBytes = 0; + let idbError: string | undefined; - tx.oncomplete = () => { - db.close(); - resolve(); - }; + try { + const db = await openDb(); + if (db !== undefined) { + const result = await new Promise<{ entries: number; bytes: number }>((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.openCursor(); + let entries = 0; + let bytes = 0; + + req.onsuccess = () => { + const cursor = req.result; + if (cursor !== null) { + const rec = cursor.value as StoredRecord; + entries++; + if (rec.blob) bytes += rec.blob.size; + cursor.continue(); + } + }; + + tx.oncomplete = () => resolve({ entries, bytes }); + tx.onerror = () => reject(new Error(tx.error?.message ?? 'unknown')); + }); + idbEntries = result.entries; + idbBytes = result.bytes; + } + } catch (e) { + idbError = e instanceof Error ? e.message : String(e); + } - tx.onerror = () => { - db.close(); - resolve(); - }; - }); + return { + entries: memoryCache.size, + fullCount, + thumbCount, + totalBytes, + idbEntries, + idbBytes, + idbError, + }; }, /** * Clear the entire cache. */ async clear(): Promise<void> { - let db: IDBDatabase; - try { - db = await openDb(); - } catch { - return; - } + memoryCache.clear(); - return new Promise((resolve) => { - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - store.clear(); + const db = await openDb(); + if (db === undefined) return; - tx.oncomplete = () => { - db.close(); - resolve(); - }; - - tx.onerror = () => { - db.close(); + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + } catch { resolve(); - }; + } }); }, } as const; diff --git a/src/lib/components/CacheDebug.svelte b/src/lib/components/CacheDebug.svelte new file mode 100644 index 0000000..293ff62 --- /dev/null +++ b/src/lib/components/CacheDebug.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { imageCache } from '../cache'; + + interface CacheStats { + entries: number; + fullCount: number; + thumbCount: number; + totalBytes: number; + idbEntries: number; + idbBytes: number; + idbError: string | undefined; + } + + let stats = $state<CacheStats | undefined>(undefined); + let refreshing = $state(false); + let collapsed = $state(false); + + function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const val = bytes / Math.pow(k, i); + return `${val.toFixed(1)} ${sizes[i]}`; + } + + async function refresh() { + refreshing = true; + try { + stats = await imageCache.getStats(); + } catch { /* ignore */ } + refreshing = false; + } + + $effect(() => { + void refresh(); + const id = setInterval(() => { void refresh(); }, 2000); + return () => clearInterval(id); + }); +</script> + +<div class="fixed top-2 left-2 z-50 bg-base-100/95 backdrop-blur-sm rounded-box shadow-lg border border-base-300 p-3 text-xs font-mono max-w-80 pointer-events-auto"> + <button + class="flex items-center justify-between w-full mb-1" + onclick={() => collapsed = !collapsed} + > + <span class="font-bold text-sm">Cache Debug</span> + <span class="text-base-content/50">{collapsed ? '▶' : '▼'}</span> + </button> + + {#if !collapsed && stats !== undefined} + <div class="space-y-2"> + <!-- Memory Cache --> + <div> + <div class="font-semibold text-primary">Memory Cache</div> + <div>Entries: <span class="text-info">{stats.entries}</span> ({stats.fullCount} full, {stats.thumbCount} thumb)</div> + <div>Size: <span class="text-info">{formatBytes(stats.totalBytes)}</span></div> + </div> + + <!-- IndexedDB --> + <div class="border-t border-base-300 pt-2"> + <div class="font-semibold text-secondary">IndexedDB</div> + {#if stats.idbError !== undefined} + <div class="text-error">Error: {stats.idbError}</div> + {:else} + <div>Entries: <span class="text-info">{stats.idbEntries}</span></div> + <div>Size: <span class="text-info">{formatBytes(stats.idbBytes)}</span></div> + <div class="mt-1"> + {#if stats.idbEntries === stats.entries} + <span class="text-success">✅ In sync with memory</span> + {:else if stats.idbEntries < stats.entries} + <span class="text-warning">⏳ IDB behind ({stats.entries - stats.idbEntries} pending)</span> + {:else} + <span class="text-info">IDB has {stats.idbEntries - stats.entries} extra (pre-loaded)</span> + {/if} + </div> + {/if} + </div> + </div> + {:else if !collapsed} + <div class="text-base-content/50"> + {#if refreshing}Loading…{:else}No data{/if} + </div> + {/if} +</div> diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte index 20a9937..914f9a1 100644 --- a/src/lib/components/ImagePreview.svelte +++ b/src/lib/components/ImagePreview.svelte @@ -224,26 +224,36 @@ }; }); + /** + * The path currently being loaded. Used to detect stale async results + * without relying on the AbortController (which the $effect cleanup + * may fire prematurely if Svelte re-schedules the effect). + */ + let activePath: string | undefined; + $effect(() => { const currentFile = file; if (currentFile === undefined) { + activePath = undefined; cleanup(); return; } + // Track which path we are loading so async callbacks can detect staleness. + activePath = currentFile.path; + // Reset zoom on file change resetZoom(); - // Clear stale state synchronously before async loads - imgNaturalW = 0; - imgNaturalH = 0; + // Revoke previous thumbnail blob URL (plain let, not $state) if (rawThumbnailUrl !== undefined) { URL.revokeObjectURL(rawThumbnailUrl); rawThumbnailUrl = undefined; } - thumbnailBlobUrl = undefined; - imageAspectRatio = '3 / 2'; + // Kick off async loaders — they handle most $state writes internally. + // resetZoom() above writes to $state (zoomLevel, panX, panY) but those + // are never read in this $effect body, so they don't add dependencies. loadThumbnail(currentFile); loadFullImage(currentFile); @@ -282,11 +292,18 @@ } async function loadThumbnail(entry: FlashAirFileEntry) { + // Reset thumbnail state at the start of each load + thumbnailBlobUrl = undefined; + imageAspectRatio = '3 / 2'; + const url = flashair.thumbnailUrl(entry.path); if (url === undefined) return; // Try cache first const cached = await imageCache.get('thumbnail', entry.path); + // Check staleness after await + if (activePath !== entry.path) return; + if (cached !== undefined) { const blobUrl = URL.createObjectURL(cached.blob); rawThumbnailUrl = blobUrl; @@ -300,6 +317,8 @@ try { const { blob, meta } = await flashair.fetchThumbnail(entry.path); + // Check staleness after await + if (activePath !== entry.path) return; // Store in cache (fire-and-forget) void imageCache.put('thumbnail', entry.path, blob, meta); const blobUrl = URL.createObjectURL(blob); @@ -315,11 +334,14 @@ } async function loadFullImage(entry: FlashAirFileEntry) { + // Reset state at the start of each load if (rawObjectUrl !== undefined) { URL.revokeObjectURL(rawObjectUrl); rawObjectUrl = undefined; } fullObjectUrl = undefined; + imgNaturalW = 0; + imgNaturalH = 0; progress = 0; loadError = undefined; @@ -331,7 +353,11 @@ // Try cache first — before setting downloading=true to avoid flicker const cached = await imageCache.get('full', entry.path); - if (cached !== undefined && !abort.signal.aborted) { + // Use activePath for staleness: the abort signal may have been tripped by + // a Svelte effect re-schedule even though the user didn't change images. + if (activePath !== entry.path) return; + + if (cached !== undefined) { const objectUrl = URL.createObjectURL(cached.blob); rawObjectUrl = objectUrl; fullObjectUrl = objectUrl; @@ -358,7 +384,7 @@ const reader = res.body?.getReader(); if (reader === undefined) { const blob = await res.blob(); - if (abort.signal.aborted) return; + if (abort.signal.aborted || activePath !== entry.path) return; // Store in cache (fire-and-forget) void imageCache.put('full', entry.path, blob); autoCacheService.markCached(entry.path); @@ -382,7 +408,7 @@ progress = totalBytes > 0 ? received / totalBytes : 0; } - if (abort.signal.aborted) return; + if (abort.signal.aborted || activePath !== entry.path) return; const blob = new Blob(chunks); // Store in cache (fire-and-forget) @@ -393,10 +419,10 @@ fullObjectUrl = objectUrl; progress = 1; } catch (e) { - if (abort.signal.aborted) return; + if (abort.signal.aborted || activePath !== entry.path) return; loadError = e instanceof Error ? e.message : String(e); } finally { - if (!abort.signal.aborted) { + if (!abort.signal.aborted && activePath === entry.path) { downloading = false; autoCacheService.resumeAfterUserDownload(); } |
