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:
parent
8f700e7f66
commit
312d87f15d
39
harnesses/gtauri/frontend/dist/index.html
vendored
39
harnesses/gtauri/frontend/dist/index.html
vendored
|
|
@ -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); };
|
||||
|
|
|
|||
|
|
@ -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<RwLock<HashMap<i64, u32>>> = 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<Mutex<HashSet<i64>>> = 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<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
|
||||
/// 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<Vec<u8>, 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<u8>, &'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<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
|
||||
// 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(())
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user