player bar: the name sets the width, and the waveform stops flattering the clamp

The source chips are gone — the rail already tells that story — and with
them the measured slot, its effects, and the bar's only container query.
The now-playing cluster hugs the song name with no cap, giving ground only
once the scrubber hits its 260px floor; the artist's 55% ceiling applies
only when an album sits beside it, so a lone loading artist no longer
renders as two letters in a twelve-pixel box.

The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms
per bucket — at 400, every display bar was ~2s of audio averaged twice),
and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp
rule, which flat-topped 53.8% of the bars on a real decode and was propped
up by per-bar noise in the rail — deleted; the lane shows the record, not a
hash of the bar index. Bars fold with a mean+peak blend and stretch across
the track's own p05–p97, so a loud master stops pinning to the ceiling and
a quiet one stops vanishing.

The playbar copies the rail's fade verbatim: whole bars blend accent→grey
with cuts landing in the 4px gap, and the one bar under the playhead
crossfades toward whatever its neighbours are — the wash while hovering,
the muted grey otherwise. The redraw gate watches that blend instead of a
0.75px playhead move, so both lanes now step on every position event.

Loading is drawn, not implied: the solid fallback bar is gone and the lane
is always the bars, always seekable. Empty, it rests as the rail's dots; a
swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a
streaming track's bars stand up left→right on a 40ms cadence, each rising
over 2s. The rise frontier is the waveform's OWN coverage, reported by
stream_waveform beside the peaks — the old contract zero-padded partial
peaks to full length and let the UI fold them against a fresher byte
count, which stretched the whole shape between refreshes. Where the bars
end is how much of the track has actually loaded; cached and local tracks
arrive at coverage 1 and pop in complete. The first partial is also
requested eagerly (fresh refresh clock per track, 700ms retries until
peaks exist), closing most of the audio-before-lane gap.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:08:26 -05:00
1 parent 3aaec30dc6
commit b33d403634
9 files changed
+740 -323

No files matched your search

+48 -10
View File
@@ -3807,7 +3807,7 @@ pub async fn stream_waveform(
path: String,
size: u64,
buckets: usize,
) -> Result<Vec<f32>, String> {
) -> Result<WaveformSlice, String> {
const MAX_WAVEFORM_BUCKETS: usize = 4096;
if buckets > MAX_WAVEFORM_BUCKETS {
return Err(format!(
@@ -3815,10 +3815,10 @@ pub async fn stream_waveform(
));
}
if buckets == 0 {
return Ok(Vec::new());
return Ok(WaveformSlice::empty());
}
let Some(cache) = playback::cache_path(&app, &username, &path) else {
return Ok(Vec::new());
return Ok(WaveformSlice::empty());
};
let key = format!("{}#{buckets}", cache.display());
let job = players.waveform_job(&key);
@@ -3830,7 +3830,7 @@ pub async fn stream_waveform(
if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) {
players.remove_progressive_waveform(&key);
if let Some(cached) = players.cached_waveform(&key) {
return Ok(cached);
return Ok(WaveformSlice::complete(cached));
}
let p = cache.clone();
@@ -3844,7 +3844,7 @@ pub async fn stream_waveform(
players.cache_waveform(key.clone(), peaks.clone());
}
drop(job_guard);
return Ok(peaks);
return Ok(WaveformSlice::complete(peaks));
}
// Seeing a partial file invalidates any completed waveform retained
@@ -3853,11 +3853,14 @@ pub async fn stream_waveform(
let partial = playback::partial_path(&cache);
let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
if have == 0 || size == 0 {
return Ok(Vec::new());
return Ok(WaveformSlice::empty());
}
if let Some(previous) = players.progressive_waveform(&key) {
if !previous.should_recompute(have) {
return Ok(previous.peaks());
return Ok(WaveformSlice {
coverage: (previous.decoded_bytes() as f64 / size as f64).min(1.0) as f32,
peaks: previous.peaks(),
});
}
}
@@ -3867,27 +3870,62 @@ pub async fn stream_waveform(
// three seconds.
let frac = (have as f64 / size as f64).min(1.0);
let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets);
let (job_guard, mut peaks) = tauri::async_runtime::spawn_blocking(move || {
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
let peaks = player::compute_waveform(&partial, n);
(job_guard, peaks)
})
.await
.map_err(|e| e.to_string())?;
if !peaks.is_empty() {
peaks.resize(buckets, 0.0);
players.cache_progressive_waveform(
key.clone(),
playback::ProgressiveWaveform::new(have, size, peaks.clone()),
);
}
drop(job_guard);
Ok(peaks)
// The peaks are ONLY the decoded audio — no zero padding to the full
// bucket count any more. The old padding made the array's length lie
// about its contents: the UI folded real-peaks-plus-zero-tail across a
// frontier computed from a FRESHER byte count, so the drawn shape
// stretched between refreshes and the loudness scale ate the zeros.
// Coverage says exactly how much of the track these peaks describe;
// where the standing bars end IS how much of the track has loaded.
Ok(WaveformSlice {
coverage: frac as f32,
peaks,
})
}
.await;
players.release_waveform_job(&key, &job);
result
}
/// A waveform plus how much of the track it actually describes. `coverage`
/// is the byte share of the transfer the peaks were decoded from (1.0 for a
/// complete file); the peaks span exactly that prefix of the track and
/// nothing more.
#[derive(Clone, serde::Serialize)]
pub struct WaveformSlice {
coverage: f32,
peaks: Vec<f32>,
}
impl WaveformSlice {
fn empty() -> Self {
WaveformSlice {
coverage: 0.0,
peaks: Vec::new(),
}
}
fn complete(peaks: Vec<f32>) -> Self {
WaveformSlice {
coverage: 1.0,
peaks,
}
}
}
/// Play an already-downloaded local file.
#[tauri::command]
pub fn play_local(