summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/lib/cache/imageCache.ts248
-rw-r--r--src/lib/cache/index.ts2
-rw-r--r--src/lib/components/CachedThumbnail.svelte67
-rw-r--r--src/lib/components/ImageList.svelte8
-rw-r--r--src/lib/components/ImagePreview.svelte31
-rw-r--r--src/main.ts4
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')!,