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
296
harnesses/gtauri/frontend/dist/index.html
vendored
296
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
<title>Gallery</title>
|
<title>Gallery</title>
|
||||||
<style>
|
<style>
|
||||||
/* Chromium owns: layout, compositor-driven scroll, lazy fetch+decode,
|
/* Chromium owns: layout, compositor-driven scroll, image cache,
|
||||||
image cache/eviction, viewer compositing. Rust owns: scan, SQLite,
|
viewer compositing. Rust owns: scan, SQLite, thumbnail decode and
|
||||||
on-disk thumbnail cache behind gallery://. */
|
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 {
|
html, body {
|
||||||
margin: 0; padding: 0;
|
margin: 0; padding: 0;
|
||||||
background: #0a0a0c;
|
background: #0a0a0c;
|
||||||
|
|
@ -24,19 +32,22 @@
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#grid {
|
#grid { position: relative; }
|
||||||
|
#spacer { /* scroll length driver — no content */ }
|
||||||
|
|
||||||
|
#band {
|
||||||
|
position: absolute; left: 0; right: 0; top: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
will-change: transform; /* compositor-positioned, zero layout */
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1c1c22;
|
background: #1c1c22;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
/* explicit 1:1 box via aspect-ratio → zero layout shift on arrival */
|
|
||||||
.cell img {
|
.cell img {
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
@ -79,7 +90,10 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="status"><span id="stext">starting…</span></div>
|
<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="viewer">
|
||||||
<div id="vload"></div>
|
<div id="vload"></div>
|
||||||
<img id="vimg" alt="">
|
<img id="vimg" alt="">
|
||||||
|
|
@ -89,143 +103,174 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
|
const spacer = document.getElementById("spacer");
|
||||||
|
const band = document.getElementById("band");
|
||||||
const stext = document.getElementById("stext");
|
const stext = document.getElementById("stext");
|
||||||
const viewer = document.getElementById("viewer");
|
const viewer = document.getElementById("viewer");
|
||||||
const vimg = document.getElementById("vimg");
|
const vimg = document.getElementById("vimg");
|
||||||
const vload = document.getElementById("vload");
|
const vload = document.getElementById("vload");
|
||||||
|
|
||||||
let total = 0, loaded = 0, lastSig = "", scanning = false;
|
const COLS = 3, GAP = 4, PAD = 4, BUFFER_ROWS = 12;
|
||||||
const imgById = new Map(); // id -> <img> (for thumb-ready re-requests)
|
|
||||||
|
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() {
|
function statusLine() {
|
||||||
stext.textContent = total + " photos" +
|
stext.textContent = total + " photos" +
|
||||||
(scanning ? " • scanning…" : "") +
|
(scanning ? " • scanning…" : "") +
|
||||||
(loaded < total ? " (" + loaded + ")" : "");
|
(loadedSet.size < total ? " (" + loadedSet.size + ")" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastIds = [];
|
/* ---------------- layout metrics (uniform 3-col squares) ------------- */
|
||||||
function rebuild(ids, isScanning, found) {
|
function layoutMetrics() {
|
||||||
const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0);
|
const w = grid.clientWidth || innerWidth;
|
||||||
if (sig === lastSig && scanning === isScanning) return;
|
cellW = Math.floor((w - PAD * 2 - GAP * (COLS - 1)) / COLS);
|
||||||
lastSig = sig;
|
if (cellW < 10) cellW = 10;
|
||||||
scanning = isScanning;
|
rowH = cellW + GAP;
|
||||||
total = ids.length; loaded = 0;
|
rows = Math.ceil(total / COLS);
|
||||||
// Live rescan (a photo just landed) inserts at the top and would
|
for (const c of pool) c.style.height = cellW + "px";
|
||||||
// shift everything — pin the viewport to the cell that was on top.
|
spacer.style.height = (rows * rowH - (rows ? GAP : 0) + PAD * 2) + "px";
|
||||||
let keepId = null;
|
}
|
||||||
if (lastIds.length > 0 && rowH > 0) {
|
|
||||||
const topIdx = Math.min(lastIds.length - 1, Math.floor(window.scrollY / rowH) * 3);
|
/* ---------------- band recycling -------------------------------------- */
|
||||||
keepId = lastIds[topIdx];
|
function cellAt(i) { // create pool cells on demand
|
||||||
}
|
while (pool.length <= i) {
|
||||||
grid.replaceChildren();
|
|
||||||
imgById.clear();
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const id of ids) {
|
|
||||||
const c = document.createElement("div");
|
const c = document.createElement("div");
|
||||||
c.className = "cell";
|
c.className = "cell";
|
||||||
const im = new Image();
|
const im = new Image();
|
||||||
im.loading = "lazy";
|
|
||||||
im.decoding = "async";
|
im.decoding = "async";
|
||||||
im.dataset.url = "http://gallery.localhost/thumb/" + id;
|
im.__retry = 0;
|
||||||
im.__ph = false, im.__retry = 0, im.__lazy = true;
|
|
||||||
im.src = im.dataset.url;
|
|
||||||
imgById.set(id, im);
|
|
||||||
im.addEventListener("load", () => {
|
im.addEventListener("load", () => {
|
||||||
// naturalWidth 1 => the Rust placeholder, not a real thumb
|
// im.__id = the id this img was last requested for (a stale
|
||||||
if (im.naturalWidth > 2) { loaded++; statusLine(); }
|
// load event from a previous assignment must not be credited
|
||||||
else if (im.__retry < 4) {
|
// 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++;
|
im.__retry++;
|
||||||
jlog("js ph-load id=" + id + " retry=" + im.__retry);
|
// Cold cell: Rust served the 1x1 placeholder and queued the
|
||||||
// safety net in case thumb-ready was lost (page rebuild race)
|
// decode. Only bother re-requesting if the cell is still in
|
||||||
setTimeout(() => {
|
// (or near) the band — far cells will be re-requested when they
|
||||||
if (!im.complete || im.naturalWidth <= 2) {
|
// cycle in, or via the thumb-ready event.
|
||||||
// Skip cells far outside the viewport — a fast scroll pass
|
const r = im.getBoundingClientRect();
|
||||||
// leaves hundreds of cold cells behind; re-requesting them
|
if (r.bottom < -2 * innerHeight || r.top > 3 * innerHeight) return;
|
||||||
// all would flood the queue with junk the user cannot see.
|
jlog("js retry id=" + id + " v=" + im.__retry);
|
||||||
const r = im.getBoundingClientRect();
|
im.src = "http://gallery.localhost/thumb/" + id + "?v=" + im.__retry;
|
||||||
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));
|
im.addEventListener("click", () => {
|
||||||
|
const id = +c.dataset.id;
|
||||||
|
if (id) openViewer(id);
|
||||||
|
});
|
||||||
c.appendChild(im);
|
c.appendChild(im);
|
||||||
frag.appendChild(c);
|
band.appendChild(c);
|
||||||
|
pool.push(c);
|
||||||
}
|
}
|
||||||
grid.appendChild(frag);
|
return pool[i];
|
||||||
lastIds = ids.slice();
|
}
|
||||||
|
|
||||||
|
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();
|
statusLine();
|
||||||
eagerBand();
|
if (keepId !== null && !scanning) {
|
||||||
if (keepId !== null) {
|
|
||||||
const i = ids.indexOf(keepId);
|
const i = ids.indexOf(keepId);
|
||||||
if (i >= 0) {
|
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 --------------
|
/* -------- scroll anchor for Rust-side thumbnail prefetch --------------
|
||||||
Tell Rust where the viewport is (throttled to ~7 Hz); the Rust worker
|
`a` = index of the first visible grid cell. Rust pre-decodes rows
|
||||||
pre-decodes the rows ahead of us so fast scrolls hit a warm cache. */
|
ahead of the viewport so fast scrolls hit a warm mem cache. */
|
||||||
let rowH = 0, lastAnchor = 0, anchorSent = false, anchorTrail = null;
|
let lastAnchor = 0, anchorTrail = null;
|
||||||
function sendAnchor(force) {
|
function sendAnchor(force) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (!force && now - lastAnchor < 150) {
|
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) {
|
if (!anchorTrail) {
|
||||||
anchorTrail = setTimeout(() => {
|
anchorTrail = setTimeout(() => { anchorTrail = null; sendAnchor(true); }, 160);
|
||||||
anchorTrail = null;
|
|
||||||
sendAnchor(true);
|
|
||||||
}, 160);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastAnchor = now;
|
lastAnchor = now;
|
||||||
if (rowH === 0) {
|
if (rowH > 0 && total > 0) {
|
||||||
const c = grid.querySelector(".cell");
|
const a = Math.min(total - 1, Math.floor(window.scrollY / rowH) * COLS);
|
||||||
if (c) rowH = c.getBoundingClientRect().height + 4; // + grid gap
|
|
||||||
}
|
|
||||||
if (rowH > 0) {
|
|
||||||
const a = Math.floor(window.scrollY / rowH) * 3;
|
|
||||||
anchorSent = true;
|
|
||||||
try {
|
try {
|
||||||
window.__TAURI__.event.emit("gtauri-scroll", { a: a });
|
window.__TAURI__.event.emit("gtauri-scroll", { a: Math.max(0, a) });
|
||||||
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + a + " y=" + Math.round(window.scrollY));
|
window.__TAURI__.event.emit("gtauri-log", "js anchor a=" + Math.max(0, a) + " y=" + Math.round(window.scrollY));
|
||||||
} catch (e) {}
|
} 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) {
|
function jlog(s) {
|
||||||
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
|
try { window.__TAURI__.event.emit("gtauri-log", s); } catch (e) {}
|
||||||
}
|
}
|
||||||
addEventListener("scroll", () => sendAnchor(false), { passive: true });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.__TAURI__.event.listen("gallery-files", (e) => {
|
window.__TAURI__.event.listen("gallery-files", (e) => {
|
||||||
let p = e.payload || {};
|
let p = e.payload || {};
|
||||||
// On Android the payload can arrive as a JSON *string* — decode it.
|
|
||||||
if (typeof p === "string") {
|
if (typeof p === "string") {
|
||||||
try { p = JSON.parse(p); } catch (err) { jlog("files parse err: " + err); }
|
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.
|
// first anchor (top of grid) — Rust also warms 0..36 on its side.
|
||||||
setTimeout(() => sendAnchor(true), 300);
|
setTimeout(() => sendAnchor(true), 300);
|
||||||
// Cold-miss round-trip: Rust served a 1x1 placeholder and queued the real
|
// READY HANDSHAKE: the initial gallery-files emit can fire before this
|
||||||
// decode off the main thread; when it lands, re-request (now a mem hit).
|
// page's JS has loaded (small/fast DBs) and be lost (Tauri events are
|
||||||
// Event payloads arrive as JSON strings on Android (object on some
|
// fire-and-forget). Tell Rust we're listening; it re-emits the state.
|
||||||
// WebView builds) — normalize both.
|
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 pid = (e) => {
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
return (typeof p === "string" ? JSON.parse(p) : p) || {};
|
return (typeof p === "string" ? JSON.parse(p) : p) || {};
|
||||||
|
|
@ -246,13 +294,11 @@ try {
|
||||||
window.__TAURI__.event.listen("thumb-ready", (e) => {
|
window.__TAURI__.event.listen("thumb-ready", (e) => {
|
||||||
const id = pid(e).id;
|
const id = pid(e).id;
|
||||||
const im = imgById.get(id);
|
const im = imgById.get(id);
|
||||||
if (!im) return;
|
if (im && im.naturalWidth <= 2) {
|
||||||
if (im.naturalWidth <= 2) {
|
|
||||||
jlog("js ready id=" + id + " v=" + (im.__retry + 1));
|
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) => {
|
window.__TAURI__.event.listen("full-ready", (e) => {
|
||||||
const id = pid(e).id;
|
const id = pid(e).id;
|
||||||
if (viewerId === id && !vFullDone) {
|
if (viewerId === id && !vFullDone) {
|
||||||
|
|
@ -260,14 +306,11 @@ try {
|
||||||
tryShow(id, "http://gallery.localhost/full/" + id + "?v=f" + (++vFullSeq), true);
|
tryShow(id, "http://gallery.localhost/full/" + id + "?v=f" + (++vFullSeq), true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Android back: registering ANY "back-button" listener makes Tauri route
|
// Android back: registering ANY "back-button" listener makes Tauri
|
||||||
// the press here instead of finishing the activity (see AppPlugin.kt).
|
// route the press here instead of finishing the activity.
|
||||||
window.__TAURI__.app.onBackButtonPress(() => {
|
window.__TAURI__.app.onBackButtonPress(() => {
|
||||||
if (viewer.classList.contains("open")) {
|
if (viewer.classList.contains("open")) closeViewer();
|
||||||
closeViewer();
|
else window.__TAURI__.app.exit(0);
|
||||||
} else {
|
|
||||||
window.__TAURI__.app.exit(0);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
|
} catch (err) { stext.textContent = "no tauri bridge: " + err; }
|
||||||
|
|
||||||
|
|
@ -275,7 +318,7 @@ try {
|
||||||
let frLast = performance.now(), frLines = 0, frBatch = [];
|
let frLast = performance.now(), frLines = 0, frBatch = [];
|
||||||
function frFlush() {
|
function frFlush() {
|
||||||
if (frBatch.length === 0) return;
|
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));
|
" scrollY=" + Math.round(window.scrollY));
|
||||||
const payload = frBatch.join("\n") + "\n";
|
const payload = frBatch.join("\n") + "\n";
|
||||||
frBatch = [];
|
frBatch = [];
|
||||||
|
|
@ -301,10 +344,8 @@ function fitScale() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function vApply() {
|
function vApply() {
|
||||||
// Clamp: never zoom out past fit, and pan is limited to the image
|
// Clamp: never zoom out past fit; pan limited to image edges — no
|
||||||
// edges — the image can never be dragged off-screen leaving blank
|
// blank space around the image, letterboxed axes stay centered.
|
||||||
// space around it. On a letterboxed axis (image smaller than the
|
|
||||||
// viewport in that dimension) panning is locked to centered.
|
|
||||||
const s = fitScale();
|
const s = fitScale();
|
||||||
if (vScale < s) vScale = s;
|
if (vScale < s) vScale = s;
|
||||||
const mx = Math.max(0, (base.w * vScale - innerWidth) / 2);
|
const mx = Math.max(0, (base.w * vScale - innerWidth) / 2);
|
||||||
|
|
@ -327,13 +368,13 @@ function fitViewer(w, h) {
|
||||||
vApply();
|
vApply();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch url and, if it is REAL image bytes (not the 1x1 placeholder),
|
/// Fetch url; promote to the viewer only if REAL image bytes (not the
|
||||||
/// promote it into the viewer.
|
/// 1x1 placeholder).
|
||||||
function tryShow(id, url, isFull) {
|
function tryShow(id, url, isFull) {
|
||||||
const im = new Image();
|
const im = new Image();
|
||||||
im.addEventListener("load", () => {
|
im.addEventListener("load", () => {
|
||||||
if (viewerId !== id) return;
|
if (viewerId !== id) return;
|
||||||
if (im.naturalWidth <= 2) return; // placeholder — wait for the ready event
|
if (im.naturalWidth <= 2) return;
|
||||||
vimg.src = im.src;
|
vimg.src = im.src;
|
||||||
fitViewer(im.naturalWidth, im.naturalHeight);
|
fitViewer(im.naturalWidth, im.naturalHeight);
|
||||||
if (isFull) {
|
if (isFull) {
|
||||||
|
|
@ -350,11 +391,10 @@ function openViewer(id) {
|
||||||
vScale = 1; vTx = 0; vTy = 0;
|
vScale = 1; vTx = 0; vTy = 0;
|
||||||
vload.textContent = "loading…";
|
vload.textContent = "loading…";
|
||||||
vimg.style.visibility = "hidden";
|
vimg.style.visibility = "hidden";
|
||||||
// 1) INSTANT: the grid thumb (served from the Rust mem cache, ~1 ms).
|
// 1) INSTANT: the grid thumb (Rust mem cache, ~1 ms).
|
||||||
// Same aspect ratio as the full image — the fit stays valid.
|
|
||||||
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
|
tryShow(id, "http://gallery.localhost/thumb/" + id, false);
|
||||||
// 2) Full-res: Rust reads the file OFF the main thread; the first
|
// 2) Full-res: Rust reads the file OFF the main thread; first request
|
||||||
// request gets the placeholder, `full-ready` triggers the swap.
|
// gets the placeholder, `full-ready` triggers the swap.
|
||||||
tryShow(id, "http://gallery.localhost/full/" + id, true);
|
tryShow(id, "http://gallery.localhost/full/" + id, true);
|
||||||
// 3) Safety net in case the ready event is ever lost.
|
// 3) Safety net in case the ready event is ever lost.
|
||||||
if (vFullTimer) clearInterval(vFullTimer);
|
if (vFullTimer) clearInterval(vFullTimer);
|
||||||
|
|
@ -365,7 +405,6 @@ function openViewer(id) {
|
||||||
}
|
}
|
||||||
if (++vFullSeq > 10) {
|
if (++vFullSeq > 10) {
|
||||||
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
|
if (vFullTimer) { clearInterval(vFullTimer); vFullTimer = null; }
|
||||||
// Corrupt/unreadable file: stop saying "loading…" forever.
|
|
||||||
if (viewerId === id && vimg.naturalWidth <= 2) {
|
if (viewerId === id && vimg.naturalWidth <= 2) {
|
||||||
vload.textContent = "image unavailable";
|
vload.textContent = "image unavailable";
|
||||||
}
|
}
|
||||||
|
|
@ -386,8 +425,8 @@ function closeViewer() {
|
||||||
document.getElementById("vclose").addEventListener("click", closeViewer);
|
document.getElementById("vclose").addEventListener("click", closeViewer);
|
||||||
addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); });
|
addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); });
|
||||||
|
|
||||||
/* pointer-based pan + pinch (touch-action:none on #viewer keeps the browser
|
/* pointer-based pan + pinch (touch-action:none keeps the browser from
|
||||||
from stealing the gesture) */
|
stealing the gesture) */
|
||||||
const pts = new Map();
|
const pts = new Map();
|
||||||
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
|
let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
|
||||||
viewer.addEventListener("pointerdown", (e) => {
|
viewer.addEventListener("pointerdown", (e) => {
|
||||||
|
|
@ -415,8 +454,7 @@ viewer.addEventListener("pointermove", (e) => {
|
||||||
// The image follows the hand (midpoint drift)...
|
// The image follows the hand (midpoint drift)...
|
||||||
vTx += m.x - pinchMid.x;
|
vTx += m.x - pinchMid.x;
|
||||||
vTy += m.y - pinchMid.y;
|
vTy += m.y - pinchMid.y;
|
||||||
// ...and zooms ABOUT the midpoint: the image point under the
|
// ...and zooms ABOUT the midpoint.
|
||||||
// midpoint stays put while the scale changes.
|
|
||||||
if (pinchDist > 0 && d > 0) {
|
if (pinchDist > 0 && d > 0) {
|
||||||
const ratio = d / pinchDist;
|
const ratio = d / pinchDist;
|
||||||
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
|
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
|
||||||
|
|
@ -426,7 +464,7 @@ viewer.addEventListener("pointermove", (e) => {
|
||||||
}
|
}
|
||||||
pinchMid = m;
|
pinchMid = m;
|
||||||
pinchDist = d;
|
pinchDist = d;
|
||||||
vApply(); // also re-clamps (fit min, image-edge pan limits)
|
vApply();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const endPt = (e) => { pts.delete(e.pointerId); };
|
const endPt = (e) => { pts.delete(e.pointerId); };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
<!-- all-files access: the app scans /sdcard/Pictures + /sdcard/DCIM directly
|
<!-- all-files access: the app scans /sdcard/Pictures + /sdcard/DCIM directly
|
||||||
(same grant the egui gallery already holds for this package). -->
|
(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.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 -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,96 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private var observer: ContentObserver? = null
|
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
|
// Rust side (this app's JNI library, loaded by TauriActivity): wakes
|
||||||
// the file-watcher thread. One JNI call + one channel send — nothing
|
// the file-watcher thread. One JNI call + one channel send — nothing
|
||||||
// heavy ever runs on the main thread.
|
// heavy ever runs on the main thread.
|
||||||
external fun nativeFsChange()
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -27,6 +108,7 @@ class MainActivity : TauriActivity() {
|
||||||
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
nativeFsChange()
|
nativeFsChange()
|
||||||
|
scheduleFsPush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observer = obs
|
observer = obs
|
||||||
|
|
@ -35,10 +117,15 @@ class MainActivity : TauriActivity() {
|
||||||
contentResolver.registerContentObserver(
|
contentResolver.registerContentObserver(
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, obs
|
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() {
|
override fun onDestroy() {
|
||||||
observer?.let { contentResolver.unregisterContentObserver(it) }
|
observer?.let { contentResolver.unregisterContentObserver(it) }
|
||||||
|
fsExecutor.shutdownNow()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,48 @@ pub fn apply(
|
||||||
Ok((inserted, updated, deleted))
|
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.
|
/// Number of rows in the DB.
|
||||||
pub fn count(conn: &mut Connection) -> Result<i64, rusqlite::Error> {
|
pub fn count(conn: &mut Connection) -> Result<i64, rusqlite::Error> {
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get(0))?;
|
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()));
|
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.
|
/// Watcher wake events.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
enum WEvent {
|
enum WEvent {
|
||||||
|
|
@ -933,9 +1011,10 @@ enum WEvent {
|
||||||
static WATCH_TX: LazyLock<Mutex<Option<mpsc::Sender<WEvent>>>> =
|
static WATCH_TX: LazyLock<Mutex<Option<mpsc::Sender<WEvent>>>> =
|
||||||
LazyLock::new(|| Mutex::new(None));
|
LazyLock::new(|| Mutex::new(None));
|
||||||
|
|
||||||
/// Android: called by MainActivity's ContentObserver via JNI.
|
/// Android: called by MainActivity's ContentObserver via JNI (and after
|
||||||
/// Signature: (JNIEnv*, jobject) for an instance method — both
|
/// each MediaStore fast-path payload drop). Signature: (JNIEnv*,
|
||||||
/// ignored; the symbol name is what binds.
|
/// jobject) for an instance method. One channel send — nothing heavy,
|
||||||
|
/// never blocks the main thread.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_wow_gmp_gallery_MainActivity_nativeFsChange(
|
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));
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
while rx.try_recv().is_ok() {}
|
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 next = dir_sig(&scan::roots());
|
||||||
let changed = if baseline {
|
let changed = if baseline {
|
||||||
baseline = false;
|
baseline = false;
|
||||||
|
|
@ -1113,6 +1200,19 @@ pub fn run() {
|
||||||
tr(&s);
|
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
|
// Scroll anchor from JS (throttled to ~7 Hz): `a` = index of the
|
||||||
// first visible grid cell. REPLACES the prefetch window with
|
// first visible grid cell. REPLACES the prefetch window with
|
||||||
// [a, a+60) — the visible band plus ~13 rows ahead. Stale
|
// [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
|
// Warm the first two screens before Chromium's lazy loader gets
|
||||||
// to them (it requests ~30 at once; we'd rather be ahead).
|
// to them (it requests ~30 at once; we'd rather be ahead).
|
||||||
set_prefetch_window(0, 36);
|
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());
|
spawn_scan(handle.clone());
|
||||||
// New photos appear in the grid ~2 s after being written.
|
// New photos appear in the grid ~2 s after being written.
|
||||||
std::thread::spawn(move || watch_files(handle));
|
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",
|
"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.
|
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
|
||||||
pub fn roots() -> Vec<PathBuf> {
|
pub fn roots() -> Vec<PathBuf> {
|
||||||
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
|
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
|
||||||
|
|
@ -163,6 +176,17 @@ fn walk_dir(
|
||||||
Some(e) => e,
|
Some(e) => e,
|
||||||
None => return,
|
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
|
// STALE-LISTING CROSS-CHECK (see walk_stream docs): if the DB holds
|
||||||
// many more files for this directory than the enumeration produced,
|
// many more files for this directory than the enumeration produced,
|
||||||
// re-read the directory a few times and keep the best listing.
|
// 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 {
|
for ent in entries {
|
||||||
let p = ent.path();
|
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() {
|
if ft.is_dir() {
|
||||||
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
|
// 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('.')) {
|
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()) {
|
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
|
||||||
continue;
|
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
|
let mtime = md
|
||||||
.modified()
|
.modified()
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -237,10 +268,21 @@ fn walk_dir(
|
||||||
mtime,
|
mtime,
|
||||||
size: md.len() as i64,
|
size: md.len() as i64,
|
||||||
});
|
});
|
||||||
|
n_pushed += 1;
|
||||||
if out.len() % 512 == 0 {
|
if out.len() % 512 == 0 {
|
||||||
on_batch(out);
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user