diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index 2e32a29..2a402a6 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -936,6 +936,24 @@ export function Workspace() {
void forgetPhoto();
}, [clearPreview]);
+ // Clean up Object URLs when App unmounts (e.g. SPA navigation to Library)
+ useEffect(() => {
+ return () => {
+ setPreviewUrl((prev) => {
+ if (prev) URL.revokeObjectURL(prev);
+ return null;
+ });
+ setCompareUrl((prev) => {
+ if (prev) URL.revokeObjectURL(prev);
+ return null;
+ });
+ setSource((prev) => {
+ if (prev) URL.revokeObjectURL(prev.url);
+ return null;
+ });
+ };
+ }, []);
+
// What the phone app gets from the OS geocoder, the browser has no twin of,
// so the name behind a coordinate comes from the API. Fire and forget: the
// stamp is already readable without it, and a name that arrives late (or not
diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx
index de91473..266aa39 100644
--- a/docker/frontend/src/Landing.tsx
+++ b/docker/frontend/src/Landing.tsx
@@ -667,7 +667,16 @@ export function Landing() {
aria-label={t('nav.language')}
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
>
- {lang.toUpperCase()}
+ {/* On a phone the button wears a globe; on a wide screen it wears
+ the code, where there is room to say which language is up.
+ Both live in the DOM — neither is the button's name (`aria-label`
+ above is), so which one is drawn is a stylesheet's business. */}
+
+ {lang.toUpperCase()}
{/* Sign in is the default: the dialog opens here, on the page the
diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx
index 7f129b4..a2dcec7 100644
--- a/docker/frontend/src/Library.tsx
+++ b/docker/frontend/src/Library.tsx
@@ -4,11 +4,14 @@ import { go } from './nav';
import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
+import { isRawName, rawThumbnail } from './engine/rawDevelop';
import {
cachedPhotos,
canBrowseFolders,
+ clearLibraryCache,
ensurePermission,
getPhoto,
+ getPhotoThumbs,
HEAD_BYTES,
jumpScan,
listEditedIds,
@@ -71,6 +74,7 @@ const FRAME_KEY = 'recipescam.library.frame';
const COLUMN_KEY = 'recipescam.library.column';
const OPEN_KEY = 'recipescam.library.open';
const DEEP_KEY = 'recipescam.library.deep';
+const VIEW_KEY = 'recipescam.library.view';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
@@ -112,8 +116,17 @@ interface Shot {
// back on a timer while a scan runs, which hands this screen a new frame object
// every few hundred milliseconds, so what keeps the file from being read again is
// the read in flight rather than the object under it.
+const SHOTS_MAX = 50;
const shots = new Map>();
+function keepShot(id: string, promise: Promise): void {
+ if (shots.size >= SHOTS_MAX) {
+ const oldestKey = shots.keys().next().value;
+ if (oldestKey) shots.delete(oldestKey);
+ }
+ shots.set(id, promise);
+}
+
// How often a running scan is allowed to have its catalogue read back, and how
// often the tab may walk the folder again just for being raised. Both are the
// same trade — the reading is watched, not driven — and both are seconds for the
@@ -162,15 +175,15 @@ export function Library() {
// tree the way the last one was left.
const [expanded, setExpanded] = useState>(() => {
const saved = recall(OPEN_KEY);
- // Nothing remembered means this is the first visit under that rule, and the
- // node the last visit left would then open behind a shut row: its own rows
- // are opened for it. A visit that folded everything remembers the empty set,
- // which is not this, and is drawn as it was left.
+ const nodeParts = (recall(NODE_KEY) ?? '').split('/');
+ const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/'));
+
if (saved === null) {
- const parts = (recall(NODE_KEY) ?? '').split('/');
- return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
+ return new Set(nodeParents);
}
- return new Set(saved.split(',').filter(Boolean));
+ const set = new Set(saved.split(',').filter(Boolean));
+ for (const parent of nodeParents) set.add(parent);
+ return set;
});
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
@@ -183,7 +196,7 @@ export function Library() {
// first one the node happens to hold. A frame that is gone falls through to
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState(() => recall(FRAME_KEY));
- const [view, setView] = useState<'preview' | 'grid'>('preview');
+ const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
// these hours — and in the order the reader picked. A way of looking, not part
@@ -472,25 +485,37 @@ export function Library() {
};
}, [reload]);
+ // The catalogue outlives the tab; the permission to read those folders does
+ // not. Asking is a quiet prompt, and a refusal leaves the folder listed with
+ // a button that asks again on a click.
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
useEffect(() => {
let alive = true;
void (async () => {
+ // 1. Trigger catalog reload immediately in parallel
+ const reloadPromise = reload();
+
+ // 2. Fetch list of directory folders
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
- // The remembered folder only stands while the folder it belongs to does: a
- // folder that was removed is not a folder to open on.
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
- const ok = new Set();
- for (const folder of rows) {
- if (await ensurePermission(folder.handle)) ok.add(folder.name);
- }
+
+ // 3. Query directory permissions in parallel
+ const permResults = await Promise.all(
+ rows.map(async (folder) => ({
+ name: folder.name,
+ ok: await ensurePermission(folder.handle),
+ }))
+ );
if (!alive) return;
+ const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
setReady(ok);
- await reload();
+
+ // 4. Await photos reload if still running
+ await reloadPromise;
if (!alive) return;
setLoaded(true);
})();
@@ -517,6 +542,17 @@ export function Library() {
useEffect(() => {
keep(DEEP_KEY, String(deep));
}, [deep]);
+ useEffect(() => {
+ keep(VIEW_KEY, view);
+ }, [view]);
+
+ // Clear global memory caches when leaving Library screen
+ useEffect(() => {
+ return () => {
+ shots.clear();
+ clearLibraryCache();
+ };
+ }, []);
// The menu closes on the next click anywhere but its own items, and on Escape.
// Escape is the whole reason this listens on the document instead of sitting
@@ -710,17 +746,11 @@ export function Library() {
// numbers drawn over the column would count a roll down to a branch of itself.
// The column falls back on the catalogue until the reading is through.
const read = live && !live.progress.from ? live.progress.counts : null;
- const nodes = useMemo(() => {
+ // Memoize photo count map derived from photos and dirs array separately so that
+ // live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
+ const baseTreeInfo = useMemo(() => {
const hits = new Map();
- // The reading's own count is a floor, never a ceiling: a row says at least
- // what the catalogue already holds of it. A roll being read again says its
- // own size while the reading is still walking up to it — where the reading's
- // number alone would have the head of the tree count 29980 while the line
- // above it counts a hundred thousand, one question with two answers.
- const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const foldersUnder = new Map>();
- // One path per folder between the picked one and `rel`, so a row is drawn for
- // every folder on the way to a frame as well as for the frame's own.
const add = (folder: string, rel: string, counted: boolean) => {
const set = foldersUnder.get(folder) ?? new Set();
foldersUnder.set(folder, set);
@@ -735,9 +765,13 @@ export function Library() {
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
if (photo.dir) add(photo.folder, photo.dir, true);
}
- // A folder the walk found is a row before it holds a frame: the names arrive
- // a pass ahead of the thumbnails.
for (const dir of dirs) add(dir.folder, dir.rel, false);
+ return { hits, foldersUnder };
+ }, [photos, dirs]);
+
+ const nodes = useMemo(() => {
+ const { hits, foldersUnder } = baseTreeInfo;
+ const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
@@ -754,7 +788,7 @@ export function Library() {
}
}
return out;
- }, [dirs, folders, photos, read]);
+ }, [baseTreeInfo, folders, read]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
@@ -840,6 +874,53 @@ export function Library() {
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
+ // Auto-scroll & focus tree sidebar to the active node selected in last session
+ const treeRef = useRef(null);
+ const scrolledNodeRef = useRef(null);
+
+ useEffect(() => {
+ if (!node || !loaded) return;
+ if (scrolledNodeRef.current === node) return;
+
+ const scrollToNode = () => {
+ const treeEl = treeRef.current;
+ if (!treeEl) return;
+ const activeBtn = treeEl.querySelector('.adm-album.on');
+ if (activeBtn) {
+ activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ scrolledNodeRef.current = node;
+ }
+ };
+
+ scrollToNode();
+ const timer = setTimeout(scrollToNode, 120);
+ return () => clearTimeout(timer);
+ }, [node, loaded, rows]);
+
+ // Auto-scroll timeline filmstrip to center the active selected thumbnail
+ const scrolledFrameRef = useRef(null);
+
+ useEffect(() => {
+ if (view !== 'preview' || !frame || !shown.length) return;
+ const idx = shown.findIndex((p) => p.id === frame.id);
+ if (idx < 0) return;
+
+ const scrollStripToFrame = () => {
+ const el = strip.ref.current;
+ if (!el) return;
+ const width = el.clientWidth || 800;
+ const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2);
+ el.scrollLeft = targetLeft;
+ };
+
+ const key = `${node}:${frame.id}`;
+ if (scrolledFrameRef.current !== key) {
+ scrolledFrameRef.current = key;
+ scrollStripToFrame();
+ requestAnimationFrame(scrollStripToFrame);
+ }
+ }, [view, frame?.id, shown, node, strip.ref]);
+
// A frame's score: click a star to give it, click the star it already has to
// take the score back. The catalogue holds it, so it is the same score the next
// visit reads — and the list on screen is told, rather than read back whole for
@@ -886,7 +967,7 @@ export function Library() {
return null;
}
})();
- shots.set(id, read);
+ keepShot(id, read);
}
void read.then((shot) => {
if (live) setShot(shot);
@@ -902,6 +983,59 @@ export function Library() {
setZoom(1);
}, [frame?.id]);
+ const [stageUrl, setStageUrl] = useState(null);
+ const stageUrlRef = useRef(null);
+
+ const revokeStageUrl = () => {
+ if (stageUrlRef.current) {
+ URL.revokeObjectURL(stageUrlRef.current);
+ stageUrlRef.current = null;
+ }
+ };
+
+ // Load high-resolution photo/preview for the raised stage frame directly from local file handle
+ useEffect(() => {
+ revokeStageUrl();
+ setStageUrl(null);
+ if (!frame) return;
+
+ let live = true;
+ void (async () => {
+ try {
+ const file = await readPhotoFile(frame);
+ if (!live) return;
+
+ let url: string | null = null;
+ if (isRawName(file.name)) {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (!live) return;
+ const previewBytes = await rawThumbnail(bytes);
+ if (previewBytes && live) {
+ const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
+ url = URL.createObjectURL(blob);
+ }
+ } else {
+ url = URL.createObjectURL(file);
+ }
+
+ if (live && url) {
+ revokeStageUrl();
+ stageUrlRef.current = url;
+ setStageUrl(url);
+ } else if (url) {
+ URL.revokeObjectURL(url);
+ }
+ } catch {
+ // Fall back to thumb tile URL if file reading fails
+ }
+ })();
+
+ return () => {
+ live = false;
+ revokeStageUrl();
+ };
+ }, [frame?.id]);
+
// A wheel tick over the frame is the zoom — a negative is the one thing on this
// screen worth filling the window with — and the point under the pointer is the
// point that stays put, which is how a reader aims at what to magnify. The
@@ -1100,6 +1234,7 @@ export function Library() {
column is the folder control, so one more folder is added
where folders are. */}
) : frame ? (
<>
- {urls[frame.id] ? (
+ {stageUrl || urls[frame.id] ? (
void open(frame)}
title={t('lib.open')}
@@ -1619,40 +1754,54 @@ export function Library() {
// one on the stage, whose tile may be nowhere near the strip's window.
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record {
const [urls, setUrls] = useState>({});
- // The URL a row was given, kept for as long as the row is on screen. A scan lands
- // in batches, and each batch hands the screen the whole list again — so a URL
- // made afresh per list is a picture the browser decodes afresh per batch:
- // thousands of bitmaps over a roll, for tiles that have not moved. A row that
- // leaves the list takes its URL with it.
- const made = useRef(new Map());
+ const made = useRef(new Map());
+
useEffect(() => {
- const list = also && !photos.includes(also) ? [...photos, also] : photos;
- const next: Record = {};
- for (const photo of list) {
- if (!photo.thumb) continue;
- const had = made.current.get(photo.id);
- if (had) next[photo.id] = had;
- else {
- const url = URL.createObjectURL(photo.thumb);
- made.current.set(photo.id, url);
- next[photo.id] = url;
+ let alive = true;
+ const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
+ if (!list.length) {
+ setUrls({});
+ return;
+ }
+ const ids = list.map((p) => p.id);
+
+ void getPhotoThumbs(ids).then((thumbMap) => {
+ if (!alive) return;
+ const next: Record = {};
+ for (const photo of list) {
+ const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
+ if (!blob) continue;
+ const had = made.current.get(photo.id);
+ if (had && had.blob === blob) {
+ next[photo.id] = had.url;
+ } else {
+ if (had) URL.revokeObjectURL(had.url);
+ const url = URL.createObjectURL(blob);
+ made.current.set(photo.id, { url, blob });
+ next[photo.id] = url;
+ }
}
- }
- for (const [id, url] of made.current) {
- if (id in next) continue;
- URL.revokeObjectURL(url);
- made.current.delete(id);
- }
- setUrls(next);
+ for (const [id, item] of made.current) {
+ if (id in next) continue;
+ URL.revokeObjectURL(item.url);
+ made.current.delete(id);
+ }
+ setUrls(next);
+ });
+
+ return () => {
+ alive = false;
+ };
}, [photos, also]);
- // The screen going away takes every URL with it.
+
useEffect(
() => () => {
- for (const url of made.current.values()) URL.revokeObjectURL(url);
+ for (const item of made.current.values()) URL.revokeObjectURL(item.url);
made.current.clear();
},
[]
);
+
return urls;
}
diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts
index a7f6a1f..c14f95c 100644
--- a/docker/frontend/src/engine/library.ts
+++ b/docker/frontend/src/engine/library.ts
@@ -20,7 +20,9 @@ import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library';
const FOLDERS = 'folders';
const PHOTOS = 'photos';
+const THUMBS = 'thumbs';
const EDITS = 'edits';
+const DB_VERSION = 2;
// The tile the catalogue keeps is the same tile the grid cell and the strip both
// paint, and there is one of them per frame of a roll that reaches six figures.
@@ -30,8 +32,8 @@ const EDITS = 'edits';
// lower quality put the same picture in the same cell: the tile is now ~12KB, and
// the roll a quarter of the disk it was. The quality is the one Lightroom keeps
// its own grid previews at, and at this size the eye cannot tell it from 0.75.
-const THUMB_MAX = 256;
-const THUMB_QUALITY = 0.6;
+const THUMB_MAX = 640;
+const THUMB_QUALITY = 0.8;
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
// than 3000 of them.
const BATCH = 50;
@@ -138,16 +140,33 @@ export function canBrowseFolders(): boolean {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
}
+const THUMB_CACHE_MAX = 500;
+const thumbCache = new Map();
+
+export function cacheThumb(id: string, blob: Blob): void {
+ if (thumbCache.has(id)) thumbCache.delete(id);
+ else if (thumbCache.size >= THUMB_CACHE_MAX) {
+ const first = thumbCache.keys().next().value;
+ if (first) thumbCache.delete(first);
+ }
+ thumbCache.set(id, blob);
+}
+
+export function getCachedThumb(id: string): Blob | null {
+ const blob = thumbCache.get(id);
+ if (blob) {
+ thumbCache.delete(id);
+ thumbCache.set(id, blob);
+ return blob;
+ }
+ return null;
+}
+
let dbPromise: Promise | null = null;
function openDb(): Promise {
dbPromise ??= new Promise((resolve, reject) => {
- // No version on purpose. A version the browser has to reach for is a version
- // another tab can block: a tab still running the previous build holds the
- // catalogue open, the upgrade waits on it, and this one never gets an answer
- // — the screen comes up empty on a catalogue that is all there. Opening
- // without one takes the catalogue as it is and creates it when it is missing.
- const req = indexedDB.open(DB_NAME);
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
@@ -156,6 +175,9 @@ function openDb(): Promise {
photos.createIndex('folder', 'folder');
photos.createIndex('taken', 'taken');
}
+ if (!db.objectStoreNames.contains(THUMBS)) {
+ db.createObjectStore(THUMBS, { keyPath: 'id' });
+ }
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
};
req.onsuccess = () => resolve(req.result);
@@ -213,12 +235,18 @@ export async function removeFolder(name: string): Promise {
const ids = (await listPhotos(name)).map((p) => p.id);
const db = await openDb();
await new Promise((resolve, reject) => {
- const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
+ const hasThumbs = db.objectStoreNames.contains(THUMBS);
+ const stores = hasThumbs ? [FOLDERS, PHOTOS, THUMBS, EDITS] : [FOLDERS, PHOTOS, EDITS];
+ const tx = db.transaction(stores, 'readwrite');
tx.objectStore(FOLDERS).delete(name);
const photos = tx.objectStore(PHOTOS);
+ const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
+ const edits = tx.objectStore(EDITS);
for (const id of ids) {
photos.delete(id);
- tx.objectStore(EDITS).delete(id);
+ thumbs?.delete(id);
+ edits.delete(id);
+ thumbCache.delete(id);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
@@ -239,12 +267,13 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
// against, and the two values are the whole type.
type PermissionMode = 'read' | 'readwrite';
const mode: PermissionMode = write ? 'readwrite' : 'read';
+ if (!handle) return false;
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: PermissionMode }) => Promise;
requestPermission?: (o: { mode: PermissionMode }) => Promise;
};
try {
- if (!askable.queryPermission) return false;
+ if (!askable || !askable.queryPermission) return false;
if ((await askable.queryPermission({ mode })) === 'granted') return true;
return (await askable.requestPermission?.({ mode })) === 'granted';
} catch {
@@ -322,7 +351,10 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null)
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return null;
ctx.drawImage(bitmap, 0, 0);
- return await new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
+ const res = await new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
+ canvas.width = 0;
+ canvas.height = 0;
+ return res;
} finally {
bitmap.close();
}
@@ -670,18 +702,23 @@ export async function scanFolder(
batch = [];
const db = await openDb();
await new Promise((resolve, reject) => {
- const tx = db.transaction(PHOTOS, 'readwrite');
- const photos = tx.objectStore(PHOTOS);
- for (const row of rows) photos.put(row);
+ const hasThumbs = db.objectStoreNames.contains(THUMBS);
+ const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
+ const tx = db.transaction(stores, 'readwrite');
+ const photoStore = tx.objectStore(PHOTOS);
+ const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
+ for (const row of rows) {
+ const { thumb, ...meta } = row;
+ photoStore.put(meta);
+ if (thumb) {
+ cacheThumb(row.id, thumb);
+ thumbStore?.put({ id: row.id, thumb });
+ }
+ }
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
progress.written += rows.length;
- // Out of the batch and onto the strip in one move, before the position is
- // written: the frames are in the catalogue now, and the screen that draws
- // them needs no read to find them. Ordered by nothing — the order they were
- // read in is the order the roll was shot in, near enough, and the sort that
- // settles it comes when the reading is through.
if (added.length) {
const fresh = added;
added = [];
@@ -689,41 +726,23 @@ export async function scanFolder(
}
await write();
};
- // One frame, end to end: its bytes, its tile, its shutter time, and the row the
- // catalogue keeps. A frame that has not moved is dropped on its size and its
- // time before a byte of it is read, which is what makes a second scan of the
- // same folder cheap; a frame that will not read at all is skipped by the lane
- // that ran it — one bad file in a folder is not a failed folder.
+
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise => {
const file = await handle.getFile();
const id = photoId(folder.name, rel);
const seen = known.get(id);
- // Unchanged means the same size and the same write time: the frame is left
- // exactly as the catalogue already holds it. This is what makes a second scan
- // of the same folder cheap, and what finishes a roll whose first scan was cut
- // short — the frames it got through are skipped, the rest are read.
- if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
- // A row restored from a backup has no handle: a handle is not a thing that
- // can be written to a file, so the row comes back with everything but the
- // one thing that opens the frame, and the walk is where it gets it back.
- // The row is put again — the bytes of the frame are still never read.
+ if (seen && seen.size === file.size && seen.mtime === file.lastModified) {
if (!seen.handle) {
batch.push({ ...seen, handle });
if (batch.length >= BATCH) await flush();
}
return;
}
- // A RAW is read whole because that is the only way LibRaw can seek to the
- // preview the camera wrote inside it; a JPEG is handed to the decoder as it
- // is, and only its header is read for the date. Both tiles come off what the
- // camera wrote — a RAW's preview is a copy, where a develop is every pixel.
const raw = isRawName(file.name);
- const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer()));
+ const RAW_HEADER_BYTES = 4 * 1024 * 1024;
+ const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer();
+ const bytes = new Uint8Array(sliceBuffer);
const preview = raw ? await rawThumbnail(bytes) : null;
- // The tile is the preview the camera wrote inside a RAW, or the frame itself.
- // Both carry their own size in their first few hundred bytes — the same bytes
- // the date comes out of — so the decoder is handed the size instead of being
- // asked with a second decode of the whole frame to learn which edge is long.
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
const thumb = src ? await tile(src, size) : null;
@@ -739,16 +758,10 @@ export async function scanFolder(
taken,
size: file.size,
mtime: file.lastModified,
- // The reader's own score is not the file's: a frame that is read again
- // keeps the stars it was given.
star: seen?.star,
addedAt: seen?.addedAt ?? Date.now(),
};
batch.push(row);
- // A frame the catalogue already held is on the strip already: its row takes
- // the place of the one there, at the end of the reading. Only the frame the
- // reading met for the first time is handed over as it lands — that is the
- // one the reader is waiting to see.
if (!seen) added.push(row);
progress.added++;
inHand = inHand.filter((r) => r !== rel);
@@ -764,6 +777,7 @@ export async function scanFolder(
// no more than one batch.
const drain = async () => {
const lanes = new Set>();
+ let processed = 0;
while (entries.length) {
if (shouldStop?.()) {
stop = true;
@@ -793,6 +807,12 @@ export async function scanFolder(
lanes.add(lane);
if (lanes.size >= LANES) await Promise.race(lanes);
publish();
+
+ // Micro-yield to the browser main thread event loop so scanning never blocks UI rendering or user interactions
+ processed++;
+ if (processed % 12 === 0) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
}
await Promise.all(lanes);
};
@@ -846,6 +866,8 @@ export async function scanFolder(
publish(true);
}
await drain();
+ // Yield between pass iterations to maintain UI fluidness
+ await new Promise((resolve) => setTimeout(resolve, 0));
} while (!walked && !stop);
await flush();
// A reading that came to its end has no position worth keeping: the next one
@@ -1223,16 +1245,99 @@ export function cachedPhotos(): LibraryPhoto[] {
return cached ?? [];
}
+export function clearLibraryCache(): void {
+ cached = null;
+}
+
// Null, and not an empty list: a read that failed is not a catalogue with
// nothing in it, and the screen that would draw an empty strip on one is the
// screen that loses the frames it was showing — under a scan of its own, where
// the read is the thing that gives way first, that is every time.
+export async function getPhotoThumb(id: string): Promise {
+ const cached = getCachedThumb(id);
+ if (cached) return cached;
+ try {
+ const db = await openDb();
+ if (!db.objectStoreNames.contains(THUMBS)) return null;
+ const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
+ if (row?.thumb) {
+ cacheThumb(id, row.thumb);
+ return row.thumb;
+ }
+ } catch {}
+ return null;
+}
+
+export async function getPhotoThumbs(ids: string[]): Promise