gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz 312d87f15d gtauri: pinch zoom about pointer midpoint + live filesystem watching
Pinch: the image now follows the hand (midpoint drift) and zooms
ABOUT the midpoint — the image point under the fingers stays put
while the scale changes. Per-frame delta math, clamped as before
(fit min, image-edge pan limits).

Live watching: new photos appear in the grid within ~2s of being
written. inotify does not work on Android's FUSE-backed external
storage, so the watcher polls a tree signature (dir mtimes only —
a few dozen stats/s) every second; on change (or a 30s safety-net
full pass) it debounces 600ms for the write burst, then runs the
incremental rescan (walk + DB diff + emit) off the main thread.

Supporting changes:
- Staged corruption: a decode failure no longer marks an id broken
  immediately — 3 consecutive failures are required, and a success
  clears the count. A photo still being written fails once or twice
  and then decodes; genuine garbage gives up and stays gray.
- Grid rebuild preserves the viewport across a live rescan (pins to
  the cell that was on top), so a new photo landing at the top does
  not jump the scroll position.
2026-11-01 18:02:09 -05:00

430 lines
14 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) => {
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 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>