forked from Keelrleek/SoulStream
overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them
This commit is contained in:
1 parent
ad2c912b71
commit
6a7a5c49c0
7 files changed
+534
-39
No files matched your search
+161
-20
@@ -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<std::path::PathBuf> {
|
||||
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<u32>,
|
||||
) -> Result<Vec<(usize, usize)>, 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<String> = 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.
|
||||
///
|
||||
|
||||
@@ -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<StreamProgress>)>,
|
||||
/// 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<u64, (PreloadDl, Option<Instant>)>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
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::<DiagState>() {
|
||||
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::<DiagState>() {
|
||||
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<f32>,
|
||||
/// The active preview clip's loudness-normalization gain in dB.
|
||||
pub preview_norm_db: Option<f32>,
|
||||
/// 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<PreloadDl>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -167,6 +214,18 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
||||
let tree_u32: Vec<u32> = 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<u64> = inner.preloads.keys().copied().collect();
|
||||
preload_ids.sort_unstable();
|
||||
let preload_dl: Vec<PreloadDl> = 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::<crate::playback::PlayerState>()
|
||||
.map(|p| p.player.fade_gain()),
|
||||
preload_dl,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user