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.
442 lines
15 KiB
HTML
442 lines
15 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 + ")" : "");
|
||
}
|
||
|
||
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>
|