diff options
| -rw-r--r-- | src/App.svelte | 48 | ||||
| -rw-r--r-- | src/lib/cache/autoCacheService.ts | 24 | ||||
| -rw-r--r-- | src/lib/cache/imageCache.ts | 76 | ||||
| -rw-r--r-- | src/lib/components/CacheDebug.svelte | 122 | ||||
| -rw-r--r-- | src/lib/components/CachedThumbnail.svelte | 5 | ||||
| -rw-r--r-- | src/lib/components/ImageList.svelte | 2 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 4 |
7 files changed, 171 insertions, 110 deletions
diff --git a/src/App.svelte b/src/App.svelte index cd63c9b..d224306 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -147,6 +147,7 @@ newImagePaths = updated; } + let showDebug = $state(false); let saving = $state(false); async function saveToDevice() { @@ -219,9 +220,7 @@ <div class="w-36 lg:w-40 shrink-0 border-l border-base-300 flex flex-col"> <div class="px-3 py-2 bg-base-100 border-b border-base-300 shrink-0 flex items-center overflow-hidden"> <span class="text-xs font-semibold whitespace-nowrap truncate">Photos {cachedCount}/{totalCount > 0 ? totalCount : images.length}</span> - {#if isAutoCaching} - <span class="loading loading-spinner loading-xs ml-1 shrink-0"></span> - {/if} + <span class="ml-1.5 shrink-0 status status-secondary" class:status-ping={isAutoCaching}></span> </div> <div class="flex-1 min-h-0"> <ImageList {images} selectedPath={selectedFile?.path} onSelect={selectImage} {newImagePaths} onAnimationDone={handleAnimationDone} /> @@ -231,14 +230,22 @@ <!-- FAB Flower: bottom-right --> <div class="fab fab-flower"> - <div tabindex="0" class="btn btn-lg btn-circle btn-primary"> + <!-- Trigger: hamburger icon (shown when closed) --> + <div tabindex="0" class="btn btn-lg btn-circle btn-secondary"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> </svg> </div> - <div class="fab-close"> - <span class="btn btn-circle btn-lg btn-secondary">✕</span> - </div> + <!-- Save/Download (center when opened) --> + <button class="btn btn-lg btn-circle btn-primary" onclick={() => void saveToDevice()} disabled={selectedFile === undefined || saving}> + {#if saving} + <span class="loading loading-spinner loading-sm"></span> + {:else} + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /> + </svg> + {/if} + </button> <!-- Delete --> <button class="btn btn-lg btn-circle btn-error" onclick={() => requestDelete()} disabled={selectedFile === undefined || deleting}> {#if deleting} @@ -255,15 +262,11 @@ <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25 2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" /> </svg> </button> - <!-- Save to device --> - <button class="btn btn-lg btn-circle btn-secondary" onclick={() => void saveToDevice()} disabled={selectedFile === undefined || saving}> - {#if saving} - <span class="loading loading-spinner loading-sm"></span> - {:else} - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /> - </svg> - {/if} + <!-- Debug toggle --> + <button class="btn btn-lg btn-circle btn-info" onclick={() => showDebug = !showDebug}> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /> + </svg> </button> <!-- Dark mode toggle --> <button class="btn btn-lg btn-circle btn-secondary" onclick={() => (isDark = !isDark)}> @@ -277,15 +280,6 @@ </svg> {/if} </button> - <!-- Refresh --> - <button class="btn btn-lg btn-circle btn-secondary" onclick={() => loadAllImages()}> - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12a7.5 7.5 0 0 1 12.57-5.55L19.5 8.87" /> - <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 4.5v4.37h-4.37" /> - <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12a7.5 7.5 0 0 1-12.57 5.55L4.5 15.13" /> - <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5v-4.37h4.37" /> - </svg> - </button> </div> <!-- Delete confirmation modal --> @@ -305,4 +299,6 @@ </dialog> {/if} -<CacheDebug /> +{#if showDebug} + <CacheDebug /> +{/if} diff --git a/src/lib/cache/autoCacheService.ts b/src/lib/cache/autoCacheService.ts index 6d29598..b144f1d 100644 --- a/src/lib/cache/autoCacheService.ts +++ b/src/lib/cache/autoCacheService.ts @@ -45,6 +45,9 @@ class AutoCacheService { /** The AbortController for the current background download. */ private _currentAbort: AbortController | undefined; + /** Current download speed in bytes/sec. 0 when idle. */ + private _downloadSpeed = 0; + /** The image list to work through (newest first). */ private _images: FlashAirFileEntry[] = []; @@ -100,6 +103,11 @@ class AutoCacheService { return this._images.length; } + /** Current download speed in bytes/second. 0 when not downloading. */ + get downloadSpeed(): number { + return this._downloadSpeed; + } + /** Check if a path has been confirmed fully cached. */ isCached(path: string): boolean { return this._cachedPaths.has(path); @@ -245,7 +253,7 @@ class AutoCacheService { if (reader === undefined) { const blob = await res.blob(); if (abort.signal.aborted) return false; - void imageCache.put('full', image.path, blob); + void imageCache.put('full', image.path, blob, undefined, image.date.getTime()); this._progressMap.set(image.path, 1); this._notify(); return true; @@ -253,6 +261,8 @@ class AutoCacheService { const chunks: Uint8Array[] = []; let received = 0; + let speedStartTime = performance.now(); + let speedStartBytes = 0; while (true) { const { done, value } = await reader.read(); @@ -261,6 +271,17 @@ class AutoCacheService { received += value.byteLength; const progress = totalBytes > 0 ? received / totalBytes : 0; this._progressMap.set(image.path, progress); + + // Calculate speed every ~500ms to avoid excessive jitter + const now = performance.now(); + const elapsed = now - speedStartTime; + if (elapsed >= 500) { + const bytesInWindow = received - speedStartBytes; + this._downloadSpeed = Math.round(bytesInWindow / (elapsed / 1000)); + speedStartTime = now; + speedStartBytes = received; + } + this._notify(); } @@ -278,6 +299,7 @@ class AutoCacheService { return false; } finally { this._downloading = false; + this._downloadSpeed = 0; this._currentAbort = undefined; } } diff --git a/src/lib/cache/imageCache.ts b/src/lib/cache/imageCache.ts index 5ff7393..0ccb975 100644 --- a/src/lib/cache/imageCache.ts +++ b/src/lib/cache/imageCache.ts @@ -15,6 +15,8 @@ export interface CachedImage { readonly meta: ThumbnailMeta | undefined; /** Unix timestamp (ms) when this entry was stored. */ readonly storedAt: number; + /** Unix timestamp (ms) of the file's date on the camera. Used for eviction priority. */ + readonly fileDate: number | undefined; } type CacheKey = `${'thumbnail' | 'full'}:${string}`; @@ -86,6 +88,7 @@ interface StoredRecord { readonly blob: Blob; readonly meta: ThumbnailMeta | undefined; readonly storedAt: number; + readonly fileDate: number | undefined; } /** Read a single record from IndexedDB. Returns undefined on any failure. */ @@ -162,8 +165,44 @@ async function idbDelete(key: CacheKey): Promise<void> { }); } -/** 30 days in milliseconds. */ -const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** 3 days in milliseconds. */ +const CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; + +/** Maximum total size of cached blobs (memory + IDB). 850 MB. */ +const MAX_CACHE_BYTES = 850 * 1024 * 1024; + +/** Current total bytes tracked in the memory cache. */ +let memoryCacheTotalBytes = 0; + +/** + * Evict the oldest entries from the cache until we're under the size budget. + * Evicts from both memory and IndexedDB. + */ +async function evictIfOverBudget(): Promise<void> { + if (memoryCacheTotalBytes <= MAX_CACHE_BYTES) return; + + // Sort entries for eviction: oldest camera file date first. + // Full images before thumbnails (evict large items first for faster budget recovery). + // Entries without fileDate are evicted first (they're from older cache formats). + const entries = [...memoryCache.entries()].sort((a, b) => { + // Full images before thumbnails + if (a[1].kind !== b[1].kind) { + return a[1].kind === 'full' ? -1 : 1; + } + // Entries without fileDate go first (unknown = low priority) + const aDate = a[1].fileDate ?? 0; + const bDate = b[1].fileDate ?? 0; + // Oldest file date first (smallest timestamp = taken earliest = evict first) + return aDate - bDate; + }); + + for (const [key, entry] of entries) { + if (memoryCacheTotalBytes <= MAX_CACHE_BYTES) break; + memoryCacheTotalBytes -= entry.blob.size; + memoryCache.delete(key); + void idbDelete(key); + } +} function isExpired(storedAt: number): boolean { return Date.now() - storedAt > CACHE_TTL_MS; @@ -183,6 +222,7 @@ export const imageCache = { const mem = memoryCache.get(key); if (mem !== undefined) { if (isExpired(mem.storedAt)) { + memoryCacheTotalBytes -= mem.blob.size; memoryCache.delete(key); void idbDelete(key); return undefined; @@ -204,9 +244,11 @@ export const imageCache = { blob: record.blob, meta: record.meta, storedAt: record.storedAt, + fileDate: record.fileDate, }; // Promote to memory for future fast lookups memoryCache.set(key, cached); + memoryCacheTotalBytes += cached.blob.size; return cached; }, @@ -218,17 +260,31 @@ export const imageCache = { * 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> { + async put( + kind: 'thumbnail' | 'full', + path: string, + blob: Blob, + meta?: ThumbnailMeta, + fileDate?: number, + ): Promise<void> { const key = makeCacheKey(kind, path); const storedAt = Date.now(); - const cached: CachedImage = { path, kind, blob, meta, storedAt }; + const cached: CachedImage = { path, kind, blob, meta, storedAt, fileDate }; - // Instant: write to memory + // Instant: write to memory (replace existing if present) + const existing = memoryCache.get(key); + if (existing !== undefined) { + memoryCacheTotalBytes -= existing.blob.size; + } memoryCache.set(key, cached); + memoryCacheTotalBytes += blob.size; + + // Evict oldest entries if over budget + void evictIfOverBudget(); // Background: persist to IndexedDB - const record: StoredRecord = { key, path, kind, blob, meta, storedAt }; + const record: StoredRecord = { key, path, kind, blob, meta, storedAt, fileDate }; await idbPut(record); }, @@ -237,6 +293,10 @@ export const imageCache = { */ async delete(kind: 'thumbnail' | 'full', path: string): Promise<void> { const key = makeCacheKey(kind, path); + const existing = memoryCache.get(key); + if (existing !== undefined) { + memoryCacheTotalBytes -= existing.blob.size; + } memoryCache.delete(key); await idbDelete(key); }, @@ -248,6 +308,7 @@ export const imageCache = { // Prune in-memory for (const [key, entry] of memoryCache) { if (isExpired(entry.storedAt)) { + memoryCacheTotalBytes -= entry.blob.size; memoryCache.delete(key); } } @@ -294,6 +355,7 @@ export const imageCache = { fullCount: number; thumbCount: number; totalBytes: number; + maxBytes: number; idbEntries: number; idbBytes: number; idbError: string | undefined; @@ -348,6 +410,7 @@ export const imageCache = { fullCount, thumbCount, totalBytes, + maxBytes: MAX_CACHE_BYTES, idbEntries, idbBytes, idbError, @@ -361,6 +424,7 @@ export const imageCache = { */ async clear(): Promise<void> { memoryCache.clear(); + memoryCacheTotalBytes = 0; const db = await openDb(); if (db === undefined) return; diff --git a/src/lib/components/CacheDebug.svelte b/src/lib/components/CacheDebug.svelte index 09e859d..ea1386d 100644 --- a/src/lib/components/CacheDebug.svelte +++ b/src/lib/components/CacheDebug.svelte @@ -1,11 +1,14 @@ <script lang="ts"> import { imageCache } from '../cache'; + import { autoCacheService } from '../cache'; + import { fly } from 'svelte/transition'; interface CacheStats { entries: number; fullCount: number; thumbCount: number; totalBytes: number; + maxBytes: number; idbEntries: number; idbBytes: number; idbError: string | undefined; @@ -14,8 +17,18 @@ } let stats = $state<CacheStats | undefined>(undefined); - let refreshing = $state(false); - let collapsed = $state(false); + + let idbBudgetPct = $derived( + stats !== undefined && stats.maxBytes > 0 + ? (stats.idbBytes / stats.maxBytes * 100) + : 0 + ); + + let budgetPct = $derived( + stats !== undefined && stats.maxBytes > 0 + ? (stats.totalBytes / stats.maxBytes * 100) + : 0 + ); function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -26,45 +39,15 @@ return `${val.toFixed(1)} ${sizes[i]}`; } - let storageEstimate = $state<{ usage: number; quota: number } | undefined>(undefined); - let storageError = $state<string | undefined>(undefined); + let downloadSpeed = $state(0); async function refresh() { - refreshing = true; try { stats = await imageCache.getStats(); - } catch { /* ignore */ } - - // Try standard Storage API first, then webkit fallback - storageEstimate = undefined; - storageError = undefined; - try { - if (navigator.storage && navigator.storage.estimate) { - const est = await navigator.storage.estimate(); - storageEstimate = { usage: est.usage ?? 0, quota: est.quota ?? 0 }; - } else if ('webkitTemporaryStorage' in navigator) { - // Chrome HTTP fallback - const wk = (navigator as Record<string, unknown>)['webkitTemporaryStorage'] as - { queryUsageAndQuota: (ok: (u: number, q: number) => void, err: (e: unknown) => void) => void } | undefined; - if (wk !== undefined) { - const result = await new Promise<{ usage: number; quota: number }>((resolve, reject) => { - wk.queryUsageAndQuota( - (usage, quota) => resolve({ usage, quota }), - (err) => reject(err), - ); - }); - storageEstimate = result; - } else { - storageError = 'Storage API not available (HTTP origin)'; - } - } else { - storageError = 'Storage API not available'; - } - } catch (e) { - storageError = e instanceof Error ? e.message : String(e); + } catch { + // ignore } - - refreshing = false; + downloadSpeed = autoCacheService.downloadSpeed; } $effect(() => { @@ -74,22 +57,33 @@ }); </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> +<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" + transition:fly={{ x: -320, duration: 250 }} +> + <div class="font-bold text-sm mb-2">Cache Debug</div> - {#if !collapsed && stats !== undefined} + {#if stats !== undefined} <div class="space-y-2"> + <!-- Download Speed --> + <div> + <span class="font-semibold">DL Speed:</span> + {#if downloadSpeed > 0} + <span class="text-info">{formatBytes(downloadSpeed)}/s</span> + {:else} + <span class="text-base-content/50">N/A</span> + {/if} + </div> + <!-- 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>Size: <span class="text-info">{formatBytes(stats.totalBytes)}</span> / {formatBytes(stats.maxBytes)}</div> + <div> + <progress class="progress progress-primary w-full h-1.5" value={budgetPct} max="100"></progress> + <span class="text-[10px]" class:text-error={budgetPct > 90} class:text-warning={budgetPct > 70 && budgetPct <= 90}>{budgetPct.toFixed(1)}% of budget</span> + </div> </div> <!-- IndexedDB --> @@ -99,46 +93,30 @@ <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>Size: <span class="text-info">{formatBytes(stats.idbBytes)}</span> / {formatBytes(stats.maxBytes)}</div> + <div> + <progress class="progress progress-secondary w-full h-1.5" value={idbBudgetPct} max="100"></progress> + <span class="text-[10px]" class:text-error={idbBudgetPct > 90} class:text-warning={idbBudgetPct > 70 && idbBudgetPct <= 90}>{idbBudgetPct.toFixed(1)}% of budget</span> + </div> {#if stats.idbWriteErrorCount > 0} - <div class="text-error mt-1">❌ {stats.idbWriteErrorCount} write errors</div> + <div class="text-error mt-1">{stats.idbWriteErrorCount} write errors</div> {#if stats.idbLastWriteError !== undefined} <div class="text-error text-[10px] break-all">{stats.idbLastWriteError}</div> {/if} {/if} <div class="mt-1"> {#if stats.idbEntries === stats.entries} - <span class="text-success">✅ In sync with memory</span> + <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> + <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> - <!-- Storage Quota --> - <div class="border-t border-base-300 pt-2"> - <div class="font-semibold text-accent">Storage Quota</div> - {#if storageEstimate !== undefined} - <div>Used: <span class="text-info">{formatBytes(storageEstimate.usage)}</span> / {formatBytes(storageEstimate.quota)}</div> - {#if storageEstimate.quota > 0} - {@const pct = (storageEstimate.usage / storageEstimate.quota * 100)} - <div> - <progress class="progress progress-primary w-full h-2" value={pct} max="100"></progress> - <span class:text-error={pct > 90} class:text-warning={pct > 70 && pct <= 90}>{pct.toFixed(1)}%</span> - </div> - {/if} - {:else if storageError !== undefined} - <div class="text-warning">{storageError}</div> - {:else} - <div class="text-base-content/50">Querying…</div> - {/if} - </div> - </div> - {:else if !collapsed} - <div class="text-base-content/50"> - {#if refreshing}Loading…{:else}No data{/if} </div> + {:else} + <div class="text-base-content/50">Loading…</div> {/if} </div> diff --git a/src/lib/components/CachedThumbnail.svelte b/src/lib/components/CachedThumbnail.svelte index 3a6d452..dada22a 100644 --- a/src/lib/components/CachedThumbnail.svelte +++ b/src/lib/components/CachedThumbnail.svelte @@ -6,9 +6,10 @@ interface Props { path: string; alt: string; + fileDate?: number; } - let { path, alt }: Props = $props(); + let { path, alt, fileDate }: Props = $props(); let blobUrl = $state<string | undefined>(undefined); let rawBlobUrl: string | undefined; @@ -56,7 +57,7 @@ try { const { blob, meta } = await flashair.fetchThumbnail(filePath); // Store in cache (fire-and-forget) - void imageCache.put('thumbnail', filePath, blob, meta); + void imageCache.put('thumbnail', filePath, blob, meta, fileDate); const url = URL.createObjectURL(blob); rawBlobUrl = url; blobUrl = url; diff --git a/src/lib/components/ImageList.svelte b/src/lib/components/ImageList.svelte index 5048e1b..d56c95c 100644 --- a/src/lib/components/ImageList.svelte +++ b/src/lib/components/ImageList.svelte @@ -40,7 +40,7 @@ onclick={() => onSelect(file)} > {#if thumbUrl} - <CachedThumbnail path={file.path} alt={file.filename} /> + <CachedThumbnail path={file.path} alt={file.filename} fileDate={file.date.getTime()} /> {: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 914f9a1..335e231 100644 --- a/src/lib/components/ImagePreview.svelte +++ b/src/lib/components/ImagePreview.svelte @@ -320,7 +320,7 @@ // Check staleness after await if (activePath !== entry.path) return; // Store in cache (fire-and-forget) - void imageCache.put('thumbnail', entry.path, blob, meta); + void imageCache.put('thumbnail', entry.path, blob, meta, entry.date.getTime()); const blobUrl = URL.createObjectURL(blob); rawThumbnailUrl = blobUrl; thumbnailBlobUrl = blobUrl; @@ -386,7 +386,7 @@ const blob = await res.blob(); if (abort.signal.aborted || activePath !== entry.path) return; // Store in cache (fire-and-forget) - void imageCache.put('full', entry.path, blob); + void imageCache.put('full', entry.path, blob, undefined, entry.date.getTime()); autoCacheService.markCached(entry.path); const objectUrl = URL.createObjectURL(blob); rawObjectUrl = objectUrl; |
