From b33d403634e1abf0a945b25d5ccac6a6c2dec507 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:08:26 -0500 Subject: [PATCH] player bar: the name sets the width, and the waveform stops flattering the clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/player/Cargo.toml | 2 + crates/player/src/waveform.rs | 90 ++++- src-tauri/src/commands.rs | 58 ++- src-tauri/src/playback.rs | 6 + src/components/NowPlaying.svelte | 137 +++++-- src/components/PlayerBar.svelte | 606 +++++++++++++++++-------------- src/lib/state.svelte.js | 70 +++- src/lib/waveshape.js | 93 +++++ 9 files changed, 740 insertions(+), 323 deletions(-) create mode 100644 src/lib/waveshape.js diff --git a/Cargo.lock b/Cargo.lock index 15b962e..a9561ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3082,6 +3082,7 @@ dependencies = [ "env_logger", "log", "rodio", + "serde_json", "symphonia", "thiserror 2.0.18", ] diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml index e5234f0..1e07e8e 100644 --- a/crates/player/Cargo.toml +++ b/crates/player/Cargo.toml @@ -12,3 +12,5 @@ log.workspace = true [dev-dependencies] env_logger = "0.11" +# Test-only: dumps bucketing variants for the waveform shaping mockup. +serde_json.workspace = true diff --git a/crates/player/src/waveform.rs b/crates/player/src/waveform.rs index 2717cd2..e0fadf9 100644 --- a/crates/player/src/waveform.rs +++ b/crates/player/src/waveform.rs @@ -32,6 +32,15 @@ pub fn compute_waveform(path: &Path, buckets: usize) -> Vec { } fn try_compute(path: &Path, buckets: usize) -> Result, String> { + Ok(resample(&coarse_rms(path)?, buckets)) +} + +/// Decode `path` into one RMS value per [`WINDOW`] frames (~23ms at 44.1kHz). +/// +/// Split out from [`try_compute`] so the bucketing strategy can be measured +/// against a real decode without re-reading the file per variant — see +/// `bucketing_strategies_on_a_real_track`. +fn coarse_rms(path: &Path) -> Result, String> { let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); @@ -111,7 +120,7 @@ fn try_compute(path: &Path, buckets: usize) -> Result, String> { coarse.push((win_energy / win_count as f64).sqrt() as f32); } - Ok(resample(&coarse, buckets)) + Ok(coarse) } /// Downsample `coarse` RMS values to exactly `buckets`, averaging each @@ -152,4 +161,83 @@ mod tests { fn empty_input() { assert_eq!(resample(&[], 5), vec![0.0; 5]); } + + /// How much of the track's dynamics survive the trip to the UI? + /// + /// The lane looked flat no matter what the frontend did with the numbers, + /// so this measures where the range actually goes. It decodes a real file + /// once and re-buckets that single RMS pass four ways, printing the spread + /// of each and dumping them as JSON for the shaping mockup. + /// + /// ```text + /// $env:SOULSTREAM_WF_FILE = "C:\path\to\track.flac" + /// $env:SOULSTREAM_WF_DUMP = "C:\path\to\out.json" + /// cargo test -p player bucketing_strategies -- --ignored --nocapture + /// ``` + #[test] + #[ignore = "decodes a real audio file named by SOULSTREAM_WF_FILE"] + fn bucketing_strategies_on_a_real_track() { + let Ok(file) = std::env::var("SOULSTREAM_WF_FILE") else { + panic!("set SOULSTREAM_WF_FILE to an audio file"); + }; + let coarse = coarse_rms(Path::new(&file)).expect("decode"); + println!( + "{} RMS windows of {WINDOW} frames (~{:.1}s of audio)", + coarse.len(), + coarse.len() as f32 * WINDOW as f32 / 44100.0, + ); + + // Each strategy takes the same spans; only the statistic differs. + fn fold(coarse: &[f32], buckets: usize, pick: impl Fn(&[f32]) -> f32) -> Vec { + let mut out = vec![0f32; buckets]; + for (b, slot) in out.iter_mut().enumerate() { + let start = b * coarse.len() / buckets; + let end = (((b + 1) * coarse.len() / buckets).max(start + 1)).min(coarse.len()); + *slot = pick(&coarse[start..end]); + } + let max = out.iter().fold(0f32, |m, &v| m.max(v)); + if max > 0.0 { + for v in out.iter_mut() { + *v /= max; + } + } + out + } + let mean = |s: &[f32]| s.iter().sum::() / s.len() as f32; + let peak = |s: &[f32]| s.iter().fold(0f32, |m, &v| m.max(v)); + let blend = move |s: &[f32]| mean(s) * 0.35 + peak(s) * 0.65; + + let mut dump = serde_json::Map::new(); + for (buckets, label) in [(400usize, "400"), (2000usize, "2000")] { + for (name, values) in [ + ("mean", fold(&coarse, buckets, mean)), + ("peak", fold(&coarse, buckets, peak)), + ("blend", fold(&coarse, buckets, blend)), + ] { + let mut sorted = values.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let q = |f: f32| sorted[((f * (sorted.len() - 1) as f32) as usize).min(sorted.len() - 1)]; + let avg = values.iter().sum::() / values.len() as f32; + let sd = (values.iter().map(|v| (v - avg).powi(2)).sum::() + / values.len() as f32) + .sqrt(); + println!( + "{label:>5} {name:<6} p05 {:.3} p50 {:.3} p95 {:.3} IQR {:.3} sigma {:.3}", + q(0.05), + q(0.50), + q(0.95), + q(0.75) - q(0.25), + sd, + ); + dump.insert( + format!("{name}{label}"), + serde_json::json!(values.iter().map(|v| (v * 10000.0).round() / 10000.0).collect::>()), + ); + } + } + if let Ok(out) = std::env::var("SOULSTREAM_WF_DUMP") { + std::fs::write(&out, serde_json::to_string(&dump).unwrap()).expect("write dump"); + println!("wrote {out}"); + } + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 97bf8e5..140c04d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3807,7 +3807,7 @@ pub async fn stream_waveform( path: String, size: u64, buckets: usize, -) -> Result, String> { +) -> Result { 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, +} + +impl WaveformSlice { + fn empty() -> Self { + WaveformSlice { + coverage: 0.0, + peaks: Vec::new(), + } + } + + fn complete(peaks: Vec) -> Self { + WaveformSlice { + coverage: 1.0, + peaks, + } + } +} + /// Play an already-downloaded local file. #[tauri::command] pub fn play_local( diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs index df2fb39..2d824bb 100644 --- a/src-tauri/src/playback.rs +++ b/src-tauri/src/playback.rs @@ -98,6 +98,12 @@ impl ProgressiveWaveform { pub(crate) fn peaks(&self) -> Vec { self.peaks.clone() } + + /// How much of the file these peaks were decoded from — the numerator of + /// the coverage the UI anchors its rise frontier to. + pub(crate) fn decoded_bytes(&self) -> u64 { + self.decoded_bytes + } } /// When the next partial re-decode is due. Early on the file doubles between diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 4c354d6..3c15a81 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -32,6 +32,13 @@ import { floatScroll } from "../lib/scrollbar.js"; import { splitDescription } from "../lib/editions.js"; import { accentFromImage } from "../lib/color.js"; + import { + BAR_PITCH, + BAR_W, + waveLevels, + RISE_CADENCE_MS, + PENDING_ALPHA, + } from "../lib/waveshape.js"; const currentItem = $derived(player.queue[player.queueIndex] ?? null); @@ -308,37 +315,29 @@ player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0, ); - // Average dense source peaks into equally sized display bars. Using an - // average (rather than picking one peak) stops isolated transients from - // making random bars hit the ceiling. - // // The COUNT is derived from the panel's width so the lane reads at one // density everywhere. It used to be a flat 78 bars on a 2px pitch — a // 310px block that overflowed the 272px panel and left a third of the // 560px one empty, which is precisely what looked wrong at both ends. - // One bar per BAR_PITCH px instead: a 2px bar on a 4px gap. - // Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1). - const BAR_PITCH = 6; - const BAR_W = 2; + // One bar per BAR_PITCH px instead; the fold and shaping live in + // waveshape.js, shared with the player bar so the two lanes cannot drift. // .np-scroll's horizontal padding, both sides — the lane is what is left. const LANE_PAD = 28; const BARS = $derived( Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)), ); - const bars = $derived.by(() => { - const w = player.waveform; - if (!w || w.length === 0) return []; - const out = []; - for (let i = 0; i < BARS; i++) { - const from = Math.floor((i / BARS) * w.length); - const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length)); - let sum = 0; - for (let j = from; j < to; j++) sum += w[j] ?? 0; - out.push(sum / (to - from)); - } - return out; - }); - const playedBars = $derived((playedPct / 100) * bars.length); + // The rise frontier is the WAVEFORM's own coverage — the audio actually + // decoded, reported by the backend with the peaks — never the live byte + // counter, whose fresher number stretched the drawn shape between + // refreshes. A partial waveform folds across exactly the covered fraction + // of the lane; the rest holds its place as pending dots (nulls below). + const railCoverage = $derived(Math.max(0, Math.min(1, player.waveformCoverage))); + const railCovered = $derived(Math.max(1, Math.floor(BARS * railCoverage))); + const bars = $derived.by(() => + waveLevels(player.waveform, railCoverage < 1 ? railCovered : BARS), + ); + // Bar i always maps to slice i/BARS of the whole track, covered or not. + const playedBars = $derived((playedPct / 100) * BARS); /** The lane is 38px of the panel's vertical rhythm whether or not peaks * have arrived yet. `player.waveform` is emptied the instant a track @@ -351,18 +350,61 @@ * elements rather than replacing them. */ const FLAT_BARS = $derived(Array(BARS).fill(0)); const wavePending = $derived(bars.length === 0); - const waveBars = $derived(wavePending ? FLAT_BARS : bars); + const waveBars = $derived.by(() => { + if (wavePending) return FLAT_BARS; + if (bars.length >= BARS) return bars; + return [...bars, ...Array(BARS - bars.length).fill(null)]; + }); - function barHeight(value, index) { - const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8); - // Stable per-bar grain keeps a dense loud passage from flattening - // into a ruler of identical maximum-height bars. It is deterministic - // (no visual flicker as the playhead moves) and only matters near the - // cap, where source peaks otherwise converge on the same value. - const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1; - const peakFactor = Math.max(0, (capped - 0.58) / 0.3); - const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor)); - return 14 + level * 72; // preserve headroom; never peak against the top + // Streaming rise — the playbar's S2, mirrored: bars stand up on the shared + // RISE_CADENCE_MS rhythm behind the download instead of at whatever rate + // bytes arrive; the CSS `rising` transition is the rail's half of RISE_MS. + // Reset when the queue item changes. + let railRevealed = $state(0); + let railRiseKey = null; + $effect(() => { + const key = currentItem ? `${currentItem.path ?? currentItem.title}` : null; + if (key !== railRiseKey) { + railRiseKey = key; + railRevealed = 0; + } + }); + // Rising while the waveform is still partial, and until the queue stands + // the last chunk up. A cached replay or a local file arrives with + // coverage 1 on its FIRST decode and never rises — it pops in complete. + const railRising = $derived( + player.track?.source === "stream" && + (railCoverage < 1 || (railRevealed > 0 && railRevealed < railCovered)), + ); + const railRevealDone = $derived(!railRising || railRevealed >= railCovered); + $effect(() => { + if (railRevealDone) return; + const timer = setInterval(() => { + // Untracked reads: the interval IS the clock, not the reactivity. + // Played bars are never hidden behind the queue. + railRevealed = Math.min( + Math.max(railRevealed + 1, Math.ceil(playedBars)), + railCovered, + ); + }, RISE_CADENCE_MS); + return () => clearInterval(timer); + }); + function railBarHeight(v, i) { + if (wavePending || v == null) return 0; + if (railRising && i >= railRevealed) return 0; + return barHeight(v); + } + function railBarDim(v, i) { + return wavePending || v == null || (railRising && i >= railRevealed); + } + + /* `bars` is already shaped 0..1 by waveshape.js (which also explains, with + measurements, why the old gain-and-clamp rule here rendered half the + lane at one height and had to be propped up with per-bar noise). This + only maps that level onto the rail's vertical rhythm: a 14% baseline, + 86% ceiling — headroom above the bars is part of the panel's look. */ + function barHeight(level) { + return 14 + level * 72; } function barFill(index) { return Math.max(0, Math.min(1, playedBars - index)); @@ -1038,7 +1080,9 @@ class="wave" class:scrubbing={waveDragging} class:pending={wavePending} - style="--bar-gap: {BAR_PITCH - BAR_W}px" + class:loading={wavePending && player.wavePending} + class:rising={railRising} + style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}" role="slider" tabindex="0" aria-label="Seek" @@ -1053,7 +1097,9 @@ > {#each waveBars as v, i} {/each} @@ -1911,6 +1957,27 @@ background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted)); min-height: 2px; } + /* Loading swell — the playbar's L2, phase-staggered per bar so one crest + travels the lane left→right; the negative delay starts the pattern + mid-flight instead of all-flat. Timing pairs with waveshape.js: 2000ms + sweep + 500ms rest = the 2500ms period, and the 30% crest is the 7px + playbar lift at the rail's 1.6× scale. CSS cannot read those constants — + change them together. */ + .wave.loading i { + animation: rail-swell 2500ms linear infinite; + animation-delay: calc(-2500ms + 2000ms * var(--i) / var(--bars, 60)); + } + @keyframes rail-swell { + 0%, 26%, 100% { height: 2px; opacity: 0.45; } + 13% { height: 30%; opacity: 0.85; } + } + /* Streaming rise — the playbar's S2: a released bar stands up over the + same RISE_MS (2000ms), on the house ease. */ + .wave.rising i { + transition: + height 2000ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 400ms linear; + } .times { display: flex; justify-content: space-between; diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index d34157c..c287b1f 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -16,18 +16,24 @@ resumePlayback, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; + import { + BAR_PITCH, + BAR_W, + waveLevels, + SWELL_SWEEP_MS, + SWELL_REST_MS, + SWELL_AMP_PX, + SWELL_WIDTH_BARS, + RISE_MS, + RISE_CADENCE_MS, + PENDING_ALPHA, + } from "../lib/waveshape.js"; import Icon from "./Icon.svelte"; import SaveMenu from "./SaveMenu.svelte"; const pct = $derived( player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0, ); - const bufferPct = $derived( - player.streamTotal > 0 - ? Math.min(100, (player.streamReceived / player.streamTotal) * 100) - : 0, - ); - const hasWave = $derived(player.waveform.length > 0); const idle = $derived(player.status === "idle"); const currentItem = $derived(player.queue[player.queueIndex] ?? null); // The native scratch engine needs a complete local file. A network stream @@ -59,101 +65,12 @@ : "volume", ); - // ---- the source line --------------------------------------------------- - // Every value below already exists in state — these are the same fields - // NowPlaying reads for its status line, so this needed no new command. - // The derivations are deliberately identical to that component's, because - // two surfaces disagreeing about whether a track is "cached" would be worse - // than either of them being wrong. - const isStream = $derived(!!currentItem?.stream); - const quality = $derived(currentItem?.quality ?? null); - /** Same set NowPlaying uses, so the green dot means the same thing here. */ - const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]); - const transferring = $derived( - isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal, - ); - const cachedStream = $derived( - isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal, - ); - /** What the line is ABOUT, as opposed to what it currently reads. The slot - * is re-measured when THIS changes and never when a percentage ticks. */ - const srcState = $derived( - !isStream - ? "library" - : cachedStream - ? "cached" - : transferring - ? "transferring" - : "connecting", - ); - const peerName = $derived(currentItem?.stream?.username ?? null); - const rateText = $derived( - player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null, - ); - const etaText = $derived.by(() => { - if (!transferring || player.streamSpeed <= 0) return null; - const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed; - if (!isFinite(secs) || secs < 1) return null; - return secs >= 60 - ? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left` - : `${Math.round(secs)}s left`; - }); - const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null); - const qualityChip = $derived.by(() => { - if (!quality?.format) return null; - return quality.bitrate - ? `${quality.format} ${Math.round(quality.bitrate)}k` - : quality.format; - }); - /** A library track reports NOTHING: it has no peer and no transfer, and - * zeroes would read as a stalled one. Same call NowPlaying makes. */ - const sourceParts = $derived.by(() => { - if (!isStream) return []; - if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean); - if (transferring) { - return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean); - } - return peerName ? [`Streaming from ${peerName}`] : []; - }); - - // The slot reserves a width rather than hugging its text: while a transfer - // runs, the percentage, rate and ETA change every position event, and a - // slot that tracked them would slide the transport and the whole scrubber - // sideways continuously. It GROWS the instant the text needs more room and - // SHRINKS only when the state changes, so nothing ever clips and nothing - // twitches. Written straight to the node: routing it through $state would - // make the effect that measures it depend on its own output. - let srcSlotEl = $state(null); - let srcLineEl = $state(null); - const SLOT_CAP = 340; - const SLOT_PAD_L = 10; - const SLOT_PAD_R = 8; - function sizeSourceSlot(reset = false) { - if (!srcSlotEl) return; - const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0; - const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0; - const current = parseFloat(srcSlotEl.style.width) || 0; - if (!reset && want <= current) return; - srcSlotEl.style.width = `${want}px`; - srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px"; - srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px"; - } - $effect(() => { - srcState; - qualityChip; - sizeSourceSlot(true); - }); - $effect(() => { - sourceParts; - sizeSourceSlot(false); - }); - // The slot's width is written imperatively, so a window resize — which can - // cross the @container threshold that hides it — has to re-measure. - $effect(() => { - const onResize = () => sizeSourceSlot(true); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }); + // The source line — peer, percentage, rate, ETA, size, format — used to + // live here in a slot whose width was measured and written by hand. It is + // gone: the Now Playing rail already carries every one of those fields, and + // reserving room for them here made the transport's position a function of + // how much a peer had sent. Nothing reads `player.streamSpeed`, + // `streamTotal` or `currentItem.quality` in this component any more. // ---- the overflow menu ------------------------------------------------- let menuOpen = $state(false); @@ -215,13 +132,149 @@ ); // Canvas colors; played/hover follow the adaptive accent theme. - const UNPLAYED = "rgba(255,255,255,0.24)"; + /** The rail's unplayed colour — `--text-muted`, copied verbatim so both + * lanes fade to the same grey. A canvas fill cannot use the CSS var; + * if the token ever changes, change this with it. */ + const UNPLAYED = "#7a7a7a"; const PREVIEW = "rgba(255,255,255,0.66)"; - /** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */ - const BAR_PITCH = 6; - const BAR_W = 2; const BAR_GAP = BAR_PITCH - BAR_W; + // The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one + // bar the playhead is inside. `theme.accent` is always "#rrggbb" (state's + // rgbToHex emits nothing else), parsed once per accent value — it only + // changes while a crossfade lerps. + // + // The bar's crossfade target must match its NEIGHBOURS, not a constant: + // normally it fades accent → the rail's grey, but while the hover wash is + // up, every bar to its right is wash-white — fading toward grey then left + // one dark notch stranded between the accent and the wash. So under a wash + // it fades accent → the wash's own resolved colour instead. + const MUTED_RGB = [122, 122, 122]; + /** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed: + * 0.66·255 + 0.34·122 — what a washed bar actually renders as. */ + const WASHED_RGB = [210, 210, 210]; + let blendParsedFor = ""; + let blendRgb = MUTED_RGB; + function blendAccent(accent, fill, towardWash) { + if (accent !== blendParsedFor) { + const n = parseInt(accent.slice(1), 16); + blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + blendParsedFor = accent; + } + const toward = towardWash ? WASHED_RGB : MUTED_RGB; + const t = 1 - fill; + const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t); + const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t); + const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t); + return `rgb(${r},${g},${b})`; + } + + // ---- loading / streaming motion --------------------------------------- + // The lane is ALWAYS the bar waveform now. With nothing to show it rests + // as dim dots (the rail's pending look, mirrored); while a waveform is + // being computed the loading SWELL crosses those dots; while a stream + // downloads, bars stand up left→right on a fixed rhythm (the RISE) — + // released one per RISE_CADENCE_MS behind the transfer, each easing up + // over RISE_MS, so a fast peer cannot machine-gun the lane. Numbers and + // provenance live in waveshape.js. The solid fallback track this replaces + // also carried the buffer fill; that story is the rise frontier now. + let riseKey = null; // which queue item the rise bookkeeping belongs to + let riseReleased = 0; // bars allowed to stand so far + let riseLastRelease = 0; // clock of the newest release, for the cadence + let riseBorn = []; // per-bar release clock, for the ease + let riseLevels = []; + let riseLevelsPeaks = null; + let riseLevelsCount = 0; + let animRaf = null; + + function paintBar(ctx, x, mid, h, color) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(x, mid - h / 2, BAR_W, h, 1); + ctx.fill(); + } + + /** One frame while the lane animates; re-arms itself from drawWave as long + * as the state it painted still moves. Position events also repaint, but + * they arrive ~5/s — the swell and the rise need real frames. */ + function scheduleAnim(active) { + if (!active || animRaf !== null || !windowActive) return; + animRaf = requestAnimationFrame(() => { + animRaf = null; + drawWave(true); + }); + } + $effect(() => () => { + if (animRaf !== null) cancelAnimationFrame(animRaf); + }); + + /** Resting dots, with the loading swell crossing them while a waveform is + * actually being computed (`player.wavePending`). No accent here — accent + * means played, and this lane has nothing to play yet. */ + function drawUnloaded(ctx, W, H, mid, count, now) { + ctx.clearRect(0, 0, W, H); + const pending = player.wavePending; + const period = SWELL_SWEEP_MS + SWELL_REST_MS; + const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat + const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS; + for (let i = 0; i < count; i++) { + let env = 0; + if (pending && u <= 1) { + const d = (i - centre) / (SWELL_WIDTH_BARS * 0.55); + env = Math.exp(-d * d); + } + const alpha = PENDING_ALPHA + (0.85 - PENDING_ALPHA) * env; + paintBar(ctx, i * BAR_PITCH, mid, 2 + env * SWELL_AMP_PX * 2, `rgba(122,122,122,${alpha})`); + } + return pending; + } + + /** The streaming rise. Released bars ease from the floor to their height; + * queued and beyond-frontier bars are pending dots. The colour pass is + * the full painter's fade — accent, boundary blend, wash, muted — done + * per bar because the heights differ every frame while bars grow. The + * partial waveform describes only the audio that has ARRIVED, so it is + * folded across the covered fraction of the lane, not the whole width. */ + function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) { + const coveredBars = Math.max(1, frontier); + if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) { + riseLevels = waveLevels(player.waveform, coveredBars); + riseLevelsPeaks = player.waveform; + riseLevelsCount = coveredBars; + } + ctx.clearRect(0, 0, W, H); + const playX = playFrac * W; + const washing = hoverX > playX; + const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1; + const full = Math.floor(playedBars); + for (let i = 0; i < count; i++) { + const x = i * BAR_PITCH; + if (i >= riseReleased) { + paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`); + continue; + } + const grow = Math.min(1, (now - (riseBorn[i] ?? now)) / RISE_MS); + const eased = 1 - Math.pow(1 - grow, 3); + const target = Math.max(2, (riseLevels[i] ?? 0.12) * H); + const h = 2 + (target - 2) * eased; + let color; + if (i < full) color = theme.accent; + else if (i === full && playedBars - full > 0) { + color = blendAccent(theme.accent, playedBars - full, washing); + } else if (washing && i > full && i <= washEndBar) { + color = `rgb(${WASHED_RGB.join(",")})`; + } else { + color = UNPLAYED; + } + paintBar(ctx, x, mid, h, color); + } + if (hoverX >= 0) { + const LINE_H = 18; + ctx.fillStyle = theme.accentHover; + ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); + } + } + function fmt(secs) { if (secs == null || !isFinite(secs)) return "–:––"; const s = Math.max(0, Math.floor(secs)); @@ -355,13 +408,15 @@ } // Wave geometry is invariant while a track and player-bar size stay the - // same. Cache it instead of rebuilding ~1,600 line segments and resetting - // the canvas backing store on every 60 ms position event. - let wavePath = null; + // same. Cache the shaped bars instead of refolding 2000 peaks and + // resetting the canvas backing store on every 60 ms position event. + let wavePath = null; // one Path2D of every bar, for the grouped fills + let waveHeights = []; // per-bar heights, for the one bar the playhead is in + let waveCount = 0; let wavePathPeaks = null; let wavePathWidth = 0; let wavePathHeight = 0; - let lastPlayX = Number.NEGATIVE_INFINITY; + let lastPlayedBars = Number.NEGATIVE_INFINITY; let lastHoverDrawn = Number.NEGATIVE_INFINITY; let lastAccent = ""; let lastAccentHover = ""; @@ -378,7 +433,7 @@ const canvas = waveCanvas; const wrap = waveWrap; const peaks = player.waveform; - if (!canvas || !wrap || !peaks || peaks.length === 0) return; + if (!canvas || !wrap) return; const dpr = window.devicePixelRatio || 1; const W = wrap.clientWidth; @@ -395,52 +450,130 @@ if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - const N = peaks.length; const mid = H / 2; + const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH)); + const now = performance.now(); + + // Nothing to draw yet: resting dots, with the swell crossing them + // while a waveform request is actually working. + if (!peaks || peaks.length === 0) { + riseKey = null; + const moving = drawUnloaded(ctx, W, H, mid, count, now); + lastPlayedBars = Number.NEGATIVE_INFINITY; + scheduleAnim(moving); + return; + } + + // A stream whose waveform does not yet cover the whole track (or one + // still standing its last bars up): the rise owns the lane. The + // frontier is the WAVEFORM'S OWN coverage — the audio that has + // actually been decoded, reported by the backend with the peaks — + // never the live byte counter, whose fresher number stretched the + // drawn shape between refreshes. Where the bars end IS how much of + // the track has loaded: the edge parks when it catches the download + // and marches again when the next chunk lands. A track whose FIRST + // decode already covers everything — a cached replay, a local file — + // never rises at all; it pops in complete. + const waveCov = Math.max(0, Math.min(1, player.waveformCoverage)); + const riseCandidate = player.track?.source === "stream"; + const riseTrackKey = currentItem?.path ?? player.track?.title ?? "?"; + if (riseCandidate && (waveCov < 1 || riseKey === riseTrackKey)) { + if (riseKey !== riseTrackKey) { + riseKey = riseTrackKey; + riseReleased = 0; + riseLastRelease = now; + riseBorn = []; + } + const frontier = Math.floor(waveCov * count); + let guard = 0; + while ( + riseReleased < frontier && + now - riseLastRelease >= RISE_CADENCE_MS && + guard++ < 600 + ) { + riseBorn[riseReleased] = now; + riseReleased += 1; + riseLastRelease += RISE_CADENCE_MS; + } + // An idle cadence clock would otherwise bank a backlog and dump it + // all at once when the next coverage arrives. + if (riseReleased >= frontier) { + riseLastRelease = Math.max(riseLastRelease, now - RISE_CADENCE_MS); + } + const playFracNow = player.duration + ? Math.max(0, Math.min(1, player.position / player.duration)) + : 0; + const playedBarsNow = playFracNow * count; + // Played audio is never hidden behind the queue. + for (let i = riseReleased; i <= Math.min(Math.floor(playedBarsNow), frontier - 1); i++) { + riseBorn[i] = now - RISE_MS; + riseReleased = i + 1; + } + const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0; + const growing = riseReleased > 0 && now - newest < RISE_MS; + if (waveCov < 1 || riseReleased < count || growing) { + drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow); + lastPlayedBars = playedBarsNow; + lastHoverDrawn = hoverX; + lastAccent = theme.accent; + lastAccentHover = theme.accentHover; + lastDuration = player.duration; + scheduleAnim(growing || riseReleased < frontier); + return; + } + // Fully covered, fully grown: hand the lane back to the cached + // painter for good. + riseKey = null; + } else if (!riseCandidate) { + riseKey = null; + } + const geometryChanged = resized || wavePathPeaks !== peaks || wavePathWidth !== W || wavePathHeight !== H; if (geometryChanged) { - // Discrete bars on the SAME pitch the Now Playing rail draws — a - // 2px bar on a 4px gap, 1px radius — so the two waveforms in the - // window read as one instrument rather than two. Both derive their - // bar count from that pitch, so neither can drift from the other. - // Mirrored about the centre line here, where the lane is short and - // wide; the rail stands its bars on the floor because it sits above - // a caption. - const path = new Path2D(); + // Same pitch AND same shaping as the Now Playing rail — both come + // from waveshape.js — so the two lanes in the window read as one + // instrument and neither can drift from the other. Mirrored about + // the centre line here, where the lane is short and wide; the rail + // stands its bars on the floor because it sits above a caption. const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH)); + const levels = waveLevels(peaks, count); + const path = new Path2D(); + waveHeights = levels.map((level) => Math.max(2, level * H)); for (let i = 0; i < count; i++) { - const from = Math.floor((i / count) * N); - const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N)); - let sum = 0; - for (let j = from; j < to; j++) sum += peaks[j] ?? 0; - // The rail's shaping, verbatim: capped so a loud passage cannot - // flatten into a ruler, then gamma'd back up. - const v = sum / (to - from); - const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H); - path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1); + path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1); } wavePath = path; + waveCount = count; wavePathPeaks = peaks; wavePathWidth = W; wavePathHeight = H; } - const playX = player.duration - ? Math.max(0, Math.min(W, (player.position / player.duration) * W)) + const playFrac = player.duration + ? Math.max(0, Math.min(1, player.position / player.duration)) : 0; + const playX = playFrac * W; + const playedBars = playFrac * waveCount; const visualsChanged = geometryChanged || hoverX !== lastHoverDrawn || theme.accent !== lastAccent || theme.accentHover !== lastAccentHover || player.duration !== lastDuration; - // A sub-device-pixel playhead move cannot materially change the image. - // Keep exact redraws for hover, resize, theme, seeks, and short tracks. - if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return; + // The playhead's visible quantum is the boundary bar's BLEND, not a + // clip edge: the fills cut on bar boundaries, so the only thing a + // small move changes is that one bar's colour. The old skip here — no + // redraw under 0.75px of playhead travel — was written for the + // continuous fill and quantized that crossfade into 12% jumps at + // ~200ms, visibly chunkier than the rail blending the same amount + // every position event. Redraw when the blend has moved ≥2% of a bar + // (every event on a normal lane) and still skip the no-op repaints of + // a paused player. + if (!force && !visualsChanged && Math.abs(playedBars - lastPlayedBars) < 0.02) return; ctx.clearRect(0, 0, W, H); const fillClip = (x0, x1, color) => { @@ -454,11 +587,31 @@ ctx.restore(); }; - fillClip(0, W, UNPLAYED); - if (hoverX > playX) fillClip(playX, hoverX, PREVIEW); - // Match the primary Play button and the sidebar's completed bars. + // The rail's fade, copied: a bar is one colour, blended from accent to + // the rail's grey by how far the playhead is through it. Fully played + // and unplayed regions clip on bar BOUNDARIES — the cut lands in the + // 4px gap — and only the single bar the playhead is inside gets the + // mixed fill, so no bar is ever chopped down its middle. The accent + // matches the primary Play button and the sidebar's completed bars; // `accentHover` remains reserved for hover previews and controls. - fillClip(0, playX, theme.accent); + const full = Math.floor(playedBars); + const washing = hoverX > playX; + fillClip(0, W, UNPLAYED); + if (washing) { + // Bar-quantized for the same reason: the wash recolours whole bars + // between the playhead's bar and the cursor, never a sliver. + const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH; + fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW); + } + fillClip(0, full * BAR_PITCH, theme.accent); + const frac = playedBars - full; + if (frac > 0 && full < waveCount) { + const h = waveHeights[full]; + ctx.fillStyle = blendAccent(theme.accent, frac, washing); + ctx.beginPath(); + ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1); + ctx.fill(); + } // No standing playhead rule any more: at this pitch the accent's own // leading edge marks the position, and a white line through the bars @@ -470,7 +623,7 @@ ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); } - lastPlayX = playX; + lastPlayedBars = playedBars; lastHoverDrawn = hoverX; lastAccent = theme.accent; lastAccentHover = theme.accentHover; @@ -541,27 +694,6 @@ - - {#if sourceParts.length || qualityChip} -
-
- {#each sourceParts as part, i} - {#if i > 0}·{/if} - {part} - {/each} - {#if qualityChip} - {#if sourceParts.length}·{/if} - {qualityChip} - {/if} -
-
- {/if} -