gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz bd27a7ccc1 gtauri: fix live watching (incremental subtree rescans)
Root cause of 'adds/removes not showing up': the watcher's file-COUNT
tree signature took 8-10 s per tick on the phone (FUSE readdir of the
8400-file Camera dir is ~1 ms/entry), so the 1 s poll was effectively
a 10 s walk, and the old 30 s forced FULL rescan (walk+stat of every
file, ~30 s) kept the watcher thread perpetually busy.

New design:
- 1 s tick stats DIRECTORY mtimes only (~1 ms). Verified on the phone:
  Android's FUSE DOES update dir mtime on file add/remove (stat
  before/after a copy showed it) — the earlier 'never fires' reading
  was wrong; no files had actually been added in that trace window.
- On a changed dir: incremental rescan of ONLY that subtree. Small
  dirs (<=2000 files) get a full walk with stat (adds, deletes AND
  in-place edits). The huge Camera dir gets a readdir-only walk: new
  files are stat'd, known files keep their DB mtime/size, deletions
  are detected by set difference. Everything outside the changed
  subtree keeps its DB metadata — cost is proportional to the change.
- Full rescan every 5 min as the safety net (in-place edits in the
  huge dir, anything missed).
- DB: busy_timeout(10s) so the startup scan and watcher can transact
  concurrently (WAL).

Measured on the phone: add to Pictures -> grid +1 in ~5 s; add to
Camera -> +1 in ~12 s; removals picked up on the next tick. Also
observed tracking a live 1700-photo user import (grid 8440 -> 10166
as the import progressed).

JS: event payload normalizer for thumb-ready/full-ready (object vs
JSON string across WebView builds), and js files/emit instrumentation
to compare send vs receive counts.
2026-11-01 18:55:31 -05:00

