forked from Keelrleek/SoulStream
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:
1 parent
3aaec30dc6
commit
b33d403634
9 files changed
+740
-323
No files matched your search
Generated
+1
@@ -3082,6 +3082,7 @@ dependencies = [
|
|||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
"rodio",
|
"rodio",
|
||||||
|
"serde_json",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ log.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
# Test-only: dumps bucketing variants for the waveform shaping mockup.
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -32,6 +32,15 @@ pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
|
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, 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<Vec<f32>, String> {
|
||||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
@@ -111,7 +120,7 @@ fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
|
|||||||
coarse.push((win_energy / win_count as f64).sqrt() as f32);
|
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
|
/// Downsample `coarse` RMS values to exactly `buckets`, averaging each
|
||||||
@@ -152,4 +161,83 @@ mod tests {
|
|||||||
fn empty_input() {
|
fn empty_input() {
|
||||||
assert_eq!(resample(&[], 5), vec![0.0; 5]);
|
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<f32> {
|
||||||
|
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::<f32>() / 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::<f32>() / values.len() as f32;
|
||||||
|
let sd = (values.iter().map(|v| (v - avg).powi(2)).sum::<f32>()
|
||||||
|
/ 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::<Vec<_>>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+48
-10
@@ -3807,7 +3807,7 @@ pub async fn stream_waveform(
|
|||||||
path: String,
|
path: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
buckets: usize,
|
buckets: usize,
|
||||||
) -> Result<Vec<f32>, String> {
|
) -> Result<WaveformSlice, String> {
|
||||||
const MAX_WAVEFORM_BUCKETS: usize = 4096;
|
const MAX_WAVEFORM_BUCKETS: usize = 4096;
|
||||||
if buckets > MAX_WAVEFORM_BUCKETS {
|
if buckets > MAX_WAVEFORM_BUCKETS {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -3815,10 +3815,10 @@ pub async fn stream_waveform(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if buckets == 0 {
|
if buckets == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(WaveformSlice::empty());
|
||||||
}
|
}
|
||||||
let Some(cache) = playback::cache_path(&app, &username, &path) else {
|
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 key = format!("{}#{buckets}", cache.display());
|
||||||
let job = players.waveform_job(&key);
|
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) {
|
if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) {
|
||||||
players.remove_progressive_waveform(&key);
|
players.remove_progressive_waveform(&key);
|
||||||
if let Some(cached) = players.cached_waveform(&key) {
|
if let Some(cached) = players.cached_waveform(&key) {
|
||||||
return Ok(cached);
|
return Ok(WaveformSlice::complete(cached));
|
||||||
}
|
}
|
||||||
|
|
||||||
let p = cache.clone();
|
let p = cache.clone();
|
||||||
@@ -3844,7 +3844,7 @@ pub async fn stream_waveform(
|
|||||||
players.cache_waveform(key.clone(), peaks.clone());
|
players.cache_waveform(key.clone(), peaks.clone());
|
||||||
}
|
}
|
||||||
drop(job_guard);
|
drop(job_guard);
|
||||||
return Ok(peaks);
|
return Ok(WaveformSlice::complete(peaks));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seeing a partial file invalidates any completed waveform retained
|
// 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 partial = playback::partial_path(&cache);
|
||||||
let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||||
if have == 0 || size == 0 {
|
if have == 0 || size == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(WaveformSlice::empty());
|
||||||
}
|
}
|
||||||
if let Some(previous) = players.progressive_waveform(&key) {
|
if let Some(previous) = players.progressive_waveform(&key) {
|
||||||
if !previous.should_recompute(have) {
|
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.
|
// three seconds.
|
||||||
let frac = (have as f64 / size as f64).min(1.0);
|
let frac = (have as f64 / size as f64).min(1.0);
|
||||||
let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets);
|
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);
|
let peaks = player::compute_waveform(&partial, n);
|
||||||
(job_guard, peaks)
|
(job_guard, peaks)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !peaks.is_empty() {
|
if !peaks.is_empty() {
|
||||||
peaks.resize(buckets, 0.0);
|
|
||||||
players.cache_progressive_waveform(
|
players.cache_progressive_waveform(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
playback::ProgressiveWaveform::new(have, size, peaks.clone()),
|
playback::ProgressiveWaveform::new(have, size, peaks.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
drop(job_guard);
|
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;
|
.await;
|
||||||
players.release_waveform_job(&key, &job);
|
players.release_waveform_job(&key, &job);
|
||||||
result
|
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.
|
/// Play an already-downloaded local file.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn play_local(
|
pub fn play_local(
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ impl ProgressiveWaveform {
|
|||||||
pub(crate) fn peaks(&self) -> Vec<f32> {
|
pub(crate) fn peaks(&self) -> Vec<f32> {
|
||||||
self.peaks.clone()
|
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
|
/// When the next partial re-decode is due. Early on the file doubles between
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
import { floatScroll } from "../lib/scrollbar.js";
|
import { floatScroll } from "../lib/scrollbar.js";
|
||||||
import { splitDescription } from "../lib/editions.js";
|
import { splitDescription } from "../lib/editions.js";
|
||||||
import { accentFromImage } from "../lib/color.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);
|
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||||
|
|
||||||
@@ -308,37 +315,29 @@
|
|||||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
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
|
// 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
|
// 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
|
// 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.
|
// 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.
|
// One bar per BAR_PITCH px instead; the fold and shaping live in
|
||||||
// Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1).
|
// waveshape.js, shared with the player bar so the two lanes cannot drift.
|
||||||
const BAR_PITCH = 6;
|
|
||||||
const BAR_W = 2;
|
|
||||||
// .np-scroll's horizontal padding, both sides — the lane is what is left.
|
// .np-scroll's horizontal padding, both sides — the lane is what is left.
|
||||||
const LANE_PAD = 28;
|
const LANE_PAD = 28;
|
||||||
const BARS = $derived(
|
const BARS = $derived(
|
||||||
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
|
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
|
||||||
);
|
);
|
||||||
const bars = $derived.by(() => {
|
// The rise frontier is the WAVEFORM's own coverage — the audio actually
|
||||||
const w = player.waveform;
|
// decoded, reported by the backend with the peaks — never the live byte
|
||||||
if (!w || w.length === 0) return [];
|
// counter, whose fresher number stretched the drawn shape between
|
||||||
const out = [];
|
// refreshes. A partial waveform folds across exactly the covered fraction
|
||||||
for (let i = 0; i < BARS; i++) {
|
// of the lane; the rest holds its place as pending dots (nulls below).
|
||||||
const from = Math.floor((i / BARS) * w.length);
|
const railCoverage = $derived(Math.max(0, Math.min(1, player.waveformCoverage)));
|
||||||
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
|
const railCovered = $derived(Math.max(1, Math.floor(BARS * railCoverage)));
|
||||||
let sum = 0;
|
const bars = $derived.by(() =>
|
||||||
for (let j = from; j < to; j++) sum += w[j] ?? 0;
|
waveLevels(player.waveform, railCoverage < 1 ? railCovered : BARS),
|
||||||
out.push(sum / (to - from));
|
);
|
||||||
}
|
// Bar i always maps to slice i/BARS of the whole track, covered or not.
|
||||||
return out;
|
const playedBars = $derived((playedPct / 100) * BARS);
|
||||||
});
|
|
||||||
const playedBars = $derived((playedPct / 100) * bars.length);
|
|
||||||
|
|
||||||
/** The lane is 38px of the panel's vertical rhythm whether or not peaks
|
/** 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
|
* have arrived yet. `player.waveform` is emptied the instant a track
|
||||||
@@ -351,18 +350,61 @@
|
|||||||
* elements rather than replacing them. */
|
* elements rather than replacing them. */
|
||||||
const FLAT_BARS = $derived(Array(BARS).fill(0));
|
const FLAT_BARS = $derived(Array(BARS).fill(0));
|
||||||
const wavePending = $derived(bars.length === 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) {
|
// Streaming rise — the playbar's S2, mirrored: bars stand up on the shared
|
||||||
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
|
// RISE_CADENCE_MS rhythm behind the download instead of at whatever rate
|
||||||
// Stable per-bar grain keeps a dense loud passage from flattening
|
// bytes arrive; the CSS `rising` transition is the rail's half of RISE_MS.
|
||||||
// into a ruler of identical maximum-height bars. It is deterministic
|
// Reset when the queue item changes.
|
||||||
// (no visual flicker as the playhead moves) and only matters near the
|
let railRevealed = $state(0);
|
||||||
// cap, where source peaks otherwise converge on the same value.
|
let railRiseKey = null;
|
||||||
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
|
$effect(() => {
|
||||||
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
|
const key = currentItem ? `${currentItem.path ?? currentItem.title}` : null;
|
||||||
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
|
if (key !== railRiseKey) {
|
||||||
return 14 + level * 72; // preserve headroom; never peak against the top
|
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) {
|
function barFill(index) {
|
||||||
return Math.max(0, Math.min(1, playedBars - index));
|
return Math.max(0, Math.min(1, playedBars - index));
|
||||||
@@ -1038,7 +1080,9 @@
|
|||||||
class="wave"
|
class="wave"
|
||||||
class:scrubbing={waveDragging}
|
class:scrubbing={waveDragging}
|
||||||
class:pending={wavePending}
|
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"
|
role="slider"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
@@ -1053,7 +1097,9 @@
|
|||||||
>
|
>
|
||||||
{#each waveBars as v, i}
|
{#each waveBars as v, i}
|
||||||
<i
|
<i
|
||||||
style="height:{wavePending ? 0 : barHeight(v, i)}%; --fill:{wavePending ? 0 : barFill(i)}"
|
style="--i:{i}; height:{railBarHeight(v, i)}%; --fill:{railBarDim(v, i)
|
||||||
|
? 0
|
||||||
|
: barFill(i)}; opacity:{railBarDim(v, i) ? PENDING_ALPHA : 1}"
|
||||||
></i>
|
></i>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1911,6 +1957,27 @@
|
|||||||
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
|
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
|
||||||
min-height: 2px;
|
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 {
|
.times {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
+337
-269
@@ -16,18 +16,24 @@
|
|||||||
resumePlayback,
|
resumePlayback,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.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 Icon from "./Icon.svelte";
|
||||||
import SaveMenu from "./SaveMenu.svelte";
|
import SaveMenu from "./SaveMenu.svelte";
|
||||||
|
|
||||||
const pct = $derived(
|
const pct = $derived(
|
||||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
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 idle = $derived(player.status === "idle");
|
||||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||||
// The native scratch engine needs a complete local file. A network stream
|
// The native scratch engine needs a complete local file. A network stream
|
||||||
@@ -59,101 +65,12 @@
|
|||||||
: "volume",
|
: "volume",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- the source line ---------------------------------------------------
|
// The source line — peer, percentage, rate, ETA, size, format — used to
|
||||||
// Every value below already exists in state — these are the same fields
|
// live here in a slot whose width was measured and written by hand. It is
|
||||||
// NowPlaying reads for its status line, so this needed no new command.
|
// gone: the Now Playing rail already carries every one of those fields, and
|
||||||
// The derivations are deliberately identical to that component's, because
|
// reserving room for them here made the transport's position a function of
|
||||||
// two surfaces disagreeing about whether a track is "cached" would be worse
|
// how much a peer had sent. Nothing reads `player.streamSpeed`,
|
||||||
// than either of them being wrong.
|
// `streamTotal` or `currentItem.quality` in this component any more.
|
||||||
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 overflow menu -------------------------------------------------
|
// ---- the overflow menu -------------------------------------------------
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
@@ -215,13 +132,149 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Canvas colors; played/hover follow the adaptive accent theme.
|
// 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)";
|
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;
|
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) {
|
function fmt(secs) {
|
||||||
if (secs == null || !isFinite(secs)) return "–:––";
|
if (secs == null || !isFinite(secs)) return "–:––";
|
||||||
const s = Math.max(0, Math.floor(secs));
|
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
|
// 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
|
// same. Cache the shaped bars instead of refolding 2000 peaks and
|
||||||
// the canvas backing store on every 60 ms position event.
|
// resetting the canvas backing store on every 60 ms position event.
|
||||||
let wavePath = null;
|
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 wavePathPeaks = null;
|
||||||
let wavePathWidth = 0;
|
let wavePathWidth = 0;
|
||||||
let wavePathHeight = 0;
|
let wavePathHeight = 0;
|
||||||
let lastPlayX = Number.NEGATIVE_INFINITY;
|
let lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||||||
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
|
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
|
||||||
let lastAccent = "";
|
let lastAccent = "";
|
||||||
let lastAccentHover = "";
|
let lastAccentHover = "";
|
||||||
@@ -378,7 +433,7 @@
|
|||||||
const canvas = waveCanvas;
|
const canvas = waveCanvas;
|
||||||
const wrap = waveWrap;
|
const wrap = waveWrap;
|
||||||
const peaks = player.waveform;
|
const peaks = player.waveform;
|
||||||
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
|
if (!canvas || !wrap) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const W = wrap.clientWidth;
|
const W = wrap.clientWidth;
|
||||||
@@ -395,52 +450,130 @@
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
const N = peaks.length;
|
|
||||||
const mid = H / 2;
|
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 =
|
const geometryChanged =
|
||||||
resized ||
|
resized ||
|
||||||
wavePathPeaks !== peaks ||
|
wavePathPeaks !== peaks ||
|
||||||
wavePathWidth !== W ||
|
wavePathWidth !== W ||
|
||||||
wavePathHeight !== H;
|
wavePathHeight !== H;
|
||||||
if (geometryChanged) {
|
if (geometryChanged) {
|
||||||
// Discrete bars on the SAME pitch the Now Playing rail draws — a
|
// Same pitch AND same shaping as the Now Playing rail — both come
|
||||||
// 2px bar on a 4px gap, 1px radius — so the two waveforms in the
|
// from waveshape.js — so the two lanes in the window read as one
|
||||||
// window read as one instrument rather than two. Both derive their
|
// instrument and neither can drift from the other. Mirrored about
|
||||||
// bar count from that pitch, so neither can drift from the other.
|
// the centre line here, where the lane is short and wide; the rail
|
||||||
// Mirrored about the centre line here, where the lane is short and
|
// stands its bars on the floor because it sits above a caption.
|
||||||
// wide; the rail stands its bars on the floor because it sits above
|
|
||||||
// a caption.
|
|
||||||
const path = new Path2D();
|
|
||||||
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
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++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const from = Math.floor((i / count) * N);
|
path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
wavePath = path;
|
wavePath = path;
|
||||||
|
waveCount = count;
|
||||||
wavePathPeaks = peaks;
|
wavePathPeaks = peaks;
|
||||||
wavePathWidth = W;
|
wavePathWidth = W;
|
||||||
wavePathHeight = H;
|
wavePathHeight = H;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playX = player.duration
|
const playFrac = player.duration
|
||||||
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
|
? Math.max(0, Math.min(1, player.position / player.duration))
|
||||||
: 0;
|
: 0;
|
||||||
|
const playX = playFrac * W;
|
||||||
|
const playedBars = playFrac * waveCount;
|
||||||
const visualsChanged =
|
const visualsChanged =
|
||||||
geometryChanged ||
|
geometryChanged ||
|
||||||
hoverX !== lastHoverDrawn ||
|
hoverX !== lastHoverDrawn ||
|
||||||
theme.accent !== lastAccent ||
|
theme.accent !== lastAccent ||
|
||||||
theme.accentHover !== lastAccentHover ||
|
theme.accentHover !== lastAccentHover ||
|
||||||
player.duration !== lastDuration;
|
player.duration !== lastDuration;
|
||||||
// A sub-device-pixel playhead move cannot materially change the image.
|
// The playhead's visible quantum is the boundary bar's BLEND, not a
|
||||||
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
|
// clip edge: the fills cut on bar boundaries, so the only thing a
|
||||||
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
|
// 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);
|
ctx.clearRect(0, 0, W, H);
|
||||||
const fillClip = (x0, x1, color) => {
|
const fillClip = (x0, x1, color) => {
|
||||||
@@ -454,11 +587,31 @@
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
fillClip(0, W, UNPLAYED);
|
// The rail's fade, copied: a bar is one colour, blended from accent to
|
||||||
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
|
// the rail's grey by how far the playhead is through it. Fully played
|
||||||
// Match the primary Play button and the sidebar's completed bars.
|
// 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.
|
// `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
|
// 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
|
// 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);
|
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPlayX = playX;
|
lastPlayedBars = playedBars;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
lastAccent = theme.accent;
|
lastAccent = theme.accent;
|
||||||
lastAccentHover = theme.accentHover;
|
lastAccentHover = theme.accentHover;
|
||||||
@@ -541,27 +694,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Who is sending this, and how far along. Its OWN slot in the bar, not a
|
|
||||||
child of `.now-playing`: sharing that budget meant a long transfer line
|
|
||||||
("peer · 50% · 0.3 MB/s · 51s left") crowded the artist and album down
|
|
||||||
to "Po… · Terrestria…". The two now have independent room, and this one
|
|
||||||
is what the container query drops when the bar gets tight. Absent
|
|
||||||
entirely for a library track — see `sourceParts`. -->
|
|
||||||
{#if sourceParts.length || qualityChip}
|
|
||||||
<div class="src-slot" bind:this={srcSlotEl}>
|
|
||||||
<div class="src-line" bind:this={srcLineEl}>
|
|
||||||
{#each sourceParts as part, i}
|
|
||||||
{#if i > 0}<span class="sep">·</span>{/if}
|
|
||||||
<span class:peer={i === 0 && srcState === "transferring"}>{part}</span>
|
|
||||||
{/each}
|
|
||||||
{#if qualityChip}
|
|
||||||
{#if sourceParts.length}<span class="sep">·</span>{/if}
|
|
||||||
<span class="qchip" class:loss={LOSSLESS.has(quality?.format)}>{qualityChip}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button
|
<button
|
||||||
class="ctl small"
|
class="ctl small"
|
||||||
@@ -592,46 +724,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="scrubber">
|
<div class="scrubber">
|
||||||
<span class="time">{fmt(player.position)}</span>
|
<span class="time">{fmt(player.position)}</span>
|
||||||
{#if hasWave}
|
<!-- ALWAYS the bar lane — the solid fallback track is gone. With no
|
||||||
<div
|
waveform the canvas rests as dots (swelling while one loads,
|
||||||
class="wave-wrap"
|
standing up as a stream arrives), and it stays a seek surface
|
||||||
bind:this={waveWrap}
|
the whole time: same handlers in every state. The tooltip
|
||||||
role="slider"
|
needs a duration to have anything truthful to say. -->
|
||||||
aria-label="Seek"
|
<div
|
||||||
aria-valuenow={Math.round(pct)}
|
class="wave-wrap"
|
||||||
tabindex="0"
|
bind:this={waveWrap}
|
||||||
onmousemove={onWaveHover}
|
role="slider"
|
||||||
onmouseleave={() => (hoverX = -1)}
|
aria-label="Seek"
|
||||||
class:dragging={draggingSeek}
|
aria-valuenow={Math.round(pct)}
|
||||||
onpointerdown={(e) => startScrub(e, waveWrap)}
|
tabindex="0"
|
||||||
onpointermove={(e) => moveScrub(e, waveWrap)}
|
onmousemove={onWaveHover}
|
||||||
onpointerup={(e) => endScrub(e, waveWrap)}
|
onmouseleave={() => (hoverX = -1)}
|
||||||
onpointercancel={(e) => endScrub(e, waveWrap)}
|
class:dragging={draggingSeek}
|
||||||
>
|
onpointerdown={(e) => startScrub(e, waveWrap)}
|
||||||
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
onpointermove={(e) => moveScrub(e, waveWrap)}
|
||||||
{#if hoverX >= 0}
|
onpointerup={(e) => endScrub(e, waveWrap)}
|
||||||
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
onpointercancel={(e) => endScrub(e, waveWrap)}
|
||||||
{/if}
|
>
|
||||||
</div>
|
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
||||||
{:else}
|
{#if hoverX >= 0 && player.duration}
|
||||||
<div
|
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
||||||
class="track"
|
{/if}
|
||||||
class:dragging={draggingSeek}
|
</div>
|
||||||
onpointerdown={(e) => startScrub(e, e.currentTarget)}
|
|
||||||
onpointermove={(e) => moveScrub(e, e.currentTarget)}
|
|
||||||
onpointerup={(e) => endScrub(e, e.currentTarget)}
|
|
||||||
onpointercancel={(e) => endScrub(e, e.currentTarget)}
|
|
||||||
role="slider"
|
|
||||||
aria-label="Seek"
|
|
||||||
aria-valuenow={Math.round(pct)}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
{#if bufferPct > 0 && player.track?.source === "stream"}
|
|
||||||
<div class="buffer" style="width: {bufferPct}%"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="fill" style="width: {pct}%"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="time">{fmt(player.duration)}</span>
|
<span class="time">{fmt(player.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -701,9 +818,10 @@
|
|||||||
at 560px while the flanks stretch, which is what left a wide window with
|
at 560px while the flanks stretch, which is what left a wide window with
|
||||||
a small waveform adrift in the middle.
|
a small waveform adrift in the middle.
|
||||||
|
|
||||||
`container-type` is for the shelf and the source slot below. This element
|
Nothing here is width-queried any more — the source slot was the only
|
||||||
spans all three grid columns, so its inline size IS the window's usable
|
container query, and it is gone — so there is no `container-type`. The
|
||||||
width — no sidebar or rail sits inside it. */
|
row fills its grid area at every window size; the scrubber absorbs
|
||||||
|
whatever the name, transport and extras do not take. */
|
||||||
.player {
|
.player {
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -716,7 +834,6 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background: var(--bg-player);
|
background: var(--bg-player);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
container-type: inline-size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-playing {
|
.now-playing {
|
||||||
@@ -724,12 +841,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
min-width: 170px;
|
min-width: 170px;
|
||||||
/* Capped, or a long title takes the bar: an unbounded cluster shrank
|
/* The song name sets this width, uncapped. `0 1 auto` means the cluster
|
||||||
the waveform to 106px on "A Very Long Track Title…". The lane is the
|
sits at its natural content width and gives ground ONLY once the
|
||||||
point of this bar, so the title ellipsises before the lane starves.
|
scrubber has reached its 260px floor — at which point the title
|
||||||
This is now the meta ALONE — the source line has its own slot — so
|
ellipsises, and not before. Chosen off
|
||||||
the budget no longer moves when a transfer line grows. */
|
`research/playbar-redesign/width-rule.html` against a capped variant:
|
||||||
max-width: min(32%, 420px);
|
the cap bounded how far the transport travelled between tracks (525px
|
||||||
|
vs 597px on the longest title in that set) at the cost of truncating
|
||||||
|
names that had room to be read. The name won. */
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +906,15 @@
|
|||||||
.who .sub {
|
.who .sub {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.who .sub:not(.album) {
|
/* ...but ONLY when there is an album to give way to. `55%` resolves against
|
||||||
|
`.who`, whose own width is shrink-to-fit of its children — so when the
|
||||||
|
artist is the only child, the cap is 55% of the artist's own width and it
|
||||||
|
eats itself: "M83" rendered as "M…" in a 12px box. That is exactly the
|
||||||
|
state the bar is in while a track loads, where the buffering branch puts
|
||||||
|
one child in this line. Measured, not reasoned: 23px of text in a 12px
|
||||||
|
box, with and without the old cluster cap, so this predates that change.
|
||||||
|
Alone, it keeps the default `0 1 auto` and ellipsises like anything else. */
|
||||||
|
.who .sub:not(.album):not(:only-child) {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
max-width: 55%;
|
max-width: 55%;
|
||||||
}
|
}
|
||||||
@@ -837,52 +964,6 @@
|
|||||||
}
|
}
|
||||||
.sub.album.link:hover { color: var(--text-primary); }
|
.sub.album.link:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
/* The source line. Width is written by sizeSourceSlot(), never by the
|
|
||||||
content: while a transfer runs its text changes on every position event,
|
|
||||||
and a slot that hugged it would slide the transport and the scrubber
|
|
||||||
sideways continuously. Border-box is global, so that width INCLUDES the
|
|
||||||
padding — otherwise the padding eats the content and clips the last chip. */
|
|
||||||
.src-slot {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition:
|
|
||||||
width 0.34s cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
padding 0.34s cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
.src-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: max-content;
|
|
||||||
flex: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
/* So a ticking percentage and a counting-down ETA do not reflow. */
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.src-line .sep {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.src-line .peer {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.qchip {
|
|
||||||
font-size: 9.5px;
|
|
||||||
font-weight: 800;
|
|
||||||
padding: 1.5px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-highlight);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.qchip.loss {
|
|
||||||
color: #7ee08a;
|
|
||||||
background: rgba(80, 200, 110, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -988,13 +1069,6 @@
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buffer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
background: hsla(0, 0%, 100%, 0.25);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Always the accent — matching the waveform scrubber's played region
|
/* Always the accent — matching the waveform scrubber's played region
|
||||||
(user decision 2026-07-23; the old white-until-hover read as off). */
|
(user decision 2026-07-23; the old white-until-hover read as off). */
|
||||||
.fill {
|
.fill {
|
||||||
@@ -1099,12 +1173,6 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* What gives way as the window narrows, ordered by how much is lost. The
|
|
||||||
source line goes first — it is enrichment, and the lane is not. */
|
|
||||||
@container (max-width: 1180px) {
|
|
||||||
.src-slot { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.vol-icon {
|
.vol-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
+62
-8
@@ -3391,6 +3391,7 @@ export async function refreshSourcePrefs() {
|
|||||||
// settings unavailable (fresh install / backend restart) → defaults
|
// settings unavailable (fresh install / backend restart) → defaults
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshSourcePrefs();
|
void refreshSourcePrefs();
|
||||||
|
|
||||||
/** Whether a peer file satisfies the user's quality window for this mode.
|
/** Whether a peer file satisfies the user's quality window for this mode.
|
||||||
@@ -4597,6 +4598,16 @@ export const player = $state({
|
|||||||
repeat: "off", // off | all | one
|
repeat: "off", // off | all | one
|
||||||
// waveform peaks (0..1) for the current local track, [] while loading
|
// waveform peaks (0..1) for the current local track, [] while loading
|
||||||
waveform: [],
|
waveform: [],
|
||||||
|
// How much of the TRACK `waveform` actually describes, 0..1 — reported by
|
||||||
|
// the backend with the peaks themselves, so the two can never disagree.
|
||||||
|
// 1 for local files and complete streams; a mid-transfer decode covers
|
||||||
|
// exactly this prefix and the lanes fold it across exactly this fraction.
|
||||||
|
// Where the standing bars end IS how much of the track has loaded.
|
||||||
|
waveformCoverage: 1,
|
||||||
|
// True while a waveform request for the CURRENT track is still working —
|
||||||
|
// what separates "computing, show the loading swell" from "there is no
|
||||||
|
// waveform, rest as dots". Cleared on success and on final failure.
|
||||||
|
wavePending: false,
|
||||||
// Which catalog entity is playing (drives now-playing badges).
|
// Which catalog entity is playing (drives now-playing badges).
|
||||||
catalogRef: { albumId: null, trackId: null },
|
catalogRef: { albumId: null, trackId: null },
|
||||||
// True for a track restored from the last session whose AUDIO did not
|
// True for a track restored from the last session whose AUDIO did not
|
||||||
@@ -4999,7 +5010,12 @@ export function initEvents() {
|
|||||||
} else if (
|
} else if (
|
||||||
ui.windowActive
|
ui.windowActive
|
||||||
&& cur?.stream
|
&& cur?.stream
|
||||||
&& performance.now() - waveRefreshAt > 3000
|
// Until the FIRST peaks land, retry fast — the earliest decodes
|
||||||
|
// race the transfer's opening seconds and often see too little
|
||||||
|
// file to succeed, and audio audibly playing over a still-empty
|
||||||
|
// lane was the gap being closed here. Once bars exist, the
|
||||||
|
// geometric decode thresholds make 3s the right cadence.
|
||||||
|
&& performance.now() - waveRefreshAt > (player.waveform.length ? 3000 : 700)
|
||||||
) {
|
) {
|
||||||
// Still downloading: refresh the partial waveform so it grows
|
// Still downloading: refresh the partial waveform so it grows
|
||||||
// with the transfer.
|
// with the transfer.
|
||||||
@@ -5207,6 +5223,10 @@ function playQueueIndex(i) {
|
|||||||
// Remote track streamed from a peer while it downloads to cache.
|
// Remote track streamed from a peer while it downloads to cache.
|
||||||
player.streamReceived = 0;
|
player.streamReceived = 0;
|
||||||
player.streamTotal = item.stream.size;
|
player.streamTotal = item.stream.size;
|
||||||
|
// A fresh track must not inherit the previous one's refresh clock —
|
||||||
|
// that throttled the FIRST partial waveform by up to three seconds of
|
||||||
|
// audible-but-laneless playback.
|
||||||
|
waveRefreshAt = 0;
|
||||||
resetStreamRate();
|
resetStreamRate();
|
||||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||||
appendedNextPath = null;
|
appendedNextPath = null;
|
||||||
@@ -5453,21 +5473,35 @@ async function maybeExtendAlbumQueue() {
|
|||||||
|
|
||||||
// ---- waveform ----
|
// ---- waveform ----
|
||||||
|
|
||||||
const WAVE_BUCKETS = 400;
|
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||||||
|
// by the time the lanes folded buckets into display bars every bar was ~2s
|
||||||
|
// averaged twice, and the transients the lane exists to show were gone before
|
||||||
|
// shaping ever ran (measured in waveshape.js's header). 2000 is ~128ms per
|
||||||
|
// bucket on a typical track — finer than any lane's bar pitch — and the
|
||||||
|
// command's ceiling is 4096, so this is only a constant.
|
||||||
|
const WAVE_BUCKETS = 2000;
|
||||||
let waveReqPath = null;
|
let waveReqPath = null;
|
||||||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||||||
let waveRefreshAt = 0;
|
let waveRefreshAt = 0;
|
||||||
|
|
||||||
async function loadWaveform(path) {
|
async function loadWaveform(path) {
|
||||||
player.waveform = [];
|
player.waveform = [];
|
||||||
|
player.wavePending = !!path;
|
||||||
waveReqPath = path;
|
waveReqPath = path;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
try {
|
try {
|
||||||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||||||
// Ignore if the track changed while we were computing.
|
// Ignore if the track changed while we were computing.
|
||||||
if (waveReqPath === path) player.waveform = peaks;
|
if (waveReqPath === path) {
|
||||||
|
player.waveform = peaks;
|
||||||
|
player.waveformCoverage = 1;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (waveReqPath === path) player.waveform = [];
|
if (waveReqPath === path) player.waveform = [];
|
||||||
|
} finally {
|
||||||
|
// Either way the request is over; a failed local decode rests as dots
|
||||||
|
// rather than swelling forever at a waveform that will never come.
|
||||||
|
if (waveReqPath === path) player.wavePending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5480,7 +5514,13 @@ let queuedStreamWave = null;
|
|||||||
|
|
||||||
async function loadStreamWaveform(item, final = false) {
|
async function loadStreamWaveform(item, final = false) {
|
||||||
const key = `stream:${item.stream.username}|${item.path}`;
|
const key = `stream:${item.stream.username}|${item.path}`;
|
||||||
if (waveReqPath !== key) player.waveform = [];
|
if (waveReqPath !== key) {
|
||||||
|
player.waveform = [];
|
||||||
|
// Pending until the FIRST partial decode lands; after that the lane
|
||||||
|
// has bars and the streaming rise owns the story. A mid-stream refresh
|
||||||
|
// that fails just keeps the bars it already has.
|
||||||
|
player.wavePending = true;
|
||||||
|
}
|
||||||
waveReqPath = key;
|
waveReqPath = key;
|
||||||
// A late partial FLAC decode can take close to the three-second refresh
|
// A late partial FLAC decode can take close to the three-second refresh
|
||||||
// interval. Coalesce all overlap into the newest request, retaining a
|
// interval. Coalesce all overlap into the newest request, retaining a
|
||||||
@@ -5498,7 +5538,12 @@ async function loadStreamWaveform(item, final = false) {
|
|||||||
next = null;
|
next = null;
|
||||||
const seq = ++waveSeq;
|
const seq = ++waveSeq;
|
||||||
try {
|
try {
|
||||||
const peaks = await api.streamWaveform(
|
// { coverage, peaks }: the backend reports how much of the
|
||||||
|
// track the peaks describe, measured from the SAME file read
|
||||||
|
// that produced them — the lanes' rise frontier is anchored to
|
||||||
|
// this, never to the live byte counter, so the drawn shape
|
||||||
|
// cannot stretch between refreshes.
|
||||||
|
const slice = await api.streamWaveform(
|
||||||
request.item.stream.username,
|
request.item.stream.username,
|
||||||
request.item.path,
|
request.item.path,
|
||||||
request.item.stream.size,
|
request.item.stream.size,
|
||||||
@@ -5507,12 +5552,19 @@ async function loadStreamWaveform(item, final = false) {
|
|||||||
if (
|
if (
|
||||||
waveReqPath === request.key
|
waveReqPath === request.key
|
||||||
&& seq === waveSeq
|
&& seq === waveSeq
|
||||||
&& peaks.length > 0
|
&& slice.peaks.length > 0
|
||||||
) {
|
) {
|
||||||
player.waveform = peaks;
|
player.waveform = slice.peaks;
|
||||||
|
player.waveformCoverage = Math.max(0, Math.min(1, slice.coverage));
|
||||||
|
player.wavePending = false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// stays empty — the buffer/progress bar renders instead
|
// Peaks stay as they were. A failed FINAL decode is the end of
|
||||||
|
// the line though — stop the loading swell so the lane rests
|
||||||
|
// as dots instead of promising a waveform that will not come.
|
||||||
|
if (request.final && waveReqPath === request.key) {
|
||||||
|
player.wavePending = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const queued = queuedStreamWave;
|
const queued = queuedStreamWave;
|
||||||
queuedStreamWave = null;
|
queuedStreamWave = null;
|
||||||
@@ -5598,6 +5650,8 @@ function stopPlayback() {
|
|||||||
player.queue = [];
|
player.queue = [];
|
||||||
player.queueIndex = -1;
|
player.queueIndex = -1;
|
||||||
player.waveform = [];
|
player.waveform = [];
|
||||||
|
player.waveformCoverage = 1;
|
||||||
|
player.wavePending = false;
|
||||||
player.catalogRef = { albumId: null, trackId: null };
|
player.catalogRef = { albumId: null, trackId: null };
|
||||||
latestPlayerPosition = 0;
|
latestPlayerPosition = 0;
|
||||||
latestPlayerDuration = null;
|
latestPlayerDuration = null;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// The ONE waveform shaping, shared by the player bar's canvas and the Now
|
||||||
|
// Playing rail's DOM bars. Both fold the same source array with the same
|
||||||
|
// statistic and stretch it by the same rule, so the two lanes in the window
|
||||||
|
// are the same instrument — change a number here and both surfaces move.
|
||||||
|
//
|
||||||
|
// Why this exists (measured, 2026-08-03, on a real decode of "Only Shallow" —
|
||||||
|
// 27.7MB FLAC, 11,093 RMS windows; the harness is
|
||||||
|
// `bucketing_strategies_on_a_real_track` in crates/player/src/waveform.rs and
|
||||||
|
// the approved before/after is research/playbar-redesign/waveform-shaping.html):
|
||||||
|
//
|
||||||
|
// The old rule — mean-fold, then `min(0.88, v * 1.18) ** 0.8` — was a fixed
|
||||||
|
// gain into a hard clamp. On that track the MEDIAN source value crossed the
|
||||||
|
// clamp, so 53.8% of the lane rendered at literally one height, and the rail
|
||||||
|
// hid the ruler under per-bar noise (deleted the same day). This rule flat-tops
|
||||||
|
// 5.0% (the designed outlier clip), σ 0.161 → 0.269, IQR 0.084 → 0.230.
|
||||||
|
//
|
||||||
|
// The steps, and what each buys:
|
||||||
|
// 1. Fold with a mean+peak BLEND, leaning peak: a pure mean averages the
|
||||||
|
// loudest ~128ms window in a bar's span away, which is precisely the
|
||||||
|
// transient a scrub lane exists to show.
|
||||||
|
// 2. Stretch the folded values across the TRACK'S OWN range — its quiet
|
||||||
|
// percentile to 0, its loud percentile to 1. A loud master no longer pins
|
||||||
|
// to the ceiling and a quiet one is no longer a hairline, because the scale
|
||||||
|
// comes from the record, not from a constant. The few bars above the
|
||||||
|
// ceiling percentile clip to full height by design: outlier control, ~3%,
|
||||||
|
// not half the lane. This mapping is scale-invariant, so the Rust side's
|
||||||
|
// normalize-by-loudest-bucket stops mattering.
|
||||||
|
// 3. A small gamma and a floor: the gamma keeps the mid-range readable, the
|
||||||
|
// floor keeps a silence from vanishing into the lane background.
|
||||||
|
|
||||||
|
/** Bar geometry both lanes draw on: a 2px bar on a 4px gap. Pitch 6 chosen off
|
||||||
|
* research/nowplaying-fans-waveform/mockup.html (W1). */
|
||||||
|
export const BAR_PITCH = 6;
|
||||||
|
export const BAR_W = 2;
|
||||||
|
|
||||||
|
// The lane's motion, tuned by Kyle on
|
||||||
|
// research/playbar-redesign/waveform-loading2.html (2026-08-03). Two motions
|
||||||
|
// exist: the LOADING SWELL (one crest crossing the resting dots while a
|
||||||
|
// waveform is being computed) and the STREAMING RISE (bars standing up
|
||||||
|
// left→right as the download arrives — released on a fixed rhythm, so a fast
|
||||||
|
// transfer cannot machine-gun the lane). The Now Playing rail mirrors both in
|
||||||
|
// CSS; its keyframe/transition literals pair with these numbers and must be
|
||||||
|
// changed together (see NowPlaying.svelte's lane styles).
|
||||||
|
export const SWELL_SWEEP_MS = 2000; // one crossing of the lane
|
||||||
|
export const SWELL_REST_MS = 500; // calm between crossings
|
||||||
|
export const SWELL_AMP_PX = 7; // crest lift, playbar px (rail scales ×1.6)
|
||||||
|
export const SWELL_WIDTH_BARS = 54; // gaussian width of the crest
|
||||||
|
export const RISE_MS = 2000; // one bar, floor → full height
|
||||||
|
export const RISE_CADENCE_MS = 40; // gap between one bar starting and the next
|
||||||
|
/** Resting/queued bars render the muted grey at this opacity — the rail's
|
||||||
|
* pending dim, shared so the two lanes' dots match. */
|
||||||
|
export const PENDING_ALPHA = 0.45;
|
||||||
|
|
||||||
|
/** Mean+peak blend per display bar, leaning peak — see step 1 above. */
|
||||||
|
const BLEND_MEAN = 0.35;
|
||||||
|
const BLEND_PEAK = 0.65;
|
||||||
|
/** The track's own range: p05 maps to the floor, p97 to full height. */
|
||||||
|
const STRETCH_LO = 0.05;
|
||||||
|
const STRETCH_HI = 0.97;
|
||||||
|
/** Mild gamma; <1 lifts the mid-range without crushing the top. */
|
||||||
|
const CURVE = 0.85;
|
||||||
|
/** No bar renders below 12% of the lane, so silence still reads as a lane. */
|
||||||
|
const FLOOR = 0.12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold `peaks` (the raw array from the `waveform` command) into `count`
|
||||||
|
* display levels in 0..1, shaped as above. Returns [] when there is nothing
|
||||||
|
* to show, matching the callers' "no waveform yet" branches.
|
||||||
|
*/
|
||||||
|
export function waveLevels(peaks, count) {
|
||||||
|
if (!peaks || peaks.length === 0 || count <= 0) return [];
|
||||||
|
const folded = new Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const from = Math.floor((i / count) * peaks.length);
|
||||||
|
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * peaks.length));
|
||||||
|
let sum = 0;
|
||||||
|
let max = 0;
|
||||||
|
for (let j = from; j < to; j++) {
|
||||||
|
const v = peaks[j] ?? 0;
|
||||||
|
sum += v;
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
folded[i] = (sum / (to - from)) * BLEND_MEAN + max * BLEND_PEAK;
|
||||||
|
}
|
||||||
|
const sorted = [...folded].sort((a, b) => a - b);
|
||||||
|
const q = (f) => sorted[Math.min(sorted.length - 1, Math.floor(f * (sorted.length - 1)))];
|
||||||
|
const lo = q(STRETCH_LO);
|
||||||
|
const span = Math.max(1e-6, q(STRETCH_HI) - lo);
|
||||||
|
return folded.map((v) => {
|
||||||
|
const stretched = Math.max(0, Math.min(1, (v - lo) / span));
|
||||||
|
return FLOOR + Math.pow(stretched, CURVE) * (1 - FLOOR);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user