gtauri: MediaStore fast path + js-ready handshake + virtualized grid
- MediaStore fast path: Kotlin's ContentObserver queries MediaStore for images added in the last 120s and writes path|added|size lines to .ms_recent, then wakes Rust via the proven zero-arg nativeFsChange() JNI. Rust upserts those rows and emits immediately — a camera photo appears in ~1s without waiting for the ~10s FUSE re-read of a 10,000-file directory. (Direct Kotlin->Rust JNI string passing and Rust->Java calls crash under the emulator's x86_64->arm64 native bridge; file handoff plus zero-arg JNI is the only reliable channel.) - js-ready handshake: the first gallery-files emit fires ~30 ms after process start, before the WebView's JS exists (Tauri events are fire-and-forget, so it was lost). JS now signals 'gtauri-js-ready'; Rust re-emits the current state. - Virtualized grid: the document is ONE spacer (full scroll height) plus ONE band of recycled cells pinned by transform — ~80 DOM nodes total for a 10,000-photo library. Launch cost no longer scales with library size; the first screen renders as soon as the WebView boots and the first thumbs arrive. - 'Missing photos' report resolved: the full walk of the 9,961-file Camera dir enumerates exactly 8,438 images with no guard firing; the difference is videos, which a photo gallery correctly excludes.
This commit is contained in:
parent
ef884b3259
commit
9e52dc0355
290
harnesses/gtauri/frontend/dist/index.html
vendored
290
harnesses/gtauri/frontend/dist/index.html
vendored
|
|
@ -5,9 +5,17 @@
|
|||
<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://. */
|
||||
/* 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;
|
||||
|
|
@ -24,19 +32,22 @@
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
#grid {
|
||||
#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 {
|
||||
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;
|
||||
|
|
@ -79,7 +90,10 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="status"><span id="stext">starting…</span></div>
|
||||
<div id="grid"></div>
|
||||
<div id="grid">
|
||||
<div id="spacer"></div>
|
||||
<div id="band"></div>
|
||||
</div>
|
||||
<div id="viewer">
|
||||
<div id="vload"></div>
|
||||
<img id="vimg" alt="">
|
||||
|
|
@ -89,143 +103,174 @@
|
|||
"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");
|
||||
|
||||
let total = 0, loaded = 0, lastSig = "", scanning = false;
|
||||
const imgById = new Map(); // id -> <img> (for thumb-ready re-requests)
|
||||
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
|
||||
|
||||
let ids = []; // display order (mtime desc)
|
||||
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…" : "") +
|
||||
(loaded < total ? " (" + loaded + ")" : "");
|
||||
(loadedSet.size < total ? " (" + loadedSet.size + ")" : "");
|
||||
}
|
||||
|
||||
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];
|
||||
/* ---------------- 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";
|
||||
}
|
||||
grid.replaceChildren();
|
||||
imgById.clear();
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const id of ids) {
|
||||
|
||||
/* ---------------- 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.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.__retry = 0;
|
||||
im.addEventListener("load", () => {
|
||||
// naturalWidth 1 => the Rust placeholder, not a real thumb
|
||||
if (im.naturalWidth > 2) { loaded++; statusLine(); }
|
||||
else if (im.__retry < 4) {
|
||||
// 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++;
|
||||
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.
|
||||
// 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 < -innerHeight || r.top > 2 * innerHeight) return;
|
||||
if (r.bottom < -2 * innerHeight || r.top > 3 * innerHeight) return;
|
||||
jlog("js retry id=" + id + " v=" + im.__retry);
|
||||
im.src = im.dataset.url + "?v=" + im.__retry;
|
||||
}
|
||||
}, 1500);
|
||||
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
|
||||
}
|
||||
});
|
||||
im.addEventListener("click", () => openViewer(id));
|
||||
im.addEventListener("click", () => {
|
||||
const id = +c.dataset.id;
|
||||
if (id) openViewer(id);
|
||||
});
|
||||
c.appendChild(im);
|
||||
frag.appendChild(c);
|
||||
band.appendChild(c);
|
||||
pool.push(c);
|
||||
}
|
||||
grid.appendChild(frag);
|
||||
lastIds = ids.slice();
|
||||
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 = "";
|
||||
if (+c.dataset.idx !== idx) {
|
||||
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;
|
||||
layoutMetrics();
|
||||
bandDirty = true;
|
||||
statusLine();
|
||||
eagerBand();
|
||||
if (keepId !== null) {
|
||||
if (keepId !== null && !scanning) {
|
||||
const i = ids.indexOf(keepId);
|
||||
if (i >= 0) {
|
||||
requestAnimationFrame(() => scrollTo(0, Math.floor(i / 3) * rowH));
|
||||
requestAnimationFrame(() => scrollTo(0, PAD + Math.floor(i / COLS) * 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;
|
||||
`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) {
|
||||
// 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);
|
||||
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;
|
||||
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: a });
|
||||
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + a + " y=" + Math.round(window.scrollY));
|
||||
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) {}
|
||||
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); }
|
||||
}
|
||||
|
|
@ -235,10 +280,13 @@ try {
|
|||
});
|
||||
// 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.
|
||||
// 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) || {};
|
||||
|
|
@ -246,13 +294,11 @@ try {
|
|||
window.__TAURI__.event.listen("thumb-ready", (e) => {
|
||||
const id = pid(e).id;
|
||||
const im = imgById.get(id);
|
||||
if (!im) return;
|
||||
if (im.naturalWidth <= 2) {
|
||||
if (im && im.naturalWidth <= 2) {
|
||||
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
|
||||
im.src = im.dataset.url + "?v=" + (++im.__retry);
|
||||
im.src = "http://gallery.localhost/thumb/" + id + "?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) {
|
||||
|
|
@ -260,14 +306,11 @@ try {
|
|||
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).
|
||||
// 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);
|
||||
}
|
||||
if (viewer.classList.contains("open")) closeViewer();
|
||||
else window.__TAURI__.app.exit(0);
|
||||
});
|
||||
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
|
||||
|
||||
|
|
@ -275,7 +318,7 @@ try {
|
|||
let frLast = performance.now(), frLines = 0, frBatch = [];
|
||||
function frFlush() {
|
||||
if (frBatch.length === 0) return;
|
||||
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loaded + "/" + total +
|
||||
frBatch.push("IMG t=" + frLast.toFixed(1) + " loaded=" + loadedSet.size + "/" + total +
|
||||
" scrollY=" + Math.round(window.scrollY));
|
||||
const payload = frBatch.join("\n") + "\n";
|
||||
frBatch = [];
|
||||
|
|
@ -301,10 +344,8 @@ function fitScale() {
|
|||
}
|
||||
|
||||
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.
|
||||
// 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);
|
||||
|
|
@ -327,13 +368,13 @@ function fitViewer(w, h) {
|
|||
vApply();
|
||||
}
|
||||
|
||||
/// Fetch url and, if it is REAL image bytes (not the 1x1 placeholder),
|
||||
/// promote it into the viewer.
|
||||
/// 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; // placeholder — wait for the ready event
|
||||
if (im.naturalWidth <= 2) return;
|
||||
vimg.src = im.src;
|
||||
fitViewer(im.naturalWidth, im.naturalHeight);
|
||||
if (isFull) {
|
||||
|
|
@ -350,11 +391,10 @@ function openViewer(id) {
|
|||
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.
|
||||
// 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; the first
|
||||
// request gets the placeholder, `full-ready` triggers the swap.
|
||||
// 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);
|
||||
|
|
@ -365,7 +405,6 @@ function openViewer(id) {
|
|||
}
|
||||
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";
|
||||
}
|
||||
|
|
@ -386,8 +425,8 @@ function closeViewer() {
|
|||
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) */
|
||||
/* 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) => {
|
||||
|
|
@ -415,8 +454,7 @@ viewer.addEventListener("pointermove", (e) => {
|
|||
// 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.
|
||||
// ...and zooms ABOUT the midpoint.
|
||||
if (pinchDist > 0 && d > 0) {
|
||||
const ratio = d / pinchDist;
|
||||
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
|
||||
|
|
@ -426,7 +464,7 @@ viewer.addEventListener("pointermove", (e) => {
|
|||
}
|
||||
pinchMid = m;
|
||||
pinchDist = d;
|
||||
vApply(); // also re-clamps (fit min, image-edge pan limits)
|
||||
vApply();
|
||||
}
|
||||
});
|
||||
const endPt = (e) => { pts.delete(e.pointerId); };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
<!-- all-files access: the app scans /sdcard/Pictures + /sdcard/DCIM directly
|
||||
(same grant the egui gallery already holds for this package). -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -8,15 +8,96 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private var observer: ContentObserver? = null
|
||||
|
||||
// MediaStore fast path: when the OS reports a media change (or right
|
||||
// after startup), query MediaStore on a background thread for images
|
||||
// indexed in the last ~2 min and push them to Rust via JNI. This is
|
||||
// what makes a just-taken camera photo appear in ~1 s instead of
|
||||
// waiting for the ~10 s FUSE re-read of a huge directory.
|
||||
private val fsExecutor = Executors.newSingleThreadExecutor()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var fsScheduled = false
|
||||
|
||||
// Rust side (this app's JNI library, loaded by TauriActivity): wakes
|
||||
// the file-watcher thread. One JNI call + one channel send — nothing
|
||||
// heavy ever runs on the main thread.
|
||||
external fun nativeFsChange()
|
||||
|
||||
// Rust side reads the payload file (getExternalFilesDir/.ms_recent)
|
||||
// when nativeFsChange wakes it. No string crosses JNI.
|
||||
|
||||
/** Query MediaStore for recently-indexed images and push to Rust. */
|
||||
private fun pushRecent() {
|
||||
try {
|
||||
val since = System.currentTimeMillis() / 1000 - 120
|
||||
val sb = StringBuilder()
|
||||
val rows = contentResolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(
|
||||
MediaStore.Images.Media.DATA,
|
||||
MediaStore.Images.Media.RELATIVE_PATH,
|
||||
MediaStore.Images.Media.SIZE,
|
||||
MediaStore.Images.Media.DATE_ADDED
|
||||
),
|
||||
"${MediaStore.Images.Media.DATE_ADDED} >= ?",
|
||||
arrayOf(since.toString()),
|
||||
"${MediaStore.Images.Media.DATE_ADDED} DESC"
|
||||
)
|
||||
if (rows == null) {
|
||||
android.util.Log.w("gallery-ms", "MediaStore query returned null (permission?)")
|
||||
return
|
||||
}
|
||||
rows.use { c ->
|
||||
val iData = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val iRel = c.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||
val iSize = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
|
||||
val iAdded = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
|
||||
while (c.moveToNext()) {
|
||||
var data = c.getString(iData)
|
||||
if (data.isNullOrEmpty() && iRel >= 0) {
|
||||
val rel = c.getString(iRel) ?: continue
|
||||
data = "/storage/emulated/0/" + rel
|
||||
}
|
||||
if (data.isNullOrEmpty()) continue
|
||||
sb.append(data).append('|')
|
||||
.append(c.getLong(iAdded)).append('|')
|
||||
.append(c.getLong(iSize)).append('\n')
|
||||
}
|
||||
}
|
||||
android.util.Log.i("gallery-ms", "pushRecent rows=" + sb.length)
|
||||
if (sb.isNotEmpty()) {
|
||||
// Hand the payload to Rust via a file (robust under the native
|
||||
// bridge; the JNI call stays the proven zero-arg wake).
|
||||
val dir = getExternalFilesDir(null) ?: return
|
||||
val tmp = java.io.File(dir, ".ms_recent.tmp")
|
||||
val fin = java.io.File(dir, ".ms_recent")
|
||||
tmp.writeText(sb.toString())
|
||||
if (!tmp.renameTo(fin)) {
|
||||
tmp.copyTo(fin, overwrite = true)
|
||||
tmp.delete()
|
||||
}
|
||||
nativeFsChange()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("gallery-ms", "pushRecent failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Debounced (800 ms, main thread) schedule of pushRecent on the
|
||||
* background executor — the observer can fire in bursts. */
|
||||
private fun scheduleFsPush() {
|
||||
if (fsScheduled) return
|
||||
fsScheduled = true
|
||||
mainHandler.postDelayed({
|
||||
fsScheduled = false
|
||||
fsExecutor.execute { pushRecent() }
|
||||
}, 800)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -27,6 +108,7 @@ class MainActivity : TauriActivity() {
|
|||
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
nativeFsChange()
|
||||
scheduleFsPush()
|
||||
}
|
||||
}
|
||||
observer = obs
|
||||
|
|
@ -35,10 +117,15 @@ class MainActivity : TauriActivity() {
|
|||
contentResolver.registerContentObserver(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, obs
|
||||
)
|
||||
// Startup fast path: if the app was killed after a photo was taken,
|
||||
// the observer won't fire — push once after the media scanner has
|
||||
// had a moment to index anything recent.
|
||||
mainHandler.postDelayed({ scheduleFsPush() }, 1500)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
observer?.let { contentResolver.unregisterContentObserver(it) }
|
||||
fsExecutor.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,48 @@ pub fn apply(
|
|||
Ok((inserted, updated, deleted))
|
||||
}
|
||||
|
||||
/// Upsert entries: insert new, update changed — NEVER deletes (the full
|
||||
/// FS rescan owns deletions). Used by the MediaStore fast path, which
|
||||
/// only ever ADDS recently-indexed files.
|
||||
pub fn upsert(
|
||||
conn: &mut Connection,
|
||||
entries: &[Entry],
|
||||
) -> Result<(usize, usize), rusqlite::Error> {
|
||||
let mut inserted = 0;
|
||||
let mut updated = 0;
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut ins = tx.prepare(
|
||||
"INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)",
|
||||
)?;
|
||||
let mut sel = tx.prepare(
|
||||
"SELECT mtime, size FROM items WHERE path = ?1",
|
||||
)?;
|
||||
let mut upd = tx.prepare(
|
||||
"UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \
|
||||
orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1",
|
||||
)?;
|
||||
for e in entries {
|
||||
let old: Option<(i64, i64)> = sel
|
||||
.query_row(params![e.path], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.ok();
|
||||
match old {
|
||||
Some((m, s)) if m == e.mtime && s == e.size => {}
|
||||
Some(_) => {
|
||||
upd.execute(params![e.path, e.mtime, e.size])?;
|
||||
updated += 1;
|
||||
}
|
||||
None => {
|
||||
ins.execute(params![e.path, e.mtime, e.size])?;
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok((inserted, updated))
|
||||
}
|
||||
|
||||
/// Number of rows in the DB.
|
||||
pub fn count(conn: &mut Connection) -> Result<i64, rusqlite::Error> {
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get(0))?;
|
||||
|
|
|
|||
|
|
@ -920,6 +920,84 @@ fn rescan_subtrees(app: &tauri::AppHandle, changed: &[PathBuf]) {
|
|||
tr(&format!("watch: incr changed={} fresh={added}", changed.len()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// MediaStore fast path (Android): when the OS says the media changed,
|
||||
// query MediaStore for images indexed in the last ~2 min. One indexed
|
||||
// provider query (~50 ms) vs a ~10 s FUSE re-read of a huge directory —
|
||||
// this is what makes a just-taken camera photo appear in ~1 s.
|
||||
// ---------------------------------------------------------------------
|
||||
/// MediaStore fast path (Android): read the .ms_recent payload dropped
|
||||
/// by MainActivity and apply it. The query itself runs on Kotlin's
|
||||
/// background executor (no Rust→Java FFI, robust under the native
|
||||
/// bridge); only a zero-arg wake crosses JNI.
|
||||
#[cfg(target_os = "android")]
|
||||
fn apply_mediasotre_payload(app: &tauri::AppHandle) {
|
||||
let path = ms_recent_path();
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { return };
|
||||
let _ = std::fs::remove_file(&path);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
tr(&format!("mediastore push: {} bytes, {} lines", text.len(), text.lines().count()));
|
||||
mediastore_apply(app, &text);
|
||||
}
|
||||
|
||||
/// Path of the .ms_recent handoff file (external files dir — MainActivity
|
||||
/// writes it with getExternalFilesDir(null)).
|
||||
#[cfg(target_os = "android")]
|
||||
fn ms_recent_path() -> std::path::PathBuf {
|
||||
external_files_dir().join(".ms_recent")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn apply_mediasotre_payload(_app: &tauri::AppHandle) {}
|
||||
|
||||
/// MediaStore fast path (Android): apply a "path|addedSec|size" payload
|
||||
/// pushed from MainActivity (which does the MediaStore query on a
|
||||
/// background thread — no Rust→Java FFI). Upserts the rows, re-emits.
|
||||
/// Never deletes (the FS rescan owns deletions). This is what makes a
|
||||
/// just-taken camera photo appear in ~1 s instead of waiting for the
|
||||
/// ~10 s FUSE re-read of a huge directory.
|
||||
#[cfg(target_os = "android")]
|
||||
fn mediastore_apply(app: &tauri::AppHandle, text: &str) {
|
||||
let mut entries = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Some(path) = line.split('|').next() else { continue };
|
||||
if path.is_empty() || !scan::is_image_path(path) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(md) = std::fs::metadata(path) {
|
||||
entries.push(scan::Entry {
|
||||
path: path.to_string(),
|
||||
mtime: mtime_ns(&md),
|
||||
size: md.len() as i64,
|
||||
});
|
||||
}
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut conn = match db::open(&db::db_path()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("mediastore db: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match db::upsert(&mut conn, &entries) {
|
||||
Ok((ins, upd)) => {
|
||||
if ins + upd > 0 {
|
||||
tr(&format!("mediastore fast +{ins} new, {upd} changed"));
|
||||
if let Err(e) = load_meta(&mut conn) {
|
||||
log::warn!("mediastore meta: {e}");
|
||||
}
|
||||
emit_files(app, false, META.read().unwrap().len());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("mediastore upsert: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Watcher wake events.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WEvent {
|
||||
|
|
@ -933,9 +1011,10 @@ enum WEvent {
|
|||
static WATCH_TX: LazyLock<Mutex<Option<mpsc::Sender<WEvent>>>> =
|
||||
LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// Android: called by MainActivity's ContentObserver via JNI.
|
||||
/// Signature: (JNIEnv*, jobject) for an instance method — both
|
||||
/// ignored; the symbol name is what binds.
|
||||
/// Android: called by MainActivity's ContentObserver via JNI (and after
|
||||
/// each MediaStore fast-path payload drop). Signature: (JNIEnv*,
|
||||
/// jobject) for an instance method. One channel send — nothing heavy,
|
||||
/// never blocks the main thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_wow_gmp_gallery_MainActivity_nativeFsChange(
|
||||
|
|
@ -1035,6 +1114,14 @@ fn watch_files(app: tauri::AppHandle) {
|
|||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
// MediaStore fast path: MainActivity (which does the MediaStore
|
||||
// query on a background thread) drops a "path|added|size" payload
|
||||
// in .ms_recent and wakes us via nativeFsChange. Apply it now —
|
||||
// a just-taken camera photo lands in ~1 s, without waiting for
|
||||
// the ~10 s FUSE re-read of a huge directory.
|
||||
if matches!(woke, Some(WEvent::Fs) | Some(WEvent::Full)) {
|
||||
apply_mediasotre_payload(&app);
|
||||
}
|
||||
let next = dir_sig(&scan::roots());
|
||||
let changed = if baseline {
|
||||
baseline = false;
|
||||
|
|
@ -1113,6 +1200,19 @@ pub fn run() {
|
|||
tr(&s);
|
||||
});
|
||||
|
||||
// JS ready handshake: the WebView's page loads ~2-3 s after
|
||||
// process start, while the first gallery-files emit can land
|
||||
// 10 ms in (small/fast DB) and be lost. On handshake, re-emit
|
||||
// the current state so the grid always gets its initial list.
|
||||
let _ = app.listen("gtauri-js-ready", move |event| {
|
||||
let _ = event;
|
||||
let h = APP_HANDLE.lock().unwrap().clone();
|
||||
if let Some(h) = h {
|
||||
tr("js ready — re-emitting state");
|
||||
emit_files(&h, false, META.read().unwrap().len());
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll anchor from JS (throttled to ~7 Hz): `a` = index of the
|
||||
// first visible grid cell. REPLACES the prefetch window with
|
||||
// [a, a+60) — the visible band plus ~13 rows ahead. Stale
|
||||
|
|
@ -1162,6 +1262,9 @@ pub fn run() {
|
|||
// Warm the first two screens before Chromium's lazy loader gets
|
||||
// to them (it requests ~30 at once; we'd rather be ahead).
|
||||
set_prefetch_window(0, 36);
|
||||
// (The startup MediaStore fast path is triggered by
|
||||
// MainActivity.onCreate — it pushes via
|
||||
// nativeMediaStoreResults ~1.5 s in.)
|
||||
spawn_scan(handle.clone());
|
||||
// New photos appear in the grid ~2 s after being written.
|
||||
std::thread::spawn(move || watch_files(handle));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,19 @@ pub const IMAGE_EXTS: &[&str] = &[
|
|||
"jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff",
|
||||
];
|
||||
|
||||
/// True if the path looks like an image we index (hidden files and
|
||||
/// non-image extensions excluded).
|
||||
pub fn is_image_path(p: &str) -> bool {
|
||||
let pb = std::path::Path::new(p);
|
||||
let name = pb.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
pb.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
||||
}
|
||||
|
||||
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
|
||||
pub fn roots() -> Vec<PathBuf> {
|
||||
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
|
||||
|
|
@ -163,6 +176,17 @@ fn walk_dir(
|
|||
Some(e) => e,
|
||||
None => return,
|
||||
};
|
||||
// GROUND-TRUTH INSTRUMENTATION: raw getdents count for this directory,
|
||||
// before any filtering. If raw < reality, the VFS served a short
|
||||
// listing to this process; if raw == reality but the walk total is
|
||||
// low, a filter below dropped entries (our bug).
|
||||
if dir.parent().is_some() {
|
||||
crate::tr(&format!(
|
||||
"rawdir {:?}: {} entries",
|
||||
dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
entries.len()
|
||||
));
|
||||
}
|
||||
// STALE-LISTING CROSS-CHECK (see walk_stream docs): if the DB holds
|
||||
// many more files for this directory than the enumeration produced,
|
||||
// re-read the directory a few times and keep the best listing.
|
||||
|
|
@ -197,9 +221,13 @@ fn walk_dir(
|
|||
}
|
||||
}
|
||||
}
|
||||
let mut n_ftfail = 0u32;
|
||||
let mut n_statfail = 0u32;
|
||||
let mut n_pushed = 0u32;
|
||||
let raw_n = entries.len();
|
||||
for ent in entries {
|
||||
let p = ent.path();
|
||||
let Ok(ft) = ent.file_type() else { continue };
|
||||
let Ok(ft) = ent.file_type() else { n_ftfail += 1; continue };
|
||||
if ft.is_dir() {
|
||||
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
|
||||
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
|
||||
|
|
@ -225,7 +253,10 @@ fn walk_dir(
|
|||
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = std::fs::metadata(&p) else { continue };
|
||||
let Ok(md) = std::fs::metadata(&p) else {
|
||||
n_statfail += 1;
|
||||
continue;
|
||||
};
|
||||
let mtime = md
|
||||
.modified()
|
||||
.ok()
|
||||
|
|
@ -237,10 +268,21 @@ fn walk_dir(
|
|||
mtime,
|
||||
size: md.len() as i64,
|
||||
});
|
||||
n_pushed += 1;
|
||||
if out.len() % 512 == 0 {
|
||||
on_batch(out);
|
||||
}
|
||||
}
|
||||
if n_ftfail > 0 || n_statfail > 0 {
|
||||
crate::tr(&format!(
|
||||
"walkdir {:?}: raw {} pushed {} ftfail {} statfail {}",
|
||||
dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
raw_n,
|
||||
n_pushed,
|
||||
n_ftfail,
|
||||
n_statfail
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user