library: the screen reads the catalogue back when a batch lands, not on a 700ms timer, and a tile keeps the object URL it was given

This commit is contained in:
2026-09-30 15:41:18 +07:00
parent c9e72595f7
commit 75ff444621
3 changed files with 62 additions and 12 deletions
+4
View File
@@ -13,6 +13,10 @@
without Safari's chrome, 'black-translucent' gives it the app's black bars,
and 'title' is the name under the icon. -->
<link rel="manifest" href="/manifest.json" />
<!-- The same thing under the name it now has: Chrome warns that the `apple-`
spelling is deprecated, and iOS Safari still reads only that one, so the
installed app carries both. -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
+47 -11
View File
@@ -153,10 +153,6 @@ export function Library() {
// holding has moved on.
const live = scanSession();
const [, bump] = useReducer((n: number) => n + 1, 0);
// When the catalogue was last read back while a scan runs: the refresh reads the
// whole catalogue, so it keeps to one every few hundred milliseconds rather than
// one per frame.
const readAt = useRef(0);
const [note, setNote] = useState<string | null>(null);
// The folder menu: what a right click opens, and where. No folder means the
// right click landed on the empty part of the column, which is the way in for
@@ -186,6 +182,13 @@ export function Library() {
// one that says the scan is through — is recognisable as an end rather than
// as a screen with nothing to do.
let was: string | null = null;
// How much of the roll the catalogue was last read back at, and the shape the
// column was last drawn at: reading the whole catalogue back costs every
// thumbnail in it, so it waits for a batch to have landed rather than for a
// frame to have been read, and the column is redrawn only when a pass has
// walked into something new.
let wrote = -1;
let drew = '';
const watching = () => {
bump();
const session = scanSession();
@@ -202,9 +205,17 @@ export function Library() {
was = folder;
if (!session) return;
const { progress } = session;
setDirs(progress.dirs);
if (progress.done === 0 || Date.now() - readAt.current > 700) {
readAt.current = Date.now();
const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : '';
if (shape !== drew) {
drew = shape;
setDirs(progress.dirs);
}
// A window beside the reading is handed the progress of another build, which
// may have no such count: the frames it has stored are then read back on a
// frame, as they were before there was one.
const written = progress.written ?? progress.done;
if (written !== wrote) {
wrote = written;
void reload();
}
};
@@ -1140,13 +1151,38 @@ export function Library() {
// the panels paint.
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
// 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<string, string>());
useEffect(() => {
const next: Record<string, string> = {};
for (const photo of photos) if (photo.thumb) next[photo.id] = URL.createObjectURL(photo.thumb);
for (const photo of photos) {
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;
}
}
for (const [id, url] of made.current) {
if (id in next) continue;
URL.revokeObjectURL(url);
made.current.delete(id);
}
setUrls(next);
return () => {
for (const url of Object.values(next)) URL.revokeObjectURL(url);
};
}, [photos]);
// The screen going away takes every URL with it.
useEffect(
() => () => {
for (const url of made.current.values()) URL.revokeObjectURL(url);
made.current.clear();
},
[]
);
return urls;
}
+11 -1
View File
@@ -320,6 +320,12 @@ export interface ScanProgress {
total: number;
done: number;
added: number;
// How many frames have reached the catalogue. `done` counts the frames the
// reading has got to, which is nearly all of them long before they are stored,
// so a screen that read the catalogue back on `done` would read it again every
// frame — and reading it back means every thumbnail in it again. This moves a
// batch at a time, which is exactly when there is something new to read.
written: number;
// How many frames the scan has read out of each row of the tree, keyed the way
// the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for
// the rest) and counted the way the rows count: a frame sits on its own row and
@@ -478,10 +484,13 @@ export async function scanFolder(
total: 0,
done: 0,
added: 0,
written: 0,
dirs: [],
counts: {},
};
progress.folder = folder.name;
// A position written down by an older visit has no count to carry on from.
progress.written ??= 0;
const found = new Set(progress.dirs.map((d) => d.id));
const counts: Record<string, number> = progress.counts;
// Count the frame the moment the scan gets to it, before it knows whether the
@@ -538,6 +547,7 @@ export async function scanFolder(
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
progress.written += rows.length;
write();
};
// One frame, end to end: its bytes, its tile, its shutter time, and the row the
@@ -848,7 +858,7 @@ export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
if (live) return Promise.reject(new Error('a scan is already running'));
const session: ScanSession = {
folder: folder.name,
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} },
progress: { folder: folder.name, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {} },
stop: false,
jump: null,
};