442 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Gallery</title>
<style>
/* Chromium owns: layout, compositor-driven scroll, lazy fetch+decode,
image cache/eviction, viewer compositing. Rust owns: scan, SQLite,
on-disk thumbnail cache behind gallery://. */
html, body {
margin: 0; padding: 0;
background: #0a0a0c;
overscroll-behavior: none;
-webkit-user-select: none; user-select: none;
}
body { overflow-y: scroll; padding-top: 26px; }
#status {
position: fixed; top: 0; left: 0; right: 0; height: 26px;
display: flex; align-items: center; padding: 0 12px;
background: rgba(10,10,12,0.87);
color: #cfd2da; font: 14px/26px system-ui, sans-serif;
z-index: 10;
}
#grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
}
.cell {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #1c1c22;
border-radius: 2px;
}
/* explicit 1:1 box via aspect-ratio → zero layout shift on arrival */
.cell img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
/* ---- viewer ---- */
#viewer {
position: fixed; inset: 0;
background: #000;
z-index: 100;
display: none;
touch-action: none;
}
#viewer.open { display: block; }
#vimg {
position: absolute; left: 50%; top: 50%;
max-width: none;
transform-origin: 0 0;
will-change: transform;
/* compositor-only animation: pan/zoom never re-rasterizes */
}
#vclose {
position: fixed; top: 8px; right: 8px;
width: 44px; height: 44px;
border-radius: 22px;
background: rgba(20,20,24,0.7);
color: #fff; font: 26px/40px system-ui;
text-align: center;
z-index: 101;
touch-action: none;
}
#vload {
position: fixed; left: 0; right: 0; top: 50%;
color: #888; text-align: center;
font: 14px system-ui;
z-index: 99;
}
</style>
</head>
<body>
<div id="status"><span id="stext">starting…</span></div>
<div id="grid"></div>
<div id="viewer">
<div id="vload"></div>
<img id="vimg" alt="">
<div id="vclose">×</div>
</div>
<script>
"use strict";
const grid = document.getElementById("grid");
const stext = document.getElementById("stext");
const viewer = document.getElementById("viewer");
const vimg = document.getElementById("vimg");
const vload = document.getElementById("vload");
let total = 0, loaded = 0, lastSig = "", scanning = false;
const imgById = new Map(); // id -> <img> (for thumb-ready re-requests)
function statusLine() {
stext.textContent = total + " photos" +
(scanning ? " • scanning…" : "") +
(loaded < total ? " (" + loaded + ")" : "");
}
let lastIds = [];
function rebuild(ids, isScanning, found) {
const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
lastSig = sig;
scanning = isScanning;
total = ids.length; loaded = 0;
// Live rescan (a photo just landed) inserts at the top and would
// shift everything — pin the viewport to the cell that was on top.
let keepId = null;
if (lastIds.length > 0 && rowH > 0) {
const topIdx = Math.min(lastIds.length - 1, Math.floor(window.scrollY / rowH) * 3);
keepId = lastIds[topIdx];
}
grid.replaceChildren();
imgById.clear();
const frag = document.createDocumentFragment();
for (const id of ids) {
const c = document.createElement("div");
c.className = "cell";
const im = new Image();
im.loading = "lazy";
im.decoding = "async";
im.dataset.url = "http://gallery.localhost/thumb/" + id;
im.__ph = false, im.__retry = 0, im.__lazy = true;
im.src = im.dataset.url;
imgById.set(id, im);
im.addEventListener("load", () => {
// naturalWidth 1 => the Rust placeholder, not a real thumb
if (im.naturalWidth > 2) { loaded++; statusLine(); }
else if (im.__retry < 4) {
im.__retry++;
jlog("js ph-load id=" + id + " retry=" + im.__retry);
// safety net in case thumb-ready was lost (page rebuild race)
setTimeout(() => {
if (!im.complete || im.naturalWidth <= 2) {
// Skip cells far outside the viewport — a fast scroll pass
// leaves hundreds of cold cells behind; re-requesting them
// all would flood the queue with junk the user cannot see.
const r = im.getBoundingClientRect();
if (r.bottom < -innerHeight || r.top > 2 * innerHeight) return;
jlog("js retry id=" + id + " v=" + im.__retry);
im.src = im.dataset.url + "?v=" + im.__retry;
}
}, 1500);
}
});
im.addEventListener("click", () => openViewer(id));
c.appendChild(im);
frag.appendChild(c);
}
grid.appendChild(frag);
lastIds = ids.slice();
statusLine();
eagerBand();
if (keepId !== null) {
const i = ids.indexOf(keepId);
if (i >= 0) {
requestAnimationFrame(() => scrollTo(0, Math.floor(i / 3) * rowH));
}
}
}
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
Tell Rust where the viewport is (throttled to ~7 Hz); the Rust worker
pre-decodes the rows ahead of us so fast scrolls hit a warm cache. */
let rowH = 0, lastAnchor = 0, anchorSent = false, anchorTrail = null;
function sendAnchor(force) {
const now = performance.now();
if (!force && now - lastAnchor < 150) {
// Throttled tick: schedule a TRAILING send so the final scroll
// position is never left unreported (a stale anchor makes Rust
// drop fresh urgent work as "off-screen").
if (!anchorTrail) {
anchorTrail = setTimeout(() => {
anchorTrail = null;
sendAnchor(true);
}, 160);
}
return;
}
lastAnchor = now;
if (rowH === 0) {
const c = grid.querySelector(".cell");
if (c) rowH = c.getBoundingClientRect().height + 4; // + grid gap
}
if (rowH > 0) {
const a = Math.floor(window.scrollY / rowH) * 3;
anchorSent = true;
try {
window.__TAURI__.event.emit("gtauri-scroll", { a: a });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + a + " y=" + Math.round(window.scrollY));
} catch (e) {}
eagerBand();
}
}
/* Force the visible band + next screen to load NOW (bypass Chromium's
lazy-loader intersection delay after a fast jump). Far cells stay lazy. */
function eagerBand() {
if (rowH === 0) return;
const i0 = Math.max(0, Math.floor((window.scrollY - innerHeight) / rowH) * 3 - 3);
const i1 = Math.min(total, Math.ceil((window.scrollY + 2.2 * innerHeight) / rowH) * 3 + 3);
const kids = grid.children;
let forced = 0;
for (let i = i0; i < i1; i++) {
const im = kids[i] && kids[i].firstElementChild;
if (im && !im.complete) {
im.loading = "eager";
if (im.__lazy) { im.__lazy = false; forced++; }
}
}
if (forced > 0) jlog("js eager forced=" + forced + " range=[" + i0 + "," + i1 + "]");
}
function jlog(s) {
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
}
addEventListener("scroll", () => sendAnchor(false), { passive: true });
try {
window.__TAURI__.event.listen("gallery-files", (e) => {
let p = e.payload || {};
// On Android the payload can arrive as a JSON *string* — decode it.
if (typeof p === "string") {
try { p = JSON.parse(p); } catch (err) { jlog("files parse err: " + err); }
}
jlog("js files ids=" + ((p && p.ids) || []).length +
" scanning=" + !!p.scanning + " type=" + typeof e.payload);
rebuild((p && p.ids) || [], !!(p && p.scanning), (p && p.found) || 0);
});
// first anchor (top of grid) — Rust also warms 0..36 on its side.
setTimeout(() => sendAnchor(true), 300);
// Cold-miss round-trip: Rust served a 1x1 placeholder and queued the real
// decode off the main thread; when it lands, re-request (now a mem hit).
// Event payloads arrive as JSON strings on Android (object on some
// WebView builds) — normalize both.
const pid = (e) => {
const p = e.payload;
return (typeof p === "string" ? JSON.parse(p) : p) || {};
};
window.__TAURI__.event.listen("thumb-ready", (e) => {
const id = pid(e).id;
const im = imgById.get(id);
if (!im) return;
if (im.naturalWidth <= 2) {
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
im.src = im.dataset.url + "?v=" + (++im.__retry);
}
});
// Full-res read finished (worker thread) — re-request; now a mem hit.
window.__TAURI__.event.listen("full-ready", (e) => {
const id = pid(e).id;
if (viewerId === id && !vFullDone) {
jlog("js full-ready id=" + id);
tryShow(id, "http://gallery.localhost/full/" + id + "?v=f" + (++vFullSeq), true);
}
});
// Android back: registering ANY "back-button" listener makes Tauri route
// the press here instead of finishing the activity (see AppPlugin.kt).
window.__TAURI__.app.onBackButtonPress(() => {
if (viewer.classList.contains("open")) {
closeViewer();
} else {
window.__TAURI__.app.exit(0);
}
});
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
/* ---------------- frame-rate logger (FR lines for tools/fr_capture.py) ---- */
let frLast = performance.now(), frLines = 0, frBatch = [];
function frFlush() {
if (frBatch.length === 0) return;
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loaded + "/" + total +
" scrollY=" + Math.round(window.scrollY));
const payload = frBatch.join("\n") + "\n";
frBatch = [];
try { window.__TAURI__.event.emit("gtauri-fr", payload); } catch (e) {}
}
function frTick(now) {
const work = performance.now() - now;
frBatch.push("FR " + now.toFixed(2).padStart(11) +
" gap=" + (now - frLast).toFixed(2).padStart(7) +
" cost=" + work.toFixed(2).padStart(6) + " drew=1");
frLast = now;
if (++frLines % 120 === 0) frFlush();
requestAnimationFrame(frTick);
}
requestAnimationFrame(frTick);
/* ---------------- viewer: compositor-only pan/zoom ---------------- */
let vScale = 1, vTx = 0, vTy = 0;
const base = { w: 0, h: 0 };
function fitScale() {
return Math.min(innerWidth / base.w, innerHeight / base.h);
}
function vApply() {
// Clamp: never zoom out past fit, and pan is limited to the image
// edges — the image can never be dragged off-screen leaving blank
// space around it. On a letterboxed axis (image smaller than the
// viewport in that dimension) panning is locked to centered.
const s = fitScale();
if (vScale < s) vScale = s;
const mx = Math.max(0, (base.w * vScale - innerWidth) / 2);
const my = Math.max(0, (base.h * vScale - innerHeight) / 2);
if (vTx > mx) vTx = mx; else if (vTx < -mx) vTx = -mx;
if (vTy > my) vTy = my; else if (vTy < -my) vTy = -my;
vimg.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
}
let viewerId = null, vFullTimer = null, vFullDone = false, vFullSeq = 0;
function fitViewer(w, h) {
base.w = w; base.h = h;
vScale = Math.min(innerWidth / w, innerHeight / h);
vimg.style.width = w + "px";
vimg.style.height = h + "px";
vimg.style.visibility = "visible";
vload.textContent = "";
vApply();
}
/// Fetch url and, if it is REAL image bytes (not the 1x1 placeholder),
/// promote it into the viewer.
function tryShow(id, url, isFull) {
const im = new Image();
im.addEventListener("load", () => {
if (viewerId !== id) return;
if (im.naturalWidth <= 2) return; // placeholder — wait for the ready event
vimg.src = im.src;
fitViewer(im.naturalWidth, im.naturalHeight);
if (isFull) {
vFullDone = true;
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
}
});
im.src = url;
}
function openViewer(id) {
viewerId = id;
vFullDone = false;
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (served from the Rust mem cache, ~1 ms).
// Same aspect ratio as the full image — the fit stays valid.
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
// 2) Full-res: Rust reads the file OFF the main thread; the first
// request gets the placeholder, `full-ready` triggers the swap.
tryShow(id, "http://gallery.localhost/full/" + id, true);
// 3) Safety net in case the ready event is ever lost.
if (vFullTimer) clearInterval(vFullTimer);
vFullTimer = setInterval(() => {
if (viewerId !== id || vFullDone) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
return;
}
if (++vFullSeq > 10) {
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
// Corrupt/unreadable file: stop saying "loading…" forever.
if (viewerId === id && vimg.naturalWidth <= 2) {
vload.textContent = "image unavailable";
}
return;
}
tryShow(id, "http://gallery.localhost/full/" + id + "?v=" + vFullSeq, true);
}, 400);
viewer.classList.add("open");
}
function closeViewer() {
viewerId = null;
vFullDone = false;
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
viewer.classList.remove("open");
vimg.src = "";
}
document.getElementById("vclose").addEventListener("click", closeViewer);
addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); });
/* pointer-based pan + pinch (touch-action:none on #viewer keeps the browser
from stealing the gesture) */
const pts = new Map();
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
viewer.addEventListener("pointerdown", (e) => {
viewer.setPointerCapture(e.pointerId);
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
const [a, b] = [...pts.values()];
pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
}
});
viewer.addEventListener("pointermove", (e) => {
const p = pts.get(e.pointerId);
if (!p) return;
if (pts.size === 1) {
vTx += e.clientX - p.x;
vTy += e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
vApply();
} else if (pts.size === 2) {
p.x = e.clientX; p.y = e.clientY;
const [a, b] = [...pts.values()];
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const d = Math.hypot(a.x - b.x, a.y - b.y);
// The image follows the hand (midpoint drift)...
vTx += m.x - pinchMid.x;
vTy += m.y - pinchMid.y;
// ...and zooms ABOUT the midpoint: the image point under the
// midpoint stays put while the scale changes.
if (pinchDist > 0 && d > 0) {
const ratio = d / pinchDist;
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
vTx = m.x - innerWidth / 2 - (m.x - cx) * ratio;
vTy = m.y - innerHeight / 2 - (m.y - cy) * ratio;
vScale *= ratio;
}
pinchMid = m;
pinchDist = d;
vApply(); // also re-clamps (fit min, image-edge pan limits)
}
});
const endPt = (e) => { pts.delete(e.pointerId); };
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>