Pan is now limited to the image bounds (|offset| <= (rendered - viewport)/2 per axis), so the image can never be dragged off-screen leaving blank space around it; on a letterboxed axis panning is locked to centered. Pinch can no longer zoom out past fit. Clamping lives in vApply() so pan, pinch and reset all share it; still compositor-only (transform only).
401 lines
13 KiB
HTML
401 lines
13 KiB
HTML
<!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 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 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(fitScale(), pinchS0 * d / pinchD0);
|
||
vApply();
|
||
}
|
||
});
|
||
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>
|