summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/App.svelte57
-rw-r--r--src/app.css23
-rw-r--r--src/lib/cache/autoCacheService.ts41
-rw-r--r--src/lib/components/ImageList.svelte14
-rw-r--r--src/lib/flashair/index.ts1
-rw-r--r--src/lib/flashair/pollService.ts259
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();