gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 02d1f097ac gtauri: fix deep-scroll stall + instant viewer
Root causes found via per-event tracing (trace.log) + heartbeats:

1. Decode backlog: single worker at ~300ms/photo could not keep up with
   fast long scrolls — phone session ended with a 2400-deep queue
   draining at ~3 cells/s (13+ min to catch up). That was the
   'scroll far down and nothing loads no matter how long I wait' zone.
   - 4 parallel decode workers (~12 photos/s)
   - viewport-aware stale drops: prefetch jobs more than 60 rows behind
     the viewport are dropped, not decoded (fleeing leaves no backlog)
   - Q_CAP bounds the queue; JS 1.5s safety net re-requests
   - queue peak in repro test: 42 (was 2405); all 850 decodable
     emulator images now load (708 before)

2. Stale-anchor bug: sendAnchor was leading-edge throttled with no
   trailing call — if a scroll ended inside the 150ms window the Rust
   anchor stayed stale forever, and the worker then dropped fresh
   URGENT jobs (tap → full read) as 'off-screen'. Fix: trailing
   anchor send; and URGENT jobs are never stale-dropped (only
   prefetch work is droppable).

3. Viewer latency: full-res read ran on Chromium's main thread
   (wry shouldInterceptRequest is synchronous) — 30-50MB flash read
   froze the whole WebView. Now: thumb shown instantly (mem cache,
   ~1ms), full read queued urgent and done off-main; full-ready event
   swaps in the full-res (256MB full-res LRU makes re-opens instant).
   Measured click→full-res: 4ms on emulator.

4. Scan: skip OS junk (._* AppleDouble forks, dotfiles) — the phone's
   DCIM carried 72 of them, rendering as permanent gray cells.

5. Viewer for a corrupt file now shows 'image unavailable' instead of
   'loading…' forever; grid retry timers skip far-off-screen cells.
2026-11-01 09:11:11 -05:00

387 lines
13 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 + ")" : "");
}
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;
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);
statusLine();
eagerBand();
}
/* -------- 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) => {
const p = e.payload || {};
rebuild(p.ids || [], !!p.scanning, 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).
window.__TAURI__.event.listen("thumb-ready", (e) => {
const id = e.payload.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 = e.payload.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 vApply() {
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 pinchD0 = 0, pinchS0 = 1;
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()];
pinchD0 = Math.hypot(a.x - b.x, a.y - b.y);
pinchS0 = vScale;
}
});
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 d = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchD0 > 0) vScale = Math.max(0.05, pinchS0 * d / pinchD0);
vApply();
}
});
const endPt = (e) => { pts.delete(e.pointerId); };
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
vScale = Math.min(innerWidth / base.w, innerHeight / base.h);
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>