- Camera-photo latency: root cause was READ_MEDIA_IMAGES never granted (granted=false on the phone), so the MediaStore fast-path query silently returned nothing and photos only appeared via the slow full FUSE walk. The app now requests the permission at runtime (once, with a single retry on denial). The camera writes through MediaStore at capture time, so the ContentObserver fires immediately and the photo appears in ~1 s. - Delete blank boxes: two JS flaws — (1) no img onerror handler, so a cell that requested a thumb for an already-deleted file (404) sat as an empty dark box forever; (2) recycled cells were matched by slot index only, so after a deletion shifted the list a cell could keep a stale id. Now: onerror drops stale ids (idSet membership) or retries transient ones, and cells reassign on idx OR id change. - Truncation guard (chicken-and-egg): the mass-delete suspicion threshold was 20% but observed FUSE truncations lose 15-17%, which sailed under it and poisoned the DB. Now 10%; a confirmed deletion (two-strike passed) lowers the new walked_max watermark, a refused one keeps it. Layer-2 re-walk compares against max(db, walked_max) so a poisoned (shrunk) DB cannot silence truncation detection. Emulator suite: healthy→truncated (all 3 layers, 0 loss), poisoned-DB→truncated (layer 2 now fires), healthy walk self-heals. - Verified on the phone: DB matches disk exactly (8,398 Camera + 27 screenshots + 11 messaging + 2 bluesky = 8,438); an earlier 10,166 count was a transient user-system state, not truncation.
503 lines
17 KiB
HTML
503 lines
17 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, 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;
|
||
|
||
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; 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) {
|
||
viewerId = id;
|
||
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(); });
|
||
|
||
/* pointer-based pan + pinch (touch-action:none 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.
|
||
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) => { 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>
|