library: the other window on the same origin draws the scan instead of running a second one
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.
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let heart: ReturnType<typeof setInterval> | 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<string, unknown>): 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<boolean> {
|
||||
if (live) return Promise.resolve(true);
|
||||
if (!channel) return Promise.resolve(false);
|
||||
const answered = new Promise<boolean>((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<ScanProgress> {
|
||||
};
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user