diff --git a/harnesses/gtauri/frontend/dist/index.html b/harnesses/gtauri/frontend/dist/index.html index 1750e0b..a05bff7 100644 --- a/harnesses/gtauri/frontend/dist/index.html +++ b/harnesses/gtauri/frontend/dist/index.html @@ -103,12 +103,20 @@ function statusLine() { (loaded < total ? " (" + loaded + ")" : ""); } +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]; + } grid.replaceChildren(); imgById.clear(); const frag = document.createDocumentFragment(); @@ -147,8 +155,15 @@ function rebuild(ids, isScanning, found) { frag.appendChild(c); } grid.appendChild(frag); + lastIds = ids.slice(); statusLine(); 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 -------------- @@ -362,14 +377,14 @@ addEventListener("keydown", (e) => { if (e.key === "Escape") closeViewer(); }); /* pointer-based pan + pinch (touch-action:none on #viewer keeps the browser from stealing the gesture) */ const pts = new Map(); -let pinchD0 = 0, pinchS0 = 1; +let pinchMid = { x: 0, y: 0 }, pinchDist = 0; viewer.addEventListener("pointerdown", (e) => { viewer.setPointerCapture(e.pointerId); pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 2) { const [a, b] = [...pts.values()]; - pinchD0 = Math.hypot(a.x - b.x, a.y - b.y); - pinchS0 = vScale; + pinchMid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + pinchDist = Math.hypot(a.x - b.x, a.y - b.y); } }); viewer.addEventListener("pointermove", (e) => { @@ -383,9 +398,23 @@ viewer.addEventListener("pointermove", (e) => { } else if (pts.size === 2) { p.x = e.clientX; p.y = e.clientY; const [a, b] = [...pts.values()]; + const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const d = Math.hypot(a.x - b.x, a.y - b.y); - if (pinchD0 > 0) vScale = Math.max(fitScale(), pinchS0 * d / pinchD0); - vApply(); + // 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. + 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); }; diff --git a/harnesses/gtauri/src/lib.rs b/harnesses/gtauri/src/lib.rs index 804121e..6e792d5 100644 --- a/harnesses/gtauri/src/lib.rs +++ b/harnesses/gtauri/src/lib.rs @@ -161,9 +161,26 @@ const STALE_ROWS: u32 = 60; const Q_CAP: usize = 600; /// id → display-order index (rebuilt on every emit_files). static ID_TO_IDX: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); -/// ids whose file cannot be decoded by ANY decoder (corrupt/mislabeled -/// garbage) — serve the placeholder forever instead of retry loops. -static BROKEN: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); +/// ids with decode/read failures: id -> consecutive failure count. +/// STAGED corruption: with live watching, a photo still being written +/// (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>> = 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 /// cold misses; JS swaps in the real bytes when the worker finishes. @@ -307,6 +324,7 @@ fn decode_worker() { if job.full { match std::fs::read(&meta.path) { Ok(bytes) => { + note_ok(id); FULL_MEM.lock().unwrap().put(id, bytes); tr(&format!("full {id} done {:.0}ms", t0.elapsed().as_secs_f64())); if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { @@ -315,8 +333,8 @@ fn decode_worker() { } Err(e) => { log::warn!("full {id}: {e}"); - BROKEN.lock().unwrap().insert(id); - tr(&format!("full {id} BROKEN: {e}")); + note_fail(id); + 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() { let _ = app.emit("full-ready", serde_json::json!({ "id": id })); } @@ -325,6 +343,7 @@ fn decode_worker() { } else { match decode_and_cache(&meta) { Ok(jpeg) => { + note_ok(id); mem_put(id, jpeg); tr(&format!("decode {id} done {:.0}ms", t0.elapsed().as_secs_f64())); if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { @@ -333,8 +352,8 @@ fn decode_worker() { } Err(e) => { log::warn!("decode {id}: {e}"); - BROKEN.lock().unwrap().insert(id); - tr(&format!("decode {id} BROKEN: {e}")); + note_fail(id); + 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() { let _ = app.emit("thumb-ready", serde_json::json!({ "id": id })); } @@ -354,10 +373,10 @@ fn heartbeat() { loop { let qlen = Q.lock().unwrap().len(); let memkb = MEM.lock().unwrap().bytes / 1024; + let broken = FAILS.lock().unwrap().values().filter(|n| **n >= BROKEN_AFTER).count(); tr(&format!( - "hb n={n} q={qlen} mem={}KB broken={}", + "hb n={n} q={qlen} mem={}KB broken={broken}", memkb, - BROKEN.lock().unwrap().len() )); n += 1; std::thread::sleep(std::time::Duration::from_secs(2)); @@ -530,8 +549,10 @@ fn serve_thumb(id: i64) -> Result, String> { return Ok(b); } } - // Corrupt file: placeholder forever, no retry loop. - if BROKEN.lock().unwrap().contains(&id) { + // Confirmed corrupt (BROKEN_AFTER consecutive failures): placeholder + // 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")); return Ok(PLACEHOLDER.as_slice().to_vec()); } @@ -566,7 +587,7 @@ fn serve_full(id: i64) -> Result<(Vec, &'static str), String> { "gif" => "image/gif", _ => "image/jpeg", }; - if BROKEN.lock().unwrap().contains(&id) { + if is_broken(id) { return Ok((PLACEHOLDER.as_slice().to_vec(), "image/jpeg")); } if let Some(b) = FULL_MEM.lock().unwrap().get(id) { @@ -691,36 +712,109 @@ fn load_meta(conn: &mut db::Connection) -> Result<(), rusqlite::Error> { Ok(()) } -fn spawn_scan(app: tauri::AppHandle) { - std::thread::spawn(move || { - let roots = scan::roots(); - let last_emit = std::cell::Cell::new(std::time::Instant::now()); - let entries = scan::walk_stream(&roots, &|batch| { - if last_emit.get().elapsed() < std::time::Duration::from_millis(500) { - return; - } - last_emit.set(std::time::Instant::now()); - emit_files(&app, true, batch.len()); - }); - // Persist + refresh meta. - let mut conn = match db::open(&db::db_path()) { - Ok(c) => c, - Err(e) => { - log::error!("db open: {e}"); - return; - } - }; - if let Err(e) = db::apply(&mut conn, &entries) { - log::error!("db apply: {e}"); +/// Incremental rescan: walk the roots, diff against the DB (insert +/// 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 last_emit = std::cell::Cell::new(std::time::Instant::now()); + let entries = scan::walk_stream(&roots, &|batch| { + if last_emit.get().elapsed() < std::time::Duration::from_millis(500) { return; } - if let Err(e) = load_meta(&mut conn) { - log::error!("load meta: {e}"); - return; - } - emit_files(&app, false, entries.len()); - log::info!("scan complete: {} files", entries.len()); + last_emit.set(std::time::Instant::now()); + emit_files(app, true, batch.len()); }); + let mut conn = match db::open(&db::db_path()) { + Ok(c) => c, + Err(e) => { + log::warn!("rescan db: {e}"); + return; + } + }; + if let Err(e) = db::apply(&mut conn, &entries) { + log::warn!("rescan apply: {e}"); + return; + } + if let Err(e) = load_meta(&mut conn) { + log::warn!("rescan meta: {e}"); + return; + } + emit_files(app, false, 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 { + let mut out = HashMap::new(); + let mut stack: Vec = 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 = 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 // to them (it requests ~30 at once; we'd rather be ahead). 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(()) })