diff options
| author | Adam Malczewski <[email protected]> | 2026-04-09 19:55:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-09 19:55:19 +0900 |
| commit | bafc36cbb2287ecb3b806cb32118a9ee9d5f7170 (patch) | |
| tree | 2b2196ef84bb20dc97bcdd61b15c7154aa569733 /src/lib | |
| parent | a39e2501b81c1e52437839e018bd5bf07b46616c (diff) | |
| download | flashair-speedsync-bafc36cbb2287ecb3b806cb32118a9ee9d5f7170.tar.gz flashair-speedsync-bafc36cbb2287ecb3b806cb32118a9ee9d5f7170.zip | |
auto-caching setup
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/cache/autoCacheService.ts | 255 | ||||
| -rw-r--r-- | src/lib/cache/index.ts | 1 | ||||
| -rw-r--r-- | src/lib/components/CachedThumbnail.svelte | 38 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 13 |
4 files changed, 301 insertions, 6 deletions
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(); } } } |
