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 + ")" : "");
|
(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); };
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user