diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3f4ebc9..7162abc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -563,6 +563,36 @@ pub async fn connect_and_login( &update.state, DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled ); + // Mirror every update for the debug overlay's + // QUEUE section — these transfers are otherwise + // invisible by design. + let state_label = match &update.state { + DS::Connecting => "connecting".to_string(), + DS::Requested => "requested".to_string(), + DS::Queued { .. } => "queued".to_string(), + DS::Transferring => "transferring".to_string(), + DS::Complete { .. } => "done".to_string(), + DS::Failed { reason } => format!("failed: {reason}"), + DS::Cancelled => "cancelled".to_string(), + }; + let place = match &update.state { + DS::Queued { place } => *place, + _ => None, + }; + crate::diagnostics::note_preload( + &event_app, + update.id, + crate::diagnostics::PreloadDl { + username: update.username.clone(), + path: update.filename.clone(), + state: state_label, + place, + received: update.received, + total: update.total, + speed_bps: update.speed_bps, + }, + terminal, + ); if terminal { let cache = ps.preloads.lock().unwrap().remove(&update.id); let save_cache = @@ -3829,6 +3859,9 @@ pub async fn play_stream( .map(|(id, _)| *id); if let Some(id) = preload_id { players.preloads.lock().unwrap().remove(&id); + // From here the transfer is the CURRENT track's stream; the overlay's + // DL line (note_stream below) reports it, not the on-deck list. + crate::diagnostics::retire_preload(&app, id); let progress = player::StreamProgress::new(); if let Ok(md) = std::fs::metadata(&partial) { progress.update(md.len(), false, None); @@ -4252,26 +4285,7 @@ pub async fn album_art( let Some(edge) = max_edge else { return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None)); }; - let target = probe_dir.and_then(|dir| { - let meta = std::fs::metadata(&probe_path).ok()?; - let mtime = meta - .modified() - .ok()? - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_nanos(); - let mut hash: u64 = 0xcbf29ce484222325; - for b in probe_path - .as_bytes() - .iter() - .chain(mtime.to_le_bytes().iter()) - .chain(meta.len().to_le_bytes().iter()) - { - hash ^= u64::from(*b); - hash = hash.wrapping_mul(0x100000001b3); - } - Some(dir.join(format!("{hash:016x}-{edge}.jpg"))) - }); + let target = probe_dir.and_then(|dir| art_thumb_target(&dir, &probe_path, edge)); if let Some(t) = &target { if let Ok(cached) = std::fs::read(t) { return Ok((cached, None)); @@ -4324,6 +4338,133 @@ pub async fn album_art( /// requests from ever owning more than two cores. static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); +/// The disk-cache file for one (source, edge) thumbnail, keyed on +/// (path, mtime, len, edge) so a replaced cover self-invalidates. ONE +/// implementation on purpose: `album_art` reads through this key and +/// `library_art_prewarm` writes through it — if they computed it separately +/// and drifted, the warm work would be invisible to the tiles it was done +/// for. None when the source file cannot be statted. +fn art_thumb_target(dir: &std::path::Path, src: &str, edge: u32) -> Option { + let meta = std::fs::metadata(src).ok()?; + let mtime = meta + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(); + let mut hash: u64 = 0xcbf29ce484222325; + for b in src + .as_bytes() + .iter() + .chain(mtime.to_le_bytes().iter()) + .chain(meta.len().to_le_bytes().iter()) + { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x100000001b3); + } + Some(dir.join(format!("{hash:016x}-{edge}.jpg"))) +} + +/// Make the thumbnail cache hold every library cover (and artist portrait) at +/// each requested edge, so the Library's first-ever scroll is a disk read +/// instead of a master decode — [{warm, decoded}] per edge, in the order +/// given, so the caller puts the size it paints first. +/// +/// Already-warm entries cost one stat each. Cold ones decode ONE at a time, +/// through the same 2-permit gate the tiles use — a background warm can never +/// own more than one core, and an on-screen tile waits behind at most one +/// decode. Edges walk outermost so the first edge finishes across the whole +/// library before the next begins. +#[tauri::command] +pub async fn library_art_prewarm( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + edges: Vec, +) -> Result, String> { + let Some(thumb_dir) = app + .path() + .app_cache_dir() + .ok() + .map(|d| d.join("art-thumbs-v1")) + else { + return Ok(vec![]); + }; + let l = lib.library.clone(); + let albums = + tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())??; + let mut paths: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for a in &albums { + // Portraits too: the shelf heads load through the same tile path. + for p in [a.art_path.as_ref(), a.artist_art_path.as_ref()] + .into_iter() + .flatten() + { + if seen.insert(p.clone()) { + paths.push(p.clone()); + } + } + } + let mut out = Vec::new(); + for edge in edges { + if edge == 0 { + out.push((0, 0)); + continue; + } + // One pass of cheap stats finds the cold set; the warm majority never + // touches a permit (the same shape as album_art's phase 1). + let dir = thumb_dir.clone(); + let batch = paths.clone(); + let (warm, cold) = tauri::async_runtime::spawn_blocking(move || { + let mut warm = 0usize; + let mut cold: Vec<(String, std::path::PathBuf)> = Vec::new(); + for path in &batch { + match art_thumb_target(&dir, path, edge) { + Some(t) if t.exists() => warm += 1, + Some(t) => cold.push((path.clone(), t)), + None => {} + } + } + (warm, cold) + }) + .await + .map_err(|e| e.to_string())?; + let mut decoded = 0usize; + for (path, target) in cold { + let permit = ART_DECODE_SLOTS + .acquire() + .await + .map_err(|e| e.to_string())?; + let ok = tauri::async_runtime::spawn_blocking(move || { + let Ok(bytes) = std::fs::read(&path) else { + return false; + }; + // Same fallback as album_art: an undecodable or already-small + // source caches its original bytes, so the discovery is paid + // once there too. + let out = match pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + if let Some(dir) = target.parent() { + let _ = std::fs::create_dir_all(dir); + } + std::fs::write(&target, &out).is_ok() + }) + .await + .map_err(|e| e.to_string())?; + drop(permit); + if ok { + decoded += 1; + } + } + out.push((warm, decoded)); + } + Ok(out) +} + /// The cover a streamed track carries INSIDE its own bytes, extracted to a /// file the ordinary art path can read. /// diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 2bd4039..f7120a4 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -6,6 +6,7 @@ //! Process figures aggregate the WHOLE process tree: the WebView2 children //! do the actual rendering, so app.exe alone would tell a comforting lie. +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -27,6 +28,48 @@ struct Inner { /// StreamProgress — the overlay must read download truth at the source, /// not the UI store's window-activity-gated mirror. current_stream: Option<(u64, Arc)>, + /// On-deck pre-buffer transfers by transfer id, mirrored from the + /// download event stream (those updates are otherwise swallowed — no + /// player, no Downloads view). The Instant is set when the transfer + /// reaches a terminal state, so finished rows linger long enough to be + /// seen and then fall off. + preloads: HashMap)>, +} + +/// One on-deck transfer as the overlay sees it. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PreloadDl { + pub username: String, + /// Full remote path — the frontend matches queue rows by this. + pub path: String, + pub state: String, + /// Remote queue position, when the peer reported one. + pub place: Option, + pub received: u64, + pub total: u64, + pub speed_bps: u64, +} + +/// Called from the download event stream for every preload update. +pub fn note_preload(app: &tauri::AppHandle, id: u64, dl: PreloadDl, terminal: bool) { + if let Some(state) = app.try_state::() { + let finished = if terminal { Some(Instant::now()) } else { None }; + state + .0 + .lock() + .expect("diag lock") + .preloads + .insert(id, (dl, finished)); + } +} + +/// Called when a play path takes the transfer over: it is the CURRENT +/// track's stream from here on (note_stream reports it), not an on-deck row. +pub fn retire_preload(app: &tauri::AppHandle, id: u64) { + if let Some(state) = app.try_state::() { + state.0.lock().expect("diag lock").preloads.remove(&id); + } } /// Called by the play paths: this track streams from a peer. @@ -51,6 +94,7 @@ pub fn init(app: &tauri::AppHandle) { gpu: GpuCounter::open(), started: Instant::now(), current_stream: None, + preloads: HashMap::new(), }))); } @@ -98,6 +142,9 @@ pub struct DebugStats { pub preview_volume: Option, /// The active preview clip's loudness-normalization gain in dB. pub preview_norm_db: Option, + /// On-deck pre-buffer transfers, in arrival order (finished rows linger + /// ~15s so a completion is visible before it drops off). + pub preload_dl: Vec, } #[tauri::command] @@ -167,6 +214,18 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS let tree_u32: Vec = tree.iter().map(|p| p.as_u32()).collect(); let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32)); + // On-deck transfers: drop terminal rows after they've been visible a + // while, then report the rest in arrival (transfer-id) order. + inner + .preloads + .retain(|_, v| v.1.is_none_or(|t| t.elapsed().as_secs() < 15)); + let mut preload_ids: Vec = inner.preloads.keys().copied().collect(); + preload_ids.sort_unstable(); + let preload_dl: Vec = preload_ids + .iter() + .filter_map(|id| inner.preloads.get(id).map(|(dl, _)| dl.clone())) + .collect(); + DebugStats { cpu_process, cpu_system: inner.sys.global_cpu_usage(), @@ -201,6 +260,7 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS main_fade_gain: app .try_state::() .map(|p| p.player.fade_gain()), + preload_dl, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6522712..8054e30 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -88,6 +88,7 @@ pub fn run() { commands::library_album_stats, commands::album_tracks, commands::album_art, + commands::library_art_prewarm, commands::stream_embedded_art, commands::refresh_album_art, commands::refresh_all_album_art, diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 7b2c77c..1831649 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -1,15 +1,22 @@ @@ -163,6 +274,36 @@
DL — no active stream
{/if} +
+
+ QUEUE + {#if player.queue.length} + {player.queueIndex + 1}/{player.queue.length} + · on-deck {deck?.indices?.length ?? 0} + {#if player.shuffle}· shuffle{/if} + {#if player.repeat && player.repeat !== "off"}· repeat {player.repeat}{/if} + {:else} + empty + {/if} +
+ {#each queueRows as r} +
+ {r.slot} + {r.name} +
+
{r.status}
+ {#if r.pct != null} +
+ {/if} + {/each} + {#each offWindow as p} + +
+ ↳ {p.state}{p.state === "transferring" ? ` ${mb(p.received)}${p.total ? `/${mb(p.total)}` : ""} MB` : ""} · {base(p.path)} +
+ {/each} +
NORM @@ -302,6 +443,47 @@ .num.hot { color: #ffc46b; } + /* QUEUE rows: name line, then an indented status line (and a mini bar + while bytes are moving) — two short lines read better at 11px than + one truncated long one. */ + .qrow { + display: flex; + gap: 6px; + align-items: baseline; + margin-top: 3px; + } + .qslot { + min-width: 12px; + text-align: right; + color: rgba(215, 224, 234, 0.45); + } + .qslot.cur { + color: #7fd0ff; + } + .qname { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .qstat { + margin-left: 18px; + font-variant-numeric: tabular-nums; + } + .qstat.warn { + color: #ffc46b; + } + .qstat.ok { + color: #7fd0ff; + } + .bar.q { + margin-left: 18px; + } + .qoff { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } /* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the distance between them is the correction still to travel. */ .bar.slew { diff --git a/src/lib/api.js b/src/lib/api.js index 9544b13..3b60458 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -140,6 +140,10 @@ export const openExternal = (url) => invoke("open_external", { url }); // pixels on the long side. Omit it only where true pixels matter (the // fullscreen viewer, the import picker's comparison and loupe). export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge }); +/** Warm the disk thumbnail cache for the whole library at the given edges — + * [{warm, decoded}] per edge. Slow by design (one decode at a time); callers + * fire-and-forget it. */ +export const libraryArtPrewarm = (edges) => invoke("library_art_prewarm", { edges }); /** The cover embedded in a streamed track's own cache file, extracted to a * local image path — null while the transfer is too young to read, or when * the peer's file simply carries no picture. */ diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 5ee5571..8207b63 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -541,7 +541,11 @@ function gatedArtFetch(fn) { .then(resolve, reject) .finally(() => { artFetchActive -= 1; - const next = artFetchQueue.shift(); + // Newest first: on a fast scroll the queue's tail is the + // tiles on screen RIGHT NOW and its head is the screens + // scrolled past. FIFO served the wake behind the viewer; + // the passed-by tiles still drain once the burst settles. + const next = artFetchQueue.pop(); if (next) next(); }); }; @@ -1176,12 +1180,53 @@ export async function refreshLibrary() { libraryStore.albums = await api.libraryAlbums(); libraryStore.loaded = true; window.__libErr = null; + scheduleArtPrewarm(); } catch (e) { // not connected to backend (browser preview) — leave empty window.__libErr = String(e); } } +// ---- thumbnail pre-warm ---- +// The disk cache makes a REVISITED cover a 1.5ms read, but the first-ever +// visit still decoded on scroll — so a section you had never scrolled to +// showed placeholders while its masters decoded two at a time. Warm the whole +// library once in the background instead (the Library's tile edge first, then +// the queue-card size) and every scroll lands on the warm path. Re-runs are +// ~one stat per cover, so this simply re-fires after every library change to +// pick up new arrivals; the guard coalesces bursts. +let artPrewarmRunning = false; +let artPrewarmAgain = false; +const ART_PREWARM_DELAY_MS = 4000; +let artPrewarmTimer = null; +function scheduleArtPrewarm() { + // Delay past the boot/refresh paint so the first screen's own art + // requests hit the decode permits before the background walk does. + clearTimeout(artPrewarmTimer); + artPrewarmTimer = setTimeout(() => { + artPrewarmTimer = null; + void runArtPrewarm(); + }, ART_PREWARM_DELAY_MS); +} +async function runArtPrewarm() { + if (artPrewarmRunning) { + artPrewarmAgain = true; + return; + } + artPrewarmRunning = true; + try { + await api.libraryArtPrewarm([ART_EDGE_DEFAULT, 96]); + } catch { + // browser preview / old backend — tiles still load on demand + } finally { + artPrewarmRunning = false; + } + if (artPrewarmAgain) { + artPrewarmAgain = false; + scheduleArtPrewarm(); + } +} + // ---- catalog browser (Deezer-backed streaming-style search) ---- export const catalog = $state({ @@ -3274,20 +3319,45 @@ export async function openAlbumByName(artist, title, opts = {}) { if (!artist || !title) return null; const na = catNorm(artist); const nt = catNorm(title); - const key = `${na}|${nt}`; + // A year hint changes which record the same name resolves to (the + // self-titled-trio case), so it is part of the answer's identity — a + // shared key would let one LP's answer poison the others'. + const key = opts.year != null ? `${na}|${nt}|${opts.year}` : `${na}|${nt}`; if (albumNameResolves.has(key)) { const id = albumNameResolves.get(key); if (id != null) openCatalogAlbum(id, opts); return id; } + /** Same-titled rows from a DATED list (the artist's own discography), + * preferring the hinted year. Self-titled multiples (American Football ×3, + * Weezer ×7) all normalize to one title, so without a date the first row + * simply wins — and the first row is the wrong record two times in three. */ + const pickByYear = (albums) => { + const same = (albums ?? []).filter((al) => catNorm(al.title) === nt); + if (!same.length) return null; + if (opts.year != null) { + const dated = same.find((al) => + String(al.release_date ?? "").startsWith(String(opts.year)), + ); + if (dated) return dated; + } + return same[0]; + }; try { const res = await api.catalogSearch(`${artist} ${title}`); if (request !== albumResolveRequest) return null; // A title-only or first-result fallback can cross into a namesake's // catalog. Without a verified id, both artist and title must match. - let match = res.albums?.find( + const searchMatches = (res.albums ?? []).filter( (album) => catNorm(album.artist) === na && catNorm(album.title) === nt, ); + // Deezer's /search/album strips release dates, so a year hint cannot + // be checked here — and a single hit is no proof of the RIGHT record + // when namesake editions exist. A unique, unhinted hit keeps the fast + // path; ambiguity (a hint, or several same-titled hits) falls through + // to the artist's own dated discography below. + let match = + searchMatches.length === 1 && opts.year == null ? searchMatches[0] : null; // Deezer's SEARCH index is not the whole discography. The artist page // merges a MusicBrainz supplement on top of it, so records Deezer does // not carry at all still have a real id, a real release date and full @@ -3303,7 +3373,7 @@ export async function openAlbumByName(artist, title, opts = {}) { if (seed?.id != null) { const page = await getCatalogArtistPage(seed.id).catch(() => null); if (request !== albumResolveRequest) return null; - match = page?.albums?.find((album) => catNorm(album.title) === nt); + match = pickByYear(page?.albums); // The cached page is only SUPPLEMENTED once the artist page // has been visited — cold, it is the raw Deezer list and the // MB-only records are still missing. Run the same merge the @@ -3324,12 +3394,14 @@ export async function openAlbumByName(artist, title, opts = {}) { }, identity, ); - match = merged?.albums?.find( - (album) => catNorm(album.title) === nt, - ); + match = pickByYear(merged?.albums); } } } + // The discography had nothing to say (unresolvable artist, or the + // record missing from it) — a bare search hit is still better than + // a dead click, year-verified or not. + if (!match) match = searchMatches[0] ?? null; } // A settled miss is an answer ABOUT THE RECORD: neither the catalog nor // the artist's own discography has it. Negative-cache that. A throw @@ -6895,6 +6967,22 @@ export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) { return out; } +/** Debug-overlay window into the on-deck machinery. Read-only: the indices + * the pre-buffer is arming, plus the module-private bits a QUEUE readout + * needs — which path is already appended to the sink, whether a path has + * been asked of the backend this session, and whether the lookahead already + * spent its one search try on a still-pending row. The membership checks are + * closures because both containers are reassigned/forgotten on queue + * rebuilds — a copied array would go stale mid-tick. */ +export function onDeckSnapshot() { + return { + indices: peekNextIndices(STREAM_PRELOAD_DEPTH), + appendedNextPath, + isRequested: (path) => preloadRequestedPaths.has(path), + wasLookaheadTried: (item) => (item ? lookaheadTried.has(item) : false), + }; +} + /** The queue index that plays after the current track, or -1 when playback * would stop there. THE one answer for what comes next — the up-next card, * the gapless preload/append, the pending-row lookahead and the advance @@ -7314,6 +7402,7 @@ if (import.meta.env.DEV) { // beyond convenience: the server ignores the handshake after a burst // of reconnects, so a verification run that streams costs a login. playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack, + jumpToQueueIndex, catalogGo, refreshLibrary, refreshRecents, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, playlistStore, refreshPlaylists, goToView, peekNextIndices, diff --git a/src/views/Library.svelte b/src/views/Library.svelte index ecac507..b3f97dd 100644 --- a/src/views/Library.svelte +++ b/src/views/Library.svelte @@ -35,6 +35,7 @@ getCatalogArtistPage, openCatalogArtist, openCatalogAlbum, + openAlbumByName, RECENTS_FLOOR_SECS, recentsStore, importResume, @@ -778,9 +779,19 @@ const wanted = album.id; try { const page = await getCatalogArtistPage(anchored.artistId); - const match = (page?.albums ?? []).find( + // Same-titled records collapse to one normalized key (American + // Football × 3, Weezer × 7). The artist page's rows are dated, so + // the local folder's year picks the right one; only then does the + // first row stand in. + const same = (page?.albums ?? []).filter( (r) => normKey(r.title) === normKey(album.title), ); + const match = + (album.year != null + && same.find((r) => + String(r.release_date ?? "").startsWith(String(album.year)), + )) + || same[0]; if (openId === wanted && match) openCatalogId = match.id; } catch { /* no link; the title stays plain */ } } @@ -1610,17 +1621,24 @@ {/if} + on the artist page. An anchored (followed) + artist's join gives the id up front; otherwise + the click resolves through openAlbumByName — + the now-playing links' guarded resolver, which + verifies artist AND title (never guesses) and + stays put on a settled miss. Plain text only + when the row has no artist+title to resolve. -->

- {#if openCatalogId != null} + {#if openCatalogId != null || (openAlbum?.artist && openAlbum?.title)} {:else} {openAlbum?.title ?? ""}