diff options
| author | Adam Malczewski <[email protected]> | 2026-04-09 20:52:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-09 20:52:18 +0900 |
| commit | b5a5ea8d300284e0f473df0e514cdc642857b03c (patch) | |
| tree | e791331edfcb3e75885a420d4f7e8bfe9a5f0506 /src | |
| parent | cbf41c0e495367bb3f582ea22aabdc03bd2d046d (diff) | |
| download | flashair-speedsync-b5a5ea8d300284e0f473df0e514cdc642857b03c.tar.gz flashair-speedsync-b5a5ea8d300284e0f473df0e514cdc642857b03c.zip | |
auto-polling of new photos
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.svelte | 57 | ||||
| -rw-r--r-- | src/app.css | 23 | ||||
| -rw-r--r-- | src/lib/cache/autoCacheService.ts | 41 | ||||
| -rw-r--r-- | src/lib/components/ImageList.svelte | 14 | ||||
| -rw-r--r-- | src/lib/flashair/index.ts | 1 | ||||
| -rw-r--r-- | src/lib/flashair/pollService.ts | 259 |
6 files changed, 378 insertions, 17 deletions
diff --git a/src/App.svelte b/src/App.svelte index 03cd5c1..6f6ef6d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { flashair } from './lib/flashair'; + import { pollService } from './lib/flashair'; import type { FlashAirFileEntry } from './lib/flashair'; import { imageCache } from './lib/cache'; import { autoCacheService } from './lib/cache'; @@ -16,12 +17,29 @@ let isAutoCaching = $state(false); let cachedCount = $state(0); let totalCount = $state(0); + let newImagePaths = $state(new Set<string>()); + + // Wire the auto-cache service to do a poll check between each download. + // This pauses the interval during auto-caching and uses checkOnce() instead. + autoCacheService.onBetweenDownloads = async () => { + await pollService.checkOnce(); + }; + + function startAutoCacheWithPollPause(imageList: FlashAirFileEntry[]) { + pollService.pause(); + autoCacheService.start(imageList); + } $effect(() => { const unsubscribe = autoCacheService.subscribe(() => { isAutoCaching = autoCacheService.isActive; cachedCount = autoCacheService.cachedCount; totalCount = autoCacheService.totalCount; + + // When auto-caching finishes, resume the poll interval + if (!autoCacheService.isActive) { + pollService.resume(); + } }); isAutoCaching = autoCacheService.isActive; cachedCount = autoCacheService.cachedCount; @@ -33,17 +51,43 @@ document.documentElement.setAttribute('data-theme', isDark ? 'black' : 'cmyk'); }); + $effect(() => { + const unsubscribe = pollService.onNewImages((detected) => { + // Mark new paths for animation + const freshPaths = new Set(newImagePaths); + for (const img of detected) { + freshPaths.add(img.path); + } + newImagePaths = freshPaths; + + // Prepend new images (they are already sorted newest-first from listAllImages) + images = [...detected, ...images]; + + // Feed new images into auto-cache service + autoCacheService.stop(); + startAutoCacheWithPollPause(images); + + // Auto-select the newest if nothing was selected + if (selectedFile === undefined && images.length > 0) { + selectedFile = images[0]; + } + }); + return unsubscribe; + }); + async function loadAllImages() { loading = true; error = undefined; autoCacheService.stop(); + pollService.stop(); try { images = await flashair.listAllImages('/DCIM'); if (images.length > 0 && selectedFile === undefined) { selectedFile = images[0]; } + pollService.start(images); if (images.length > 0) { - autoCacheService.start(images); + startAutoCacheWithPollPause(images); } } catch (e) { error = e instanceof Error ? e.message : String(e); @@ -74,6 +118,7 @@ void imageCache.delete('thumbnail', fileToDelete.path); void imageCache.delete('full', fileToDelete.path); autoCacheService.removeImage(fileToDelete.path); + pollService.removeKnownPath(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); @@ -95,12 +140,18 @@ showDeleteConfirm = false; } + function handleAnimationDone(path: string) { + const updated = new Set(newImagePaths); + updated.delete(path); + newImagePaths = updated; + } + async function clearAllCache() { autoCacheService.stop(); await imageCache.clear(); // Restart auto-caching from scratch if (images.length > 0) { - autoCacheService.start(images); + startAutoCacheWithPollPause(images); } } </script> @@ -140,7 +191,7 @@ {/if} </div> <div class="flex-1 min-h-0"> - <ImageList {images} selectedPath={selectedFile?.path} onSelect={selectImage} /> + <ImageList {images} selectedPath={selectedFile?.path} onSelect={selectImage} {newImagePaths} onAnimationDone={handleAnimationDone} /> </div> </div> </div> diff --git a/src/app.css b/src/app.css index 9a009fc..9fffc26 100644 --- a/src/app.css +++ b/src/app.css @@ -7,3 +7,26 @@ html, body { touch-action: pan-x pan-y; overscroll-behavior: none; } + +/* + * Reveal animation for newly detected sidebar images. + * Items use a CSS keyframe animation: start at max-height 0 (content clipped, + * not stretched) and grow to full natural height. The large max-height target + * ensures any item fits; the element collapses to its intrinsic height once done. + */ +.image-reveal { + overflow: hidden; +} + +.image-reveal-enter { + animation: reveal-grow 600ms ease-out forwards; +} + +@keyframes reveal-grow { + from { + max-height: 0; + } + to { + max-height: 500px; + } +} diff --git a/src/lib/cache/autoCacheService.ts b/src/lib/cache/autoCacheService.ts index e5a6584..6d29598 100644 --- a/src/lib/cache/autoCacheService.ts +++ b/src/lib/cache/autoCacheService.ts @@ -2,15 +2,6 @@ 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; @@ -20,14 +11,26 @@ export interface AutoCacheProgress { type Listener = () => void; /** + * Callback invoked between each auto-cache download. + * The auto-cache service will wait for this to resolve before starting + * the next download. Used to give the poll service a chance to check + * for new images without HTTP contention. + */ +type BetweenDownloadsHook = () => Promise<void>; + +/** * Singleton auto-cache service. * - * Call `start(images)` after loading the image list. The service will + * 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. + * + * Set `onBetweenDownloads` to a hook that runs between each cached image. + * This gives external services (e.g. the poll service) a window to use the + * FlashAir HTTP connection without contention. */ class AutoCacheService { /** Map from image path → download progress 0–1. Only contains entries being cached. */ @@ -57,6 +60,12 @@ class AutoCacheService { /** Listeners notified on every progress change. */ private _listeners = new Set<Listener>(); + /** + * Optional hook called between downloads. The service awaits this before + * starting the next download. + */ + onBetweenDownloads: BetweenDownloadsHook | undefined; + /** Subscribe to state changes. Returns an unsubscribe function. */ subscribe(fn: Listener): () => void { this._listeners.add(fn); @@ -188,8 +197,16 @@ class AutoCacheService { continue; } - // Not cached — download it - if (this._userDownloading || !this._running) return; + // Not cached — run the between-downloads hook (e.g. poll check) + if (this.onBetweenDownloads !== undefined) { + try { + await this.onBetweenDownloads(); + } catch { + // Hook failed — continue anyway + } + // Re-check state after the async hook + if (!this._running || this._userDownloading) return; + } const success = await this._downloadImage(image); if (success) { diff --git a/src/lib/components/ImageList.svelte b/src/lib/components/ImageList.svelte index 532cd3b..5048e1b 100644 --- a/src/lib/components/ImageList.svelte +++ b/src/lib/components/ImageList.svelte @@ -7,9 +7,15 @@ images: FlashAirFileEntry[]; selectedPath: string | undefined; onSelect: (file: FlashAirFileEntry) => void; + newImagePaths: Set<string>; + onAnimationDone: (path: string) => void; } - let { images, selectedPath, onSelect }: Props = $props(); + let { images, selectedPath, onSelect, newImagePaths, onAnimationDone }: Props = $props(); + + function handleRevealEnd(path: string) { + onAnimationDone(path); + } </script> <div class="h-full overflow-y-auto bg-base-100"> @@ -22,7 +28,11 @@ {#each images as file (file.path)} {@const thumbUrl = flashair.thumbnailUrl(file.path)} {@const isSelected = file.path === selectedPath} - <li> + {@const isNew = newImagePaths.has(file.path)} + <li + class="image-reveal overflow-hidden {isNew ? 'image-reveal-enter' : ''}" + onanimationend={() => handleRevealEnd(file.path)} + > <button class="flex flex-col w-full rounded-lg p-1.5 text-left transition-colors {isSelected ? 'bg-primary text-primary-content' diff --git a/src/lib/flashair/index.ts b/src/lib/flashair/index.ts index e7ff8af..b418e33 100644 --- a/src/lib/flashair/index.ts +++ b/src/lib/flashair/index.ts @@ -1,2 +1,3 @@ export { flashair } from './client'; +export { pollService } from './pollService'; export type { FlashAirFileEntry, ThumbnailMeta } from './types'; diff --git a/src/lib/flashair/pollService.ts b/src/lib/flashair/pollService.ts new file mode 100644 index 0000000..32010b3 --- /dev/null +++ b/src/lib/flashair/pollService.ts @@ -0,0 +1,259 @@ +import { flashair } from './client'; +import type { FlashAirFileEntry } from './types'; + +/** Regex matching common image file extensions. */ +const IMAGE_EXTENSIONS = /\.(jpe?g|png|bmp|gif)$/i; + +type PollListener = (newImages: FlashAirFileEntry[]) => void; + +/** + * Polls the FlashAir card every 1.5 seconds using op=102 (hasUpdated). + * + * Uses an optimistic fast-path strategy: + * - Tracks the "active" DCIM subdirectory (the newest/highest-numbered one). + * - When an update is detected, lists just the active subdirectory (one request) + * and diffs against the known set. + * - If the active subdir didn't change, lists /DCIM to check for new subdirs. + * + * The interval timer can be paused/resumed so other services (auto-cache) can + * call `checkOnce()` between their own requests instead. + */ +class PollService { + private _intervalId: ReturnType<typeof setInterval> | undefined; + private _knownPaths = new Set<string>(); + private _rootDir = '/DCIM'; + private _listeners = new Set<PollListener>(); + private _polling = false; + private _started = false; + + /** The active (newest) subdirectory path, e.g. "/DCIM/100__TSB". */ + private _activeSubdir: string | undefined; + + /** All known subdirectory paths under /DCIM. */ + private _knownSubdirs = new Set<string>(); + + /** Subscribe to new-image events. Returns an unsubscribe function. */ + onNewImages(fn: PollListener): () => void { + this._listeners.add(fn); + return () => { + this._listeners.delete(fn); + }; + } + + /** + * Start polling. Provide the initial set of known images so the first + * diff doesn't treat everything as "new". + */ + start(knownImages: readonly FlashAirFileEntry[], rootDir?: string): void { + this.stop(); + this._rootDir = rootDir ?? '/DCIM'; + this._knownPaths.clear(); + this._knownSubdirs.clear(); + for (const img of knownImages) { + this._knownPaths.add(img.path); + this._knownSubdirs.add(img.directory); + } + this._activeSubdir = this._findNewestSubdir(); + this._started = true; + this._startInterval(); + } + + /** Stop polling entirely. */ + stop(): void { + this._started = false; + this._stopInterval(); + this._polling = false; + } + + /** + * Pause the automatic interval timer. + * The service stays "started" — `checkOnce()` can still be called, + * and `resume()` will restart the timer. + */ + pause(): void { + this._stopInterval(); + } + + /** + * Resume the automatic interval timer after a `pause()`. + * No-op if the service hasn't been started. + */ + resume(): void { + if (!this._started) return; + this._startInterval(); + } + + /** + * Perform a single poll check (op=102 + fast detect) immediately. + * Can be called while the interval is paused. + * Returns the new images found (empty array if none). + */ + async checkOnce(): Promise<FlashAirFileEntry[]> { + if (this._polling || !this._started) return []; + this._polling = true; + + try { + const updated = await flashair.hasUpdated(); + if (!updated) return []; + + const detected = await this._fastDetect(); + if (detected.length > 0) { + this._emit(detected); + } + return detected; + } catch { + return []; + } finally { + this._polling = false; + } + } + + /** Add a path to the known set. */ + addKnownPath(path: string): void { + this._knownPaths.add(path); + } + + /** Remove a path from the known set. */ + removeKnownPath(path: string): void { + this._knownPaths.delete(path); + } + + // --- private --- + + private _startInterval(): void { + this._stopInterval(); + this._intervalId = setInterval(() => { + void this._tick(); + }, 1500); + } + + private _stopInterval(): void { + if (this._intervalId !== undefined) { + clearInterval(this._intervalId); + this._intervalId = undefined; + } + } + + private _findNewestSubdir(): string | undefined { + if (this._knownSubdirs.size === 0) return undefined; + const sorted = [...this._knownSubdirs].sort(); + return sorted[sorted.length - 1]; + } + + private async _tick(): Promise<void> { + if (this._polling) return; + this._polling = true; + + try { + const updated = await flashair.hasUpdated(); + if (!updated) return; + + const detected = await this._fastDetect(); + if (detected.length > 0) { + this._emit(detected); + } + } catch { + // Network error — silently retry next tick + } finally { + this._polling = false; + } + } + + /** + * Fast detection: + * 1. List the active subdirectory and diff. + * 2. If no new images, list /DCIM for new subdirectories. + * 3. If a new subdir exists, promote it and list it. + */ + private async _fastDetect(): Promise<FlashAirFileEntry[]> { + // Step 1: check active subdirectory + if (this._activeSubdir !== undefined) { + const newImages = await this._diffDirectory(this._activeSubdir); + if (newImages.length > 0) return newImages; + } + + // Step 2: list /DCIM for new subdirectories + const dcimEntries = await flashair.listFiles(this._rootDir); + const newSubdirs: string[] = []; + for (const entry of dcimEntries) { + if (entry.isDirectory && !this._knownSubdirs.has(entry.path)) { + newSubdirs.push(entry.path); + this._knownSubdirs.add(entry.path); + } + } + + // Check for images directly in /DCIM + const dcimImages: FlashAirFileEntry[] = []; + for (const entry of dcimEntries) { + if ( + !entry.isDirectory && + IMAGE_EXTENSIONS.test(entry.filename) && + !this._knownPaths.has(entry.path) + ) { + dcimImages.push(entry); + this._knownPaths.add(entry.path); + } + } + + // Step 3: promote newest new subdir and list it + if (newSubdirs.length > 0) { + newSubdirs.sort(); + const newest = newSubdirs[newSubdirs.length - 1]; + if (newest !== undefined) { + this._activeSubdir = newest; + const subImages = await this._diffDirectory(newest); + const all = [...subImages, ...dcimImages]; + all.sort((a, b) => { + const d = b.rawDate - a.rawDate; + if (d !== 0) return d; + return b.rawTime - a.rawTime; + }); + return all; + } + } + + if (dcimImages.length > 0) { + dcimImages.sort((a, b) => { + const d = b.rawDate - a.rawDate; + if (d !== 0) return d; + return b.rawTime - a.rawTime; + }); + return dcimImages; + } + + return []; + } + + private async _diffDirectory(dir: string): Promise<FlashAirFileEntry[]> { + const entries = await flashair.listFiles(dir); + const newImages: FlashAirFileEntry[] = []; + + for (const entry of entries) { + if ( + !entry.isDirectory && + IMAGE_EXTENSIONS.test(entry.filename) && + !this._knownPaths.has(entry.path) + ) { + newImages.push(entry); + this._knownPaths.add(entry.path); + } + } + + newImages.sort((a, b) => { + const dateCompare = b.rawDate - a.rawDate; + if (dateCompare !== 0) return dateCompare; + return b.rawTime - a.rawTime; + }); + + return newImages; + } + + private _emit(newImages: FlashAirFileEntry[]): void { + for (const fn of this._listeners) { + fn(newImages); + } + } +} + +/** Singleton instance. */ +export const pollService = new PollService(); |
