summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-09 19:55:19 +0900
committerAdam Malczewski <[email protected]>2026-04-09 19:55:19 +0900
commitbafc36cbb2287ecb3b806cb32118a9ee9d5f7170 (patch)
tree2b2196ef84bb20dc97bcdd61b15c7154aa569733
parenta39e2501b81c1e52437839e018bd5bf07b46616c (diff)
downloadflashair-speedsync-bafc36cbb2287ecb3b806cb32118a9ee9d5f7170.tar.gz
flashair-speedsync-bafc36cbb2287ecb3b806cb32118a9ee9d5f7170.zip
auto-caching setup
-rw-r--r--src/App.svelte23
-rw-r--r--src/lib/cache/autoCacheService.ts255
-rw-r--r--src/lib/cache/index.ts1
-rw-r--r--src/lib/components/CachedThumbnail.svelte38
-rw-r--r--src/lib/components/ImagePreview.svelte13
5 files changed, 323 insertions, 7 deletions
diff --git a/src/App.svelte b/src/App.svelte
index bbc0c43..7d99875 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -2,6 +2,7 @@
import { flashair } from './lib/flashair';
import type { FlashAirFileEntry } from './lib/flashair';
import { imageCache } from './lib/cache';
+ import { autoCacheService } from './lib/cache';
import ImageList from './lib/components/ImageList.svelte';
import ImagePreview from './lib/components/ImagePreview.svelte';
@@ -20,11 +21,15 @@
async function loadAllImages() {
loading = true;
error = undefined;
+ autoCacheService.stop();
try {
images = await flashair.listAllImages('/DCIM');
if (images.length > 0 && selectedFile === undefined) {
selectedFile = images[0];
}
+ if (images.length > 0) {
+ autoCacheService.start(images);
+ }
} catch (e) {
error = e instanceof Error ? e.message : String(e);
images = [];
@@ -50,9 +55,10 @@
deleting = true;
try {
await flashair.deleteFile(fileToDelete.path);
- // Remove from cache
+ // Remove from cache and auto-cache queue
void imageCache.delete('thumbnail', fileToDelete.path);
void imageCache.delete('full', fileToDelete.path);
+ autoCacheService.removeImage(fileToDelete.path);
// Remove from list and select next image
const idx = images.findIndex((f) => f.path === fileToDelete.path);
images = images.filter((f) => f.path !== fileToDelete.path);
@@ -73,6 +79,15 @@
function cancelDelete() {
showDeleteConfirm = false;
}
+
+ async function clearAllCache() {
+ autoCacheService.stop();
+ await imageCache.clear();
+ // Restart auto-caching from scratch
+ if (images.length > 0) {
+ autoCacheService.start(images);
+ }
+ }
</script>
<div class="flex h-screen bg-base-200">
@@ -144,6 +159,12 @@
</svg>
{/if}
</button>
+ <!-- Clear Cache -->
+ <button class="btn btn-lg btn-circle btn-warning" onclick={() => void clearAllCache()}>
+ <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="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>
<!-- 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">
diff --git a/src/lib/cache/autoCacheService.ts b/src/lib/cache/autoCacheService.ts
new file mode 100644
index 0000000..04dc92b
--- /dev/null
+++ b/src/lib/cache/autoCacheService.ts
@@ -0,0 +1,255 @@
+import { flashair } from '../flashair';
+import type { FlashAirFileEntry } from '../flashair';
+import { imageCache } from './imageCache';
+
+/**
+ * Reactive state for the auto-cache background service.
+ *
+ * Uses Svelte 5 $state runes via the .svelte.ts convention — but since this
+ * is a plain .ts file consumed by Svelte components, we store reactive values
+ * in a simple mutable object that components can poll or subscribe to via
+ * a wrapper. We use a manual callback approach instead.
+ */
+
+/** Progress of a single image being auto-cached (0–1). */
+export interface AutoCacheProgress {
+ readonly path: string;
+ readonly progress: number;
+}
+
+type Listener = () => void;
+
+/**
+ * Singleton auto-cache service.
+ *
+ * Call `start(images)` after loading the image list. The service will
+ * iterate newest-first, skip already-cached images, and download full-size
+ * images in the background one at a time.
+ *
+ * When a user-initiated download is active, call `pauseForUserDownload()`
+ * / `resumeAfterUserDownload()` to yield bandwidth.
+ */
+class AutoCacheService {
+ /** Map from image path → download progress 0–1. Only contains entries being cached. */
+ private _progressMap = new Map<string, number>();
+
+ /** Set of paths that are confirmed cached (full). */
+ private _cachedPaths = new Set<string>();
+
+ /** Whether the user is actively downloading an image. */
+ private _userDownloading = false;
+
+ /** The AbortController for the current background download. */
+ private _currentAbort: AbortController | undefined;
+
+ /** The image list to work through (newest first). */
+ private _images: FlashAirFileEntry[] = [];
+
+ /** Index of the next image to check/download. */
+ private _nextIndex = 0;
+
+ /** Whether the service is running. */
+ private _running = false;
+
+ /** Whether we're actively downloading right now. */
+ private _downloading = false;
+
+ /** Listeners notified on every progress change. */
+ private _listeners = new Set<Listener>();
+
+ /** Subscribe to state changes. Returns an unsubscribe function. */
+ subscribe(fn: Listener): () => void {
+ this._listeners.add(fn);
+ return () => {
+ this._listeners.delete(fn);
+ };
+ }
+
+ private _notify(): void {
+ for (const fn of this._listeners) {
+ fn();
+ }
+ }
+
+ /** Get progress for a specific image path. Returns undefined if not being cached. */
+ getProgress(path: string): number | undefined {
+ return this._progressMap.get(path);
+ }
+
+ /** Check if a path has been confirmed fully cached. */
+ isCached(path: string): boolean {
+ return this._cachedPaths.has(path);
+ }
+
+ /**
+ * Start (or restart) auto-caching for the given image list.
+ * Images should already be sorted newest-first.
+ */
+ start(images: readonly FlashAirFileEntry[]): void {
+ this.stop();
+ this._images = [...images];
+ this._nextIndex = 0;
+ this._running = true;
+ this._cachedPaths.clear();
+ this._progressMap.clear();
+ this._notify();
+ void this._processNext();
+ }
+
+ /** Stop all background caching. */
+ stop(): void {
+ this._running = false;
+ if (this._currentAbort !== undefined) {
+ this._currentAbort.abort();
+ this._currentAbort = undefined;
+ }
+ this._progressMap.clear();
+ this._downloading = false;
+ this._notify();
+ }
+
+ /** Mark a path as cached externally (e.g. the user just viewed it). */
+ markCached(path: string): void {
+ this._cachedPaths.add(path);
+ }
+
+ /** Pause background downloads while the user is downloading. */
+ pauseForUserDownload(): void {
+ this._userDownloading = true;
+ if (this._currentAbort !== undefined) {
+ this._currentAbort.abort();
+ this._currentAbort = undefined;
+ }
+ // Clear progress for the aborted download — it will be retried.
+ this._progressMap.clear();
+ this._downloading = false;
+ this._notify();
+ }
+
+ /** Resume background downloads after user download completes. */
+ resumeAfterUserDownload(): void {
+ this._userDownloading = false;
+ if (this._running) {
+ void this._processNext();
+ }
+ }
+
+ /** Remove a path from the work queue (e.g. after deletion). */
+ removeImage(path: string): void {
+ this._images = this._images.filter((img) => img.path !== path);
+ this._cachedPaths.delete(path);
+ this._progressMap.delete(path);
+ // Reset index to avoid skipping
+ if (this._nextIndex > this._images.length) {
+ this._nextIndex = this._images.length;
+ }
+ this._notify();
+ }
+
+ private async _processNext(): Promise<void> {
+ if (!this._running || this._userDownloading || this._downloading) return;
+
+ while (this._nextIndex < this._images.length) {
+ if (!this._running || this._userDownloading) return;
+
+ const image = this._images[this._nextIndex];
+ if (image === undefined) {
+ this._nextIndex++;
+ continue;
+ }
+
+ // Skip if already confirmed cached
+ if (this._cachedPaths.has(image.path)) {
+ this._nextIndex++;
+ continue;
+ }
+
+ // Check IndexedDB cache
+ const cached = await imageCache.get('full', image.path);
+ if (cached !== undefined) {
+ this._cachedPaths.add(image.path);
+ this._nextIndex++;
+ this._notify();
+ continue;
+ }
+
+ // Not cached — download it
+ if (this._userDownloading || !this._running) return;
+
+ const success = await this._downloadImage(image);
+ if (success) {
+ this._cachedPaths.add(image.path);
+ this._progressMap.delete(image.path);
+ this._nextIndex++;
+ this._notify();
+ } else if (this._running && !this._userDownloading) {
+ // Download failed (not aborted) — skip this image and move on
+ this._progressMap.delete(image.path);
+ this._nextIndex++;
+ this._notify();
+ }
+ // If aborted due to user download, don't advance — will retry
+ }
+ }
+
+ private async _downloadImage(image: FlashAirFileEntry): Promise<boolean> {
+ this._downloading = true;
+ const abort = new AbortController();
+ this._currentAbort = abort;
+
+ this._progressMap.set(image.path, 0);
+ this._notify();
+
+ const url = flashair.fileUrl(image.path);
+ const totalBytes = image.size;
+
+ try {
+ const res = await fetch(url, { signal: abort.signal });
+ if (!res.ok) {
+ throw new Error(`${res.status} ${res.statusText}`);
+ }
+
+ const reader = res.body?.getReader();
+ if (reader === undefined) {
+ const blob = await res.blob();
+ if (abort.signal.aborted) return false;
+ void imageCache.put('full', image.path, blob);
+ this._progressMap.set(image.path, 1);
+ this._notify();
+ return true;
+ }
+
+ const chunks: Uint8Array[] = [];
+ let received = 0;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ received += value.byteLength;
+ const progress = totalBytes > 0 ? received / totalBytes : 0;
+ this._progressMap.set(image.path, progress);
+ this._notify();
+ }
+
+ if (abort.signal.aborted) return false;
+
+ const blob = new Blob(chunks);
+ void imageCache.put('full', image.path, blob);
+ this._progressMap.set(image.path, 1);
+ this._notify();
+ return true;
+ } catch (e) {
+ if (abort.signal.aborted) return false;
+ // Network error — skip this image
+ console.warn(`Auto-cache failed for ${image.path}:`, e);
+ return false;
+ } finally {
+ this._downloading = false;
+ this._currentAbort = undefined;
+ }
+ }
+}
+
+/** Singleton instance. */
+export const autoCacheService = new AutoCacheService();
diff --git a/src/lib/cache/index.ts b/src/lib/cache/index.ts
index 7ce52dc..18f873b 100644
--- a/src/lib/cache/index.ts
+++ b/src/lib/cache/index.ts
@@ -1,2 +1,3 @@
export { imageCache } from './imageCache';
export type { CachedImage } from './imageCache';
+export { autoCacheService } from './autoCacheService';
diff --git a/src/lib/components/CachedThumbnail.svelte b/src/lib/components/CachedThumbnail.svelte
index f0fe8bf..3a6d452 100644
--- a/src/lib/components/CachedThumbnail.svelte
+++ b/src/lib/components/CachedThumbnail.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { flashair } from '../flashair';
import { imageCache } from '../cache';
+ import { autoCacheService } from '../cache';
interface Props {
path: string;
@@ -11,6 +12,7 @@
let blobUrl = $state<string | undefined>(undefined);
let rawBlobUrl: string | undefined;
+ let cacheProgress = $state<number | undefined>(undefined);
$effect(() => {
const currentPath = path;
@@ -26,6 +28,17 @@
};
});
+ // Subscribe to auto-cache progress updates
+ $effect(() => {
+ const currentPath = path;
+ const unsubscribe = autoCacheService.subscribe(() => {
+ cacheProgress = autoCacheService.getProgress(currentPath);
+ });
+ // Set initial value
+ cacheProgress = autoCacheService.getProgress(currentPath);
+ return unsubscribe;
+ });
+
async function loadThumbnail(filePath: string) {
// Try cache first
const cached = await imageCache.get('thumbnail', filePath);
@@ -51,15 +64,28 @@
// Thumbnail fetch failed — show placeholder
}
}
+
+ let cachePercent = $derived(
+ cacheProgress !== undefined ? Math.round(cacheProgress * 100) : undefined
+ );
</script>
{#if blobUrl !== undefined}
- <img
- src={blobUrl}
- {alt}
- class="w-full aspect-square rounded object-cover"
- draggable="false"
- />
+ <div class="relative w-full aspect-square">
+ <img
+ src={blobUrl}
+ {alt}
+ class="w-full aspect-square rounded object-cover"
+ draggable="false"
+ />
+ {#if cachePercent !== undefined && cachePercent < 100}
+ <div class="absolute inset-0 flex items-center justify-center">
+ <div class="radial-progress text-primary bg-base-300/60 border-2 border-base-300/60" style:--value={cachePercent} style:--size="2.5rem" style:--thickness="3px" role="progressbar">
+ <span class="text-[0.6rem] font-bold text-primary-content drop-shadow">{cachePercent}%</span>
+ </div>
+ </div>
+ {/if}
+ </div>
{:else}
<div class="w-full aspect-square rounded bg-base-300 flex items-center justify-center">
<span class="loading loading-spinner loading-sm"></span>
diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte
index f5032a6..20a9937 100644
--- a/src/lib/components/ImagePreview.svelte
+++ b/src/lib/components/ImagePreview.svelte
@@ -2,6 +2,7 @@
import { flashair } from '../flashair';
import type { FlashAirFileEntry } from '../flashair';
import { imageCache } from '../cache';
+ import { autoCacheService } from '../cache';
interface Props {
file: FlashAirFileEntry | undefined;
@@ -251,6 +252,12 @@
currentAbort.abort();
currentAbort = undefined;
}
+ // If we were downloading and got aborted (e.g. user navigated away),
+ // make sure to resume the auto-cache service.
+ if (downloading) {
+ downloading = false;
+ autoCacheService.resumeAfterUserDownload();
+ }
};
});
@@ -330,12 +337,14 @@
fullObjectUrl = objectUrl;
progress = 1;
downloading = false;
+ autoCacheService.markCached(entry.path);
return;
}
downloading = true;
progress = 0;
loadError = undefined;
+ autoCacheService.pauseForUserDownload();
const url = flashair.fileUrl(entry.path);
const totalBytes = entry.size;
@@ -352,11 +361,13 @@
if (abort.signal.aborted) return;
// Store in cache (fire-and-forget)
void imageCache.put('full', entry.path, blob);
+ autoCacheService.markCached(entry.path);
const objectUrl = URL.createObjectURL(blob);
rawObjectUrl = objectUrl;
fullObjectUrl = objectUrl;
progress = 1;
downloading = false;
+ autoCacheService.resumeAfterUserDownload();
return;
}
@@ -376,6 +387,7 @@
const blob = new Blob(chunks);
// Store in cache (fire-and-forget)
void imageCache.put('full', entry.path, blob);
+ autoCacheService.markCached(entry.path);
const objectUrl = URL.createObjectURL(blob);
rawObjectUrl = objectUrl;
fullObjectUrl = objectUrl;
@@ -386,6 +398,7 @@
} finally {
if (!abort.signal.aborted) {
downloading = false;
+ autoCacheService.resumeAfterUserDownload();
}
}
}