feat(library): a shelf that fills in as it reads, and one that carries the studio's look and frame back with it

- tiles land one at a time. The screenful a picked folder asks for was built all
  at once, and a HEIC or a RAW in it is a decode of its own, so the strip stood
  empty until every one of them was done: 2293ms on a roll of eighteen, with the
  batch arriving in one go (0/18 then 18/18 in a single step). Tiles are built in
  two lanes now and each one goes up the moment it is drawn — first picture at
  36ms, then 1, 2, 4, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18 of 18, done at 2496ms.
  The threading is handed back between tiles (`scheduler.yield`), so a click
  still answers while the strip fills: no long tasks either side, worst frame gap
  169ms -> 164ms, a view toggle 49ms -> 41ms. The tiles are the same tiles; the
  wall's is drawn from the same counter.
- the studio's look reaches the shelf. A frame graded in the studio writes the
  tile the shelf draws, at neutral geometry — no turn, crop, watermark or frame,
  since the shelf turns a tile itself — and every screen holding the tile is told
  the moment it is written, so the row repaints without a reload.
- the shelf comes back on the frame the studio was holding. The frame a visit
  opened is kept with the visit, and coming back from the studio raises it: the
  strip centres it and the wall brings its card to the middle, even when the
  frame sits in another folder of the same tree. A shelf that opened on whatever
  it was left standing on sent the reader looking for the frame they had just
  been working on.
- the frame's name stands under the picture, in the studio's own meta bar.
This commit is contained in:
2026-10-09 21:52:38 +07:00
parent 6a052cb9d9
commit 53e7606b37
4 changed files with 177 additions and 28 deletions
+25 -16
View File
@@ -1840,11 +1840,24 @@ export async function getPhotoThumb(id: string): Promise<Blob | null> {
return null;
}
// Whoever is drawing a tile out of what the store holds (`Library.tsx`) is told
// about a tile rewritten under it: the URL it has is a copy of the old blob, and
// the write hands over the new one, so the row is redrawn without a second read.
const thumbListeners = new Set<(id: string, blob: Blob) => void>();
export function onThumbWritten(listener: (id: string, blob: Blob) => void): () => void {
thumbListeners.add(listener);
return () => {
thumbListeners.delete(listener);
};
}
// A tile made because someone looked at the frame, kept where the next visit
// will find it: the decode is paid once per frame, on the visit that first drew
// it, and the reads after that are the same reads a scanned tile would have had.
export async function putPhotoThumb(id: string, blob: Blob): Promise<void> {
cacheThumb(id, blob);
for (const listener of thumbListeners) listener(id, blob);
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return;
@@ -1930,23 +1943,19 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
// stutter, and it bought nothing: the wall mints the frames in view itself.
const THUMB_LANES = 2;
// The breath between frames. The idle queue where the browser has one — the tile
// then waits for a gap rather than making one — and a plain macrotask otherwise;
// the timeout is there because an idle queue on a busy page can stay empty.
// The breath between frames: the thread handed back so the page repaints and a
// click still lands while the shelves fill in. This used to be the idle queue, and
// that is what broke it — on a page somebody is using, an idle callback is parked
// for its whole timeout, so every tile cost 120ms of nothing and the tiles looked
// like they never came. `scheduler.yield` hands the thread over and comes straight
// back; a macrotask is the same thing where there is no such call.
//
// ponytail: the walk's own scan is not paced from here and does not stop for a
// tile; the two share the thread when a folder is opened mid-read. One budget for
// both is the next step if a scan ever runs visibly slower beside a tile job.
function breathe(): Promise<void> {
return new Promise((resolve) => {
const idle = (
globalThis as {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
}
).requestIdleCallback;
if (idle) idle(() => resolve(), { timeout: 120 });
else setTimeout(resolve, 0);
});
// ponytail: `THUMB_LANES` jobs share one budget — the thread is handed over after
// a tile, not on a clock. Pace by elapsed time if a folder of RAW ever wants it.
export function breathe(): Promise<void> {
const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (s?.yield) return s.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
// One frame's tile, in flight. The wall scrolls to a frame and asks for its tile