gtauri: pinch zoom about pointer midpoint + live filesystem watching

Pinch: the image now follows the hand (midpoint drift) and zooms
ABOUT the midpoint — the image point under the fingers stays put
while the scale changes. Per-frame delta math, clamped as before
(fit min, image-edge pan limits).

Live watching: new photos appear in the grid within ~2s of being
written. inotify does not work on Android's FUSE-backed external
storage, so the watcher polls a tree signature (dir mtimes only —
a few dozen stats/s) every second; on change (or a 30s safety-net
full pass) it debounces 600ms for the write burst, then runs the
incremental rescan (walk + DB diff + emit) off the main thread.

Supporting changes:
- Staged corruption: a decode failure no longer marks an id broken
  immediately — 3 consecutive failures are required, and a success
  clears the count. A photo still being written fails once or twice
  and then decodes; genuine garbage gives up and stays gray.
- Grid rebuild preserves the viewport across a live rescan (pins to
  the cell that was on top), so a new photo landing at the top does
  not jump the scroll position.
This commit is contained in:
Greg Pomerantz 2026-11-01 18:02:09 -05:00
parent 8f700e7f66
commit 312d87f15d
2 changed files with 170 additions and 45 deletions

View File

@ -103,12 +103,20 @@ function statusLine() {
(loaded < total ? " (" + loaded + ")" : ""); (loaded < total ? " (" + loaded + ")" : "");
} }
let lastIds = [];
function rebuild(ids, isScanning, found) { function rebuild(ids, isScanning, found) {
const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0); const sig = ids.length + ":" + (ids[0] ?? 0) + ":" + (ids[ids.length - 1] ?? 0);
if (sig === lastSig && scanning === isScanning) return; if (sig === lastSig && scanning === isScanning) return;
lastSig = sig; lastSig = sig;
scanning = isScanning; scanning = isScanning;
total = ids.length; loaded = 0; total = ids.length; loaded = 0;
// Live rescan (a photo just landed) inserts at the top and would
// shift everything — pin the viewport to the cell that was on top.
let keepId = null;
if (lastIds.length > 0 && rowH > 0) {
const topIdx = Math.min(lastIds.length - 1, Math.floor(window.scrollY / rowH) * 3);
keepId = lastIds[topIdx];
}
grid.replaceChildren(); grid.replaceChildren();
imgById.clear(); imgById.clear();
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
@ -147,8 +155,15 @@ function rebuild(ids, isScanning, found) {
frag.appendChild(c); frag.appendChild(c);
} }
grid.appendChild(frag); grid.appendChild(frag);
lastIds = ids.slice();
statusLine(); statusLine();
eagerBand(); eagerBand();
if (keepId !== null) {
const i = ids.indexOf(keepId);
if (i >= 0) {
requestAnimationFrame(() => scrollTo(0, Math.floor(i / 3) * rowH));
}
}
} }
/* -------- scroll anchor for Rust-side thumbnail prefetch -------------- /* -------- scroll anchor for Rust-side thumbnail prefetch --------------
@ -362,14 +377,14 @@ 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 on #viewer keeps the browser
from stealing the gesture) */ from stealing the gesture) */
const pts = new Map(); const pts = new Map();
let pinchD0 = 0, pinchS0 = 1; let pinchMid = { x: 0, y: 0 }, pinchDist = 0;
viewer.addEventListener("pointerdown", (e) => { viewer.addEventListener("pointerdown", (e) => {
viewer.setPointerCapture(e.pointerId); viewer.setPointerCapture(e.pointerId);
pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) { if (pts.size === 2) {
const [a, b] = [...pts.values()]; const [a, b] = [...pts.values()];
pinchD0 = Math.hypot(a.x - b.x, a.y - b.y); pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
pinchS0 = vScale; pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
} }
}); });
viewer.addEventListener("pointermove", (e) => { viewer.addEventListener("pointermove", (e) => {
@ -383,9 +398,23 @@ viewer.addEventListener("pointermove", (e) => {
} else if (pts.size === 2) { } else if (pts.size === 2) {
p.x = e.clientX; p.y = e.clientY; p.x = e.clientX; p.y = e.clientY;
const [a, b] = [...pts.values()]; const [a, b] = [...pts.values()];
const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const d = Math.hypot(a.x - b.x, a.y - b.y); const d = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchD0 > 0) vScale = Math.max(fitScale(), pinchS0 * d / pinchD0); // The image follows the hand (midpoint drift)...
vApply(); vTx += m.x - pinchMid.x;
vTy += m.y - pinchMid.y;
// ...and zooms ABOUT the midpoint: the image point under the
// midpoint stays put while the scale changes.
if (pinchDist > 0 && d > 0) {
const ratio = d / pinchDist;
const cx = innerWidth / 2 + vTx, cy = innerHeight / 2 + vTy;
vTx = m.x - innerWidth / 2 - (m.x - cx) * ratio;
vTy = m.y - innerHeight / 2 - (m.y - cy) * ratio;
vScale *= ratio;
}
pinchMid = m;
pinchDist = d;
vApply(); // also re-clamps (fit min, image-edge pan limits)
} }
}); });
const endPt = (e) => { pts.delete(e.pointerId); }; const endPt = (e) => { pts.delete(e.pointerId); };

View File

@ -161,9 +161,26 @@ const STALE_ROWS: u32 = 60;
const Q_CAP: usize = 600; const Q_CAP: usize = 600;
/// id → display-order index (rebuilt on every emit_files). /// id → display-order index (rebuilt on every emit_files).
static ID_TO_IDX: LazyLock<RwLock<HashMap<i64, u32>>> = LazyLock::new(|| RwLock::new(HashMap::new())); static ID_TO_IDX: LazyLock<RwLock<HashMap<i64, u32>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
/// ids whose file cannot be decoded by ANY decoder (corrupt/mislabeled /// ids with decode/read failures: id -> consecutive failure count.
/// garbage) — serve the placeholder forever instead of retry loops. /// STAGED corruption: with live watching, a photo still being written
static BROKEN: LazyLock<Mutex<HashSet<i64>>> = LazyLock::new(|| Mutex::new(HashSet::new())); /// (camera mid-save) fails one or two times and then decodes fine, so a
/// single failure must NOT be final. Genuine garbage (mislabeled WEBP,
/// 17-byte files, …) keeps failing and gives up after BROKEN_AFTER —
/// placeholder forever, no retry loops. A success clears the count.
const BROKEN_AFTER: u32 = 3;
static FAILS: LazyLock<Mutex<HashMap<i64, u32>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn is_broken(id: i64) -> bool {
FAILS.lock().unwrap().get(&id).is_some_and(|n| *n >= BROKEN_AFTER)
}
fn note_fail(id: i64) {
*FAILS.lock().unwrap().entry(id).or_insert(0) += 1;
}
fn note_ok(id: i64) {
FAILS.lock().unwrap().remove(&id);
}
/// 1x1 JPEG in the cell background color (#1c1c22) — served instantly for /// 1x1 JPEG in the cell background color (#1c1c22) — served instantly for
/// cold misses; JS swaps in the real bytes when the worker finishes. /// cold misses; JS swaps in the real bytes when the worker finishes.
@ -307,6 +324,7 @@ fn decode_worker() {
if job.full { if job.full {
match std::fs::read(&meta.path) { match std::fs::read(&meta.path) {
Ok(bytes) => { Ok(bytes) => {
note_ok(id);
FULL_MEM.lock().unwrap().put(id, bytes); FULL_MEM.lock().unwrap().put(id, bytes);
tr(&format!("full {id} done {:.0}ms", t0.elapsed().as_secs_f64())); tr(&format!("full {id} done {:.0}ms", t0.elapsed().as_secs_f64()));
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
@ -315,8 +333,8 @@ fn decode_worker() {
} }
Err(e) => { Err(e) => {
log::warn!("full {id}: {e}"); log::warn!("full {id}: {e}");
BROKEN.lock().unwrap().insert(id); note_fail(id);
tr(&format!("full {id} BROKEN: {e}")); tr(&format!("full {id} fail {}: {e}", FAILS.lock().unwrap().get(&id).copied().unwrap_or(0)));
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
let _ = app.emit("full-ready", serde_json::json!({ "id": id })); let _ = app.emit("full-ready", serde_json::json!({ "id": id }));
} }
@ -325,6 +343,7 @@ fn decode_worker() {
} else { } else {
match decode_and_cache(&meta) { match decode_and_cache(&meta) {
Ok(jpeg) => { Ok(jpeg) => {
note_ok(id);
mem_put(id, jpeg); mem_put(id, jpeg);
tr(&format!("decode {id} done {:.0}ms", t0.elapsed().as_secs_f64())); tr(&format!("decode {id} done {:.0}ms", t0.elapsed().as_secs_f64()));
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
@ -333,8 +352,8 @@ fn decode_worker() {
} }
Err(e) => { Err(e) => {
log::warn!("decode {id}: {e}"); log::warn!("decode {id}: {e}");
BROKEN.lock().unwrap().insert(id); note_fail(id);
tr(&format!("decode {id} BROKEN: {e}")); tr(&format!("decode {id} fail {}: {e}", FAILS.lock().unwrap().get(&id).copied().unwrap_or(0)));
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
let _ = app.emit("thumb-ready", serde_json::json!({ "id": id })); let _ = app.emit("thumb-ready", serde_json::json!({ "id": id }));
} }
@ -354,10 +373,10 @@ fn heartbeat() {
loop { loop {
let qlen = Q.lock().unwrap().len(); let qlen = Q.lock().unwrap().len();
let memkb = MEM.lock().unwrap().bytes / 1024; let memkb = MEM.lock().unwrap().bytes / 1024;
let broken = FAILS.lock().unwrap().values().filter(|n| **n >= BROKEN_AFTER).count();
tr(&format!( tr(&format!(
"hb n={n} q={qlen} mem={}KB broken={}", "hb n={n} q={qlen} mem={}KB broken={broken}",
memkb, memkb,
BROKEN.lock().unwrap().len()
)); ));
n += 1; n += 1;
std::thread::sleep(std::time::Duration::from_secs(2)); std::thread::sleep(std::time::Duration::from_secs(2));
@ -530,8 +549,10 @@ fn serve_thumb(id: i64) -> Result<Vec<u8>, String> {
return Ok(b); return Ok(b);
} }
} }
// Corrupt file: placeholder forever, no retry loop. // Confirmed corrupt (BROKEN_AFTER consecutive failures): placeholder
if BROKEN.lock().unwrap().contains(&id) { // forever, no retry loop. A file still being written is NOT broken
// yet — it gets queued and retried until it decodes.
if is_broken(id) {
tr(&format!("req {id} broken")); tr(&format!("req {id} broken"));
return Ok(PLACEHOLDER.as_slice().to_vec()); return Ok(PLACEHOLDER.as_slice().to_vec());
} }
@ -566,7 +587,7 @@ fn serve_full(id: i64) -> Result<(Vec<u8>, &'static str), String> {
"gif" => "image/gif", "gif" => "image/gif",
_ => "image/jpeg", _ => "image/jpeg",
}; };
if BROKEN.lock().unwrap().contains(&id) { if is_broken(id) {
return Ok((PLACEHOLDER.as_slice().to_vec(), "image/jpeg")); return Ok((PLACEHOLDER.as_slice().to_vec(), "image/jpeg"));
} }
if let Some(b) = FULL_MEM.lock().unwrap().get(id) { if let Some(b) = FULL_MEM.lock().unwrap().get(id) {
@ -691,8 +712,11 @@ fn load_meta(conn: &mut db::Connection) -> Result<(), rusqlite::Error> {
Ok(()) Ok(())
} }
fn spawn_scan(app: tauri::AppHandle) { /// Incremental rescan: walk the roots, diff against the DB (insert
std::thread::spawn(move || { /// new / update changed / delete removed), refresh META, emit the list.
/// Used by the startup scan AND the live watcher — a new photo appears
/// in the grid seconds after it is written.
fn rescan(app: &tauri::AppHandle) {
let roots = scan::roots(); let roots = scan::roots();
let last_emit = std::cell::Cell::new(std::time::Instant::now()); let last_emit = std::cell::Cell::new(std::time::Instant::now());
let entries = scan::walk_stream(&roots, &|batch| { let entries = scan::walk_stream(&roots, &|batch| {
@ -700,27 +724,97 @@ fn spawn_scan(app: tauri::AppHandle) {
return; return;
} }
last_emit.set(std::time::Instant::now()); last_emit.set(std::time::Instant::now());
emit_files(&app, true, batch.len()); emit_files(app, true, batch.len());
}); });
// Persist + refresh meta.
let mut conn = match db::open(&db::db_path()) { let mut conn = match db::open(&db::db_path()) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
log::error!("db open: {e}"); log::warn!("rescan db: {e}");
return; return;
} }
}; };
if let Err(e) = db::apply(&mut conn, &entries) { if let Err(e) = db::apply(&mut conn, &entries) {
log::error!("db apply: {e}"); log::warn!("rescan apply: {e}");
return; return;
} }
if let Err(e) = load_meta(&mut conn) { if let Err(e) = load_meta(&mut conn) {
log::error!("load meta: {e}"); log::warn!("rescan meta: {e}");
return; return;
} }
emit_files(&app, false, entries.len()); emit_files(app, false, entries.len());
log::info!("scan complete: {} files", entries.len()); log::info!("rescan: {} files", entries.len());
}); }
fn spawn_scan(app: tauri::AppHandle) {
std::thread::spawn(move || rescan(&app));
}
fn mtime_ns(m: &std::fs::Metadata) -> i64 {
m.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0)
}
/// Tree signature: (dir, mtime) for every directory under the roots.
/// Adding/removing a file updates its directory's mtime, so a changed
/// library shows up here; the cost is a few dozen stats — cheap enough
/// to run every second. (inotify does not work on Android's FUSE-backed
/// external storage, which is why this is polling, not events.)
fn tree_sig(roots: &[PathBuf]) -> HashMap<PathBuf, i64> {
let mut out = HashMap::new();
let mut stack: Vec<PathBuf> = roots.iter().filter(|r| r.is_dir()).cloned().collect();
while let Some(dir) = stack.pop() {
let Ok(m) = std::fs::metadata(&dir) else { continue };
out.insert(dir.clone(), mtime_ns(&m));
if let Ok(rd) = std::fs::read_dir(&dir) {
for ent in rd.flatten() {
let p = ent.path();
if p.is_symlink() || !p.is_dir() {
continue;
}
if p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
continue;
}
stack.push(p);
}
}
}
out
}
/// Live file watching: poll the tree signature every second; when any
/// directory changed (or 30 s elapsed as a safety net in case an mtime
/// fails to update), debounce briefly so a write burst settles, then
/// run the incremental rescan. New photos appear in the grid within ~2 s
/// of being written; deleted/renamed ones are picked up on the next tick.
fn watch_files(app: tauri::AppHandle) {
let mut sig: HashMap<PathBuf, i64> = HashMap::new();
let mut last_full = std::time::Instant::now();
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
let roots = scan::roots();
let next = tree_sig(&roots);
let changed = if sig.is_empty() {
sig = next;
false // first pass: just establish the baseline
} else {
let mut ch = next.iter().any(|(d, t)| sig.get(d) != Some(t))
|| sig.keys().any(|d| !next.contains_key(d));
sig = next;
ch
};
if !changed && last_full.elapsed() < std::time::Duration::from_secs(30) {
continue;
}
// Let the write burst (file + temp + sidecars) settle.
std::thread::sleep(std::time::Duration::from_millis(600));
tr("watch: rescan");
rescan(&app);
last_full = std::time::Instant::now();
sig = tree_sig(&scan::roots());
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -817,7 +911,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);
spawn_scan(handle); spawn_scan(handle.clone());
// New photos appear in the grid ~2 s after being written.
std::thread::spawn(move || watch_files(handle));
Ok(()) Ok(())
}) })