gallery/harnesses/gtauri/frontend/dist/index.html
Greg Pomerantz bca6a9de4a gtauri viewer: horizontal swipe navigation (left/right to next/previous)
Standard gallery swipe: at fit scale a horizontal drag follows the
finger 1:1 (rubber-banded past the page edge); on release, past 25% of
the width or a fast flick (>0.35 px/ms), the neighbor in the list
opens with a 150 ms ease-out slide-in from the drag direction.
Short drags snap back; vertical drags at fit are inert; zoomed drags
still pan and pinch still zooms. Arrow keys navigate on desktop.

Verified on the emulator: left swipes advance, right swipe returns to
the previous image pixel-identical, 80px drag and vertical drag do not
navigate.
2026-11-03 11:23:41 -05:00

607 lines
21 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, image cache,
viewer compositing. Rust owns: scan, SQLite, thumbnail decode and
the on-disk cache behind gallery://. */
/*
VIRTUALIZED GRID
The document contains ONE spacer div (full scroll height) and ONE
band of recycled cells (~2.5 screens + buffer). Total DOM nodes:
~80 for a 10,000-photo library — layout cost is independent of
library size, so launch renders the first screen as soon as the
WebView boots + the first thumbnails arrive.
*/
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 { position: relative; }
#spacer { /* scroll length driver — no content */ }
#band {
position: absolute; left: 0; right: 0; top: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
will-change: transform; /* compositor-positioned, zero layout */
}
.cell {
overflow: hidden;
background: #1c1c22;
border-radius: 2px;
}
.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 id="spacer"></div>
<div id="band"></div>
</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 spacer = document.getElementById("spacer");
const band = document.getElementById("band");
const stext = document.getElementById("stext");
const viewer = document.getElementById("viewer");
const vimg = document.getElementById("vimg");
const vload = document.getElementById("vload");
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
let ids = []; // display order (mtime desc)
const idSet = new Set(); // id membership (O(1) stale-id checks on 404)
let total = 0, scanning = false, lastSig = "";
let cellW = 0, rowH = 0, rows = 0;
const loadedSet = new Set(); // ids whose real thumb has rendered
const imgById = new Map(); // id -> <img> currently bound in the band
const pool = []; // recycled .cell elements (band children)
function statusLine() {
stext.textContent = total + " photos" +
(scanning ? " • scanning…" : "") +
(loadedSet.size < total ? " (" + loadedSet.size + ")" : "");
}
/* ---------------- layout metrics (uniform 3-col squares) ------------- */
function layoutMetrics() {
const w = grid.clientWidth || innerWidth;
cellW = Math.floor((w - PAD * 2 - GAP * (COLS - 1)) / COLS);
if (cellW < 10) cellW = 10;
rowH = cellW + GAP;
rows = Math.ceil(total / COLS);
for (const c of pool) c.style.height = cellW + "px";
spacer.style.height = (rows * rowH - (rows ? GAP : 0) + PAD * 2) + "px";
}
/* ---------------- band recycling -------------------------------------- */
function cellAt(i) { // create pool cells on demand
while (pool.length <= i) {
const c = document.createElement("div");
c.className = "cell";
const im = new Image();
im.decoding = "async";
im.__retry = 0;
im.addEventListener("load", () => {
// im.__id = the id this img was last requested for (a stale
// load event from a previous assignment must not be credited
// to the recycled cell's new id).
const id = im.__id;
if (im.naturalWidth > 2) {
if (id !== undefined && !loadedSet.has(id)) { loadedSet.add(id); statusLine(); }
} else if (im.__retry < 4) {
im.__retry++;
// Cold cell: Rust served the 1x1 placeholder and queued the
// decode. Only bother re-requesting if the cell is still in
// (or near) the band — far cells will be re-requested when they
// cycle in, or via the thumb-ready event.
const r = im.getBoundingClientRect();
if (r.bottom < -2 * innerHeight || r.top > 3 * innerHeight) return;
jlog("js retry id=" + id + " v=" + im.__retry);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
}
});
im.addEventListener("error", () => {
// The protocol returned no image (404: the file was deleted and
// the list update has not reached this cell yet, or a transient
// read failure). Without this handler the cell would sit as an
// empty dark box forever.
const id = im.__id;
if (id === undefined) return;
if (!idSet.has(id)) {
// id no longer in the list — drop the content; the next band
// pass reassigns (or hides) this cell.
im.removeAttribute("src");
markBand();
} else if (im.__retry < 4) {
im.__retry++;
jlog("js err id=" + id + " v=" + im.__retry);
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
}
});
im.addEventListener("click", () => {
const id = +c.dataset.id;
if (id) openViewer(id);
});
c.appendChild(im);
band.appendChild(c);
pool.push(c);
}
return pool[i];
}
let bandDirty = true;
function markBand() { bandDirty = true; }
function updateBand() {
if (total === 0 || rowH === 0) return;
const y = window.scrollY;
const r0 = Math.max(0, Math.floor(y / rowH) - BUFFER_ROWS);
const r1 = Math.min(rows, Math.ceil((y + innerHeight) / rowH) + BUFFER_ROWS);
const i0 = r0 * COLS, i1 = r1 * COLS;
band.style.transform = "translateY(" + (r0 * rowH) + "px)";
const need = i1 - i0;
while (pool.length < need) cellAt(pool.length);
for (let s = 0; s < pool.length; s++) {
const c = pool[s];
if (s >= need) { c.style.display = "none"; continue; }
const idx = i0 + s;
const id = ids[idx];
if (id === undefined) { c.style.display = "none"; continue; }
c.style.display = "";
// Compare BOTH idx and id: after a deletion the same slot index
// holds a different photo — idx alone would keep the stale image.
if (+c.dataset.idx !== idx || c.dataset.id != id) {
c.dataset.idx = idx;
c.dataset.id = id;
const im = c.firstElementChild;
im.__retry = 0;
im.__id = id;
// swap tracking: this id is now bound to this img
const old = +c.dataset.idold || 0;
if (old && imgById.get(old) === im) imgById.delete(old);
c.dataset.idold = id;
imgById.set(id, im);
im.src = "http://gallery.localhost/thumb/" + id;
}
}
}
function bandLoop() {
if (bandDirty) { bandDirty = false; updateBand(); }
requestAnimationFrame(bandLoop);
}
requestAnimationFrame(bandLoop);
addEventListener("scroll", () => {
markBand();
sendAnchor(false);
}, { passive: true });
addEventListener("resize", () => { layoutMetrics(); markBand(); });
/* -------- rebuild (initial list + live rescan) ------------------------ */
function rebuild(newIds, isScanning, found) {
const sig = newIds.length + ":" + (newIds[0] ?? 0) + ":" + (newIds[newIds.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return;
// pin the viewport: the cell currently on top keeps its screen slot
const keepId = (ids.length > 0 && rowH > 0)
? ids[Math.min(ids.length - 1, Math.floor(window.scrollY / rowH) * COLS)]
: null;
ids = newIds;
lastSig = sig;
scanning = isScanning;
total = ids.length;
idSet.clear();
for (const id of ids) idSet.add(id);
layoutMetrics();
bandDirty = true;
statusLine();
if (keepId !== null && !scanning) {
const i = ids.indexOf(keepId);
if (i >= 0) {
requestAnimationFrame(() => scrollTo(0, PAD + Math.floor(i / COLS) * rowH));
}
}
}
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
`a` = index of the first visible grid cell. Rust pre-decodes rows
ahead of the viewport so fast scrolls hit a warm mem cache. */
let lastAnchor = 0, anchorTrail = null;
function sendAnchor(force) {
const now = performance.now();
if (!force && now - lastAnchor < 150) {
if (!anchorTrail) {
anchorTrail = setTimeout(() => { anchorTrail = null; sendAnchor(true); }, 160);
}
return;
}
lastAnchor = now;
if (rowH > 0 && total > 0) {
const a = Math.min(total - 1, Math.floor(window.scrollY / rowH) * COLS);
try {
window.__TAURI__.event.emit("gtauri-scroll", { a: Math.max(0, a) });
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + Math.max(0, a) + " y=" + Math.round(window.scrollY));
} catch (e) {}
}
}
function jlog(s) {
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
}
try {
window.__TAURI__.event.listen("gallery-files", (e) => {
let p = e.payload || {};
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);
// READY HANDSHAKE: the initial gallery-files emit can fire before this
// page's JS has loaded (small/fast DBs) and be lost (Tauri events are
// fire-and-forget). Tell Rust we're listening; it re-emits the state.
try { window.__TAURI__.event.emit("gtauri-js-ready"); } catch (e) {}
// Rust served a 1x1 placeholder and finished the decode off-main:
// re-request (now a mem hit). Payloads arrive as JSON strings 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 && im.naturalWidth <= 2) {
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + (++im.__retry);
}
});
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.
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=" + loadedSet.size + "/" + 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; pan limited to image edges — no
// blank space around the image, letterboxed axes stay 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;
let slideFrom = null; // pending slide start offset for a swiped-in image
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 = "";
if (slideFrom != null) {
// Incoming swiped image: start where the page strip puts it and
// slide home. The tween owns the transform from here on.
vTx = slideFrom; vTy = 0;
slideFrom = null;
tweenTo(0, 0, 150);
return;
}
if (tween) return; // settle/slide owns the transform right now
vApply();
}
/// Fetch url; promote to the viewer only if REAL image bytes (not the
/// 1x1 placeholder).
function tryShow(id, url, isFull) {
const im = new Image();
im.addEventListener("load", () => {
if (viewerId !== id) return;
if (im.naturalWidth <= 2) return;
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, fromX) {
viewerId = id;
slideFrom = (fromX == null) ? null : fromX;
vFullDone = false;
vScale = 1; vTx = 0; vTy = 0;
vload.textContent = "loading…";
vimg.style.visibility = "hidden";
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
// 2) Full-res: Rust reads the file OFF the main thread; 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; }
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();
else if (viewer.classList.contains("open")) {
if (e.key === "ArrowRight") navigate(+1);
else if (e.key === "ArrowLeft") navigate(-1);
}
});
/* pointer-based pan + pinch (touch-action:none keeps the browser from
stealing the gesture). At FIT scale a horizontal drag is a SWIPE:
it navigates to the neighbor in the list and slides the incoming
image in from the drag direction. When zoomed, drags pan. */
const pts = new Map();
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
let gesture = null; // {mode, x0, y0, sx, t, vx} for a 1-pointer gesture
let tween = null; // in-flight slide/settle animation {raf}
/// Below fitScale()*1.02 the user is "at fit" (swipes navigate).
function atFit() {
return vScale <= fitScale() * 1.02;
}
/// Raw transform write (no clamping) — used by the tween and swipes.
function applyTransform() {
vimg.style.transform =
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
}
/// Ease-out tween of the view transform to (tx, ty) over `ms`.
function tweenTo(tx, ty, ms) {
if (tween) cancelAnimationFrame(tween.raf);
const x0 = vTx, y0 = vTy, t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / ms);
const e = 1 - Math.pow(1 - t, 3);
vTx = x0 + (tx - x0) * e;
vTy = y0 + (ty - y0) * e;
applyTransform();
if (t < 1) tween.raf = requestAnimationFrame(step);
else { tween = null; vTx = tx; vTy = ty; }
};
tween = { raf: requestAnimationFrame(step) };
}
/// Go to the neighbor `delta` steps down the list (+1 = swipe left).
/// The incoming image starts on the page strip where the drag left
/// off and slides home.
function navigate(delta) {
const i = ids.indexOf(viewerId);
const ni = i + delta;
if (i < 0 || ni < 0 || ni >= ids.length) return;
const from = vTx + delta * innerWidth;
openViewer(ids[ni], from);
}
viewer.addEventListener("pointerdown", (e) => {
viewer.setPointerCapture(e.pointerId);
if (tween) { cancelAnimationFrame(tween.raf); tween = null; }
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
gesture = null;
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);
} else {
const now = performance.now();
gesture = { mode: "free", x0: e.clientX, y0: e.clientY,
sx: e.clientX, sy: e.clientY, t: now, vx: 0 };
}
});
viewer.addEventListener("pointermove", (e) => {
const p = pts.get(e.pointerId);
if (!p) return;
if (pts.size === 1 && gesture) {
if (atFit()) {
// At fit: horizontal drag = swipe navigation, vertical = inert.
const dx = e.clientX - gesture.x0;
const dy = e.clientY - gesture.y0;
if (gesture.mode === "free") {
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
gesture.mode = Math.abs(dx) >= Math.abs(dy) * 1.15 ? "swipe" : "vblank";
}
if (gesture.mode === "swipe") {
// Velocity over the last ~30 ms (px/ms) for flick detection.
const now = performance.now();
if (now - gesture.t > 30) {
gesture.vx = (e.clientX - gesture.sx) / (now - gesture.t);
gesture.t = now; gesture.sx = e.clientX;
}
// 1:1 follow, rubber-banded once the page edge is passed.
const w = innerWidth;
vTx = Math.abs(dx) > w ? Math.sign(dx) * (w + (Math.abs(dx) - w) * 0.4) : dx;
vTy = 0;
applyTransform();
}
return;
}
// Zoomed: free pan (incremental, as before).
vTx += e.clientX - p.x;
vTy += e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
vApply();
return;
}
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.
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();
}
});
const endPt = (e) => {
const g = gesture;
pts.delete(e.pointerId);
if (pts.size === 0 && g && g.mode === "swipe") {
const dx = e.clientX - g.x0;
const w = innerWidth;
if (dx < -w * 0.25 || g.vx < -0.35) navigate(+1);
else if (dx > w * 0.25 || g.vx > 0.35) navigate(-1);
else tweenTo(0, 0, 120); // not far enough: snap back
}
gesture = null;
};
viewer.addEventListener("pointerup", endPt);
viewer.addEventListener("pointercancel", endPt);
viewer.addEventListener("dblclick", () => {
vScale = fitScale();
vTx = 0; vTy = 0; vApply();
});
</script>
</body>
</html>