Chromium owns scroll/compositor/lazy-load/image-cache; Rust owns scan/SQLite/thumbnail-decode behind a gallery:// custom protocol. Hard-won gotchas (see in-code comments): - Android: no native custom-scheme support → wry rewrites the URL to http://gallery.localhost/... (custom_protocol_workaround). The JS MUST request that form; the handler receives the reverted gallery://localhost/... and must strip the authority. - tauri_build::build() in build.rs must run UNCONDITIONALLY (build scripts run on the host: a cfg(target_os) gate silently disables it, which skips the rustc-cfg=mobile emission → no mobile_entry_point → .so with no JNI symbols → UnsatisfiedLinkError). - build.rs must list frontend files for rerun-if-changed (a dir-level directive only watches the dir mtime, not content edits) — the .so bakes the frontend at compile time and cargo otherwise ships stale UI with a 'successful' build. - logcat drops ~75% of lines under load: use the file trace. - fetch() is CORS-restricted (page origin http://tauri.localhost); <img> is not — add ACAO:* anyway. Emulator (5853 real files): grid renders, lazy thumbs via gallery:// with on-disk cache (87 files 35-72KB), FR log clean at 60Hz, viewer opens full-res with compositor-only pan/zoom. The single 'broken' cell is a 17-byte fake test JPEG (correct behavior).
230 lines
6.7 KiB
HTML
230 lines
6.7 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;
|
||
|
||
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();
|
||
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.src = "http://gallery.localhost/thumb/" + id;
|
||
im.addEventListener("load", () => { loaded++; statusLine(); });
|
||
im.addEventListener("click", () => openViewer(id));
|
||
c.appendChild(im);
|
||
frag.appendChild(c);
|
||
}
|
||
grid.appendChild(frag);
|
||
statusLine();
|
||
}
|
||
|
||
try {
|
||
window.__TAURI__.event.listen("gallery-files", (e) => {
|
||
const p = e.payload || {};
|
||
rebuild(p.ids || [], !!p.scanning, p.found || 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 vApply() {
|
||
vimg.style.transform =
|
||
"translate(" + (vTx - base.w * vScale / 2) + "px," + (vTy - base.h * vScale / 2) + "px) scale(" + vScale + ")";
|
||
}
|
||
|
||
function openViewer(id) {
|
||
vload.textContent = "loading…";
|
||
vimg.style.visibility = "hidden";
|
||
vScale = 1; vTx = 0; vTy = 0;
|
||
vimg.onload = () => {
|
||
base.w = vimg.naturalWidth; base.h = vimg.naturalHeight;
|
||
const s = Math.min(innerWidth / base.w, innerHeight / base.h);
|
||
vScale = s;
|
||
vimg.style.width = base.w + "px";
|
||
vimg.style.height = base.h + "px";
|
||
vimg.style.visibility = "visible";
|
||
vload.textContent = "";
|
||
vApply();
|
||
};
|
||
vimg.src = "http://gallery.localhost/full/" + id;
|
||
viewer.classList.add("open");
|
||
}
|
||
|
||
function closeViewer() {
|
||
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(0.05, pinchS0 * d / pinchD0);
|
||
vApply();
|
||
}
|
||
});
|
||
const endPt = (e) => { pts.delete(e.pointerId); };
|
||
viewer.addEventListener("pointerup", endPt);
|
||
viewer.addEventListener("pointercancel", endPt);
|
||
viewer.addEventListener("dblclick", () => {
|
||
vScale = Math.min(innerWidth / base.w, innerHeight / base.h);
|
||
vTx = 0; vTy = 0; vApply();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|