From c9e72595f7aa6bb07059ab84502b3c71f16a9b5b Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 12:58:08 +0700 Subject: [PATCH] library: the other window on the same origin draws the scan instead of running a second one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An installed app and a browser tab are one origin, one catalogue and one roll. Without a word between them the second window opens, finds nothing in flight, and reads the same frames again — two readers of one folder, two RAW decoders at 256MB apiece, for a progress the first window already has. The window holding the reading now announces it on every frame and on a heartbeat, and answers a window that has just opened and asks. A window that does not hold the reading takes it as its own: the same progress line, the folder marked as being read, the same STOP — which it relays rather than redirects — and the same jump. What it does not do is read the disk. A reading that is through leaves the catalogue behind, and a window watching it has nothing left to be told, so the screen reads the catalogue back once more when the session goes away: the frames that arrived with the last flush would otherwise never be drawn in the window that was only watching. Measured over one origin (two pages, 24 RAW frames): the second window read 25 frames before and 1 after (the open frame), the peak over an idle browser fell from 1412MB to 1155MB against 944MB for a single reader. --- docker/frontend/src/Library.tsx | 29 +++++- docker/frontend/src/engine/library.ts | 131 +++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 8aff98c..fe8cca8 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -17,6 +17,7 @@ import { readPhotoFile, removeFolder, renameFolder, + scanBusy, scanSession, setStar, startScan, @@ -181,10 +182,26 @@ export function Library() { // names a pass has walked into are drawn as they arrive, a pass ahead of the // frames under them; the catalogue behind the strip is read back on a timer. useEffect(() => { + // The folder the last pass was on, so the pass that comes back empty — the + // 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; const watching = () => { bump(); - const progress = scanSession()?.progress; - if (!progress) return; + const session = scanSession(); + const folder = session?.folder ?? null; + // A reading that is over leaves the catalogue it filled behind: this screen + // reads back the last of it, whether the reading was this window's or the + // one beside it — and the window watching never saw it otherwise, because a + // reading that is through has nothing left to hand over. + if (was && !folder) { + was = null; + void reload(); + return; + } + was = folder; + if (!session) return; + const { progress } = session; setDirs(progress.dirs); if (progress.done === 0 || Date.now() - readAt.current > 700) { readAt.current = Date.now(); @@ -287,8 +304,14 @@ export function Library() { useEffect(() => { const root = folders.find((f) => f.name === rootKey); if (!loaded || !root || !ready.has(root.name)) return; + // Another window may hold the reading — the app beside the browser, which + // this window cannot see. Asking is a round trip, and the answer is what + // keeps this one from reading the same roll a second time. const catchUp = () => { - if (!scanSession()) void scan(root, true); + if (scanSession()) return; + void scanBusy().then((busy) => { + if (!busy && !scanSession()) void scan(root, true); + }); }; catchUp(); const raised = () => { diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 6420b4f..bc12409 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -703,10 +703,115 @@ const tell = () => { for (const fn of watchers) fn(); }; +// --- the other windows on this origin -------------------------------------- + +// An installed app and a browser tab share one origin, one catalogue and one +// roll: without a word between them the second window opens, finds nothing in +// flight here, and reads the same frames again — two readers of one folder, two +// RAW decoders at 256MB apiece, for a progress the first window already has. +// So the window holding the reading says so, on every frame and on a heartbeat, +// and the windows that do not hold it take the reading as their own: they draw +// the same progress, offer the same stop, and above all start nothing. +const CHANNEL = 'recipescam-library'; +const SELF = Math.random().toString(36).slice(2); +// A window that has heard nothing for this long has a peer that was closed, or +// one that is wedged — either way the reading is nobody's again, and this window +// may pick it up as it always could. +const PEER_MS = 6000; +const HEART_MS = 2000; +// One round trip, for the window that has just opened: whether another window +// holds the reading is what decides whether this one starts its own. +const ASK_MS = 250; + +interface Peer { + folder: string; + progress: ScanProgress; + at: number; +} + +let peer: Peer | null = null; +let peerTimer: ReturnType | null = null; +let heart: ReturnType | null = null; +let askers: ((busy: boolean) => void)[] = []; +// A browser without the channel (an old one, a worker) simply has no peers, and +// everything below is a no-op there. +const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(CHANNEL) : null; + +const say = (m: Record): void => { + channel?.postMessage({ ...m, from: SELF }); +}; + +const dropPeer = (): void => { + if (peerTimer) { + clearTimeout(peerTimer); + peerTimer = null; + } + if (!peer) return; + peer = null; + tell(); +}; + +const holdPeer = (folder: string, progress: ScanProgress): void => { + peer = { folder, progress, at: Date.now() }; + for (const fn of askers) fn(true); + askers = []; + if (peerTimer) clearTimeout(peerTimer); + peerTimer = setTimeout(dropPeer, PEER_MS); + tell(); +}; + +// A peer holds the reading the way this window holds one, as far as a screen is +// concerned: the same shape, read the same way. Whether the reading is stopped +// or jumped from here is answered by relaying it, which is why the window +// watching can offer the buttons the window reading has. +const peerSession = (): ScanSession | null => + peer ? { folder: peer.folder, progress: peer.progress, stop: false, jump: null } : null; + +const announce = (): void => { + if (live) say({ k: 'scan', folder: live.folder, progress: live.progress }); +}; + +if (channel) { + channel.onmessage = (e: MessageEvent) => { + const m = e.data as { k?: string; from?: string; folder?: string; progress?: ScanProgress; rel?: string | null } | null; + if (!m || m.from === SELF) return; + if (m.k === 'scan' && m.folder && m.progress) holdPeer(m.folder, m.progress); + else if (m.k === 'end') { + if (peer?.folder === m.folder) dropPeer(); + } else if (m.k === 'who') announce(); // a window just opened and asks who reads + else if (m.k === 'stop' && live && live.folder === m.folder) { + live.stop = true; + tell(); + } else if (m.k === 'jump' && live && live.folder === m.folder) live.jump = m.rel ?? null; + }; +} + +// Whether another window is reading right now, asked rather than assumed: the +// window that opens beside a running scan knows nothing until the reading +// answers, and reading the disk twice over is exactly what the answer prevents. +export function scanBusy(): Promise { + if (live) return Promise.resolve(true); + if (!channel) return Promise.resolve(false); + const answered = new Promise((resolve) => { + const settle = (busy: boolean): void => { + askers = askers.filter((fn) => fn !== settle); + resolve(busy); + }; + askers.push(settle); + // Nobody answered: no reading is running, and waiting longer only delays + // this window's own. + setTimeout(() => settle(false), ASK_MS); + }); + say({ k: 'who' }); + return answered; +} + // The scan in flight, or null. The same object for the whole of the scan, with // `progress` replaced on every pass — watchers are told, so they re-read it. +// A reading another window holds answers the same way: the screens no longer +// know or care which window the reading is in. export function scanSession(): ScanSession | null { - return live; + return live ?? peerSession(); } // Watch, and get the unsubscribe back: the shape an effect already has. @@ -718,13 +823,22 @@ export function watchScan(fn: () => void): () => void { } export function stopScan(): void { - if (!live) return; + // A reading another window holds is stopped by telling it so: the button this + // window drew is the same button, on the window that can act on it. + if (!live) { + if (peer) say({ k: 'stop', folder: peer.folder }); + return; + } live.stop = true; tell(); } export function jumpScan(rel: string | null): void { - if (live) live.jump = rel; + if (live) { + live.jump = rel; + return; + } + if (peer) say({ k: 'jump', folder: peer.folder, rel }); } // Start reading a roll. Only one at a time: the walk reads one frame at a time on @@ -740,16 +854,27 @@ export function startScan(folder: LibraryFolder): Promise { }; live = session; tell(); + announce(); + // Between two frames there is nothing to announce, and a window that hears + // silence long enough would take the reading for abandoned and start its own. + if (heart) clearInterval(heart); + heart = setInterval(announce, HEART_MS); return scanFolder( folder, (p) => { session.progress = p; tell(); + announce(); }, () => session.stop, () => session.jump ).finally(() => { + if (heart) { + clearInterval(heart); + heart = null; + } live = null; + say({ k: 'end', folder: session.folder }); tell(); }); }