playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14404 -584

No files matched your search

Generated
+20 -1
View File
@@ -163,6 +163,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
name = "app"
version = "0.1.0-alpha.3"
dependencies = [
"audio-formats",
"base64 0.22.1",
"image",
"keyring",
@@ -170,6 +171,8 @@ dependencies = [
"log",
"metadata",
"player",
"quick-xml 0.37.5",
"reqwest 0.12.28",
"serde",
"serde_json",
"soulseek-client",
@@ -231,6 +234,10 @@ dependencies = [
"url",
]
[[package]]
name = "audio-formats"
version = "0.1.0-alpha.3"
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -2299,6 +2306,7 @@ dependencies = [
name = "library"
version = "0.1.0-alpha.3"
dependencies = [
"audio-formats",
"log",
"rusqlite",
"serde",
@@ -3080,6 +3088,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
name = "player"
version = "0.1.0-alpha.3"
dependencies = [
"audio-formats",
"env_logger",
"log",
"rodio",
@@ -3096,7 +3105,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.41.0",
"serde",
"time",
]
@@ -3266,6 +3275,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -4079,6 +4097,7 @@ dependencies = [
name = "soulseek-client"
version = "0.1.0-alpha.3"
dependencies = [
"audio-formats",
"bytes",
"env_logger",
"igd-next",
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/tagger",
"crates/metadata",
"crates/library",
"crates/audio-formats",
]
[workspace.package]
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "audio-formats"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
+166
View File
@@ -0,0 +1,166 @@
//! What counts as audio, and what the player can actually decode.
//!
//! These were seven separate hand-kept lists — the library scanner, the share
//! index, the row pruner, the loudness pass, and three in the frontend — and
//! they disagreed. The scanner indexed `.aiff`, the pruner did not recognise it
//! and DELETED the rows on the next startup. The share index knew `.ape`, the
//! scanner did not. Every list named `.opus`, which nothing here can decode.
//!
//! One table now, with a distinction the old lists could not express:
//!
//! - [`PLAYABLE`] — the decoder can turn this into sound. Derived from the
//! codecs symphonia is actually built with (`all-codecs` + `all-formats`:
//! aac, adpcm, alac, flac, mp1/2/3, pcm, vorbis; caf, isomp4, mkv, ogg,
//! aiff, wav) and verified against real files by
//! `cargo run -p player --example probe_formats`.
//! - [`RECOGNISED`] — audio we can index, share, download and show, but cannot
//! play. Naming these is the point: a file the app can see and refuses to
//! play with a clear reason is honest, whereas leaving it out of the tables
//! makes it invisible, and claiming it plays makes the player fail at the
//! moment the user presses play.
//!
//! Adding a codec means adding a decoder, not editing a list.
/// Extensions the player can decode. Verified, not assumed.
pub const PLAYABLE: &[&str] = &[
// FLAC
"flac",
// MPEG audio — the bundle provides layers I, II and III
"mp3", "mp2", "mp1",
// ISO/MP4 container: AAC and ALAC live here
"m4a", "m4b", "mp4", "m4r",
// Raw AAC (ADTS)
"aac",
// Ogg container, Vorbis inside
"ogg", "oga",
// RIFF: PCM and ADPCM
"wav", "wave",
// AIFF / AIFF-C, also via the RIFF reader
"aiff", "aif", "aifc",
// Core Audio Format
"caf",
// Matroska — FLAC/Vorbis/AAC in a different wrapper
"mka", "webm",
];
/// Audio we recognise but cannot decode. Indexed and shared; never played.
///
/// `wv` earns its place here more than any of them. A WavPack file that
/// carries the original RIFF header in its wrapper is probed as raw PCM, so
/// the decoder does not fail — it reads compressed bytes as samples and emits
/// NaN into the mixer. Refusing it by extension is the only thing that stops
/// that, because the decoder itself reports success.
pub const RECOGNISED: &[&str] = &[
"opus", // no Opus decoder is built in
"wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
];
/// Is this an audio file at all — playable or merely recognised?
///
/// The scanner, the share index and the row pruner must all answer this the
/// same way, or a file is indexed by one and deleted by another.
pub fn is_audio_ext(ext: &str) -> bool {
let e = ext.to_ascii_lowercase();
PLAYABLE.contains(&e.as_str()) || RECOGNISED.contains(&e.as_str())
}
/// Can the player turn this into sound?
pub fn is_playable_ext(ext: &str) -> bool {
PLAYABLE.contains(&ext.to_ascii_lowercase().as_str())
}
/// Why this file will not play, in words that can go on screen.
pub fn unplayable_reason(ext: &str) -> Option<&'static str> {
match ext.to_ascii_lowercase().as_str() {
"opus" => Some("Opus isn't supported yet — this build has no Opus decoder"),
"wv" => Some("WavPack isn't supported yet"),
"ape" => Some("Monkey's Audio (APE) isn't supported yet"),
"wma" => Some("Windows Media Audio isn't supported yet"),
"mpc" => Some("Musepack isn't supported yet"),
"tta" => Some("True Audio isn't supported yet"),
"shn" => Some("Shorten isn't supported yet"),
"dsf" | "dff" => Some("DSD isn't supported yet"),
e if RECOGNISED.contains(&e) => Some("this format isn't supported yet"),
_ => None,
}
}
fn ext_of(path: &std::path::Path) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
}
pub fn is_audio_path(path: &std::path::Path) -> bool {
ext_of(path).is_some_and(|e| is_audio_ext(&e))
}
pub fn is_playable_path(path: &std::path::Path) -> bool {
ext_of(path).is_some_and(|e| is_playable_ext(&e))
}
/// Why this path will not play. `None` means "go ahead and try".
pub fn unplayable_path_reason(path: &std::path::Path) -> Option<&'static str> {
unplayable_reason(&ext_of(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
/// The bug the single table exists to prevent: the scanner indexed a
/// format the pruner did not recognise, so importing an AIFF album put it
/// in the library and the next startup silently deleted it.
#[test]
fn everything_indexed_survives_the_pruner() {
for ext in PLAYABLE.iter().chain(RECOGNISED.iter()) {
assert!(is_audio_ext(ext), "{ext} indexed but not recognised as audio");
}
// The extensions that were actually in the divergence.
for ext in ["aiff", "aif", "ape", "wv", "opus"] {
assert!(is_audio_ext(ext), "{ext} must be recognised");
}
}
/// A format cannot be both — every entry has one answer.
#[test]
fn no_extension_is_in_both_tables() {
for e in PLAYABLE {
assert!(!RECOGNISED.contains(e), "{e} is in both tables");
}
}
/// Recognised-but-not-playable must carry a reason, or the UI has nothing
/// to say when it refuses.
#[test]
fn every_unplayable_format_can_explain_itself() {
for e in RECOGNISED {
assert!(unplayable_reason(e).is_some(), "{e} has no reason");
}
for e in PLAYABLE {
assert!(unplayable_reason(e).is_none(), "{e} plays but claims it cannot");
}
}
/// WavPack is the dangerous one: the decoder does NOT fail on it, so only
/// an extension check keeps NaN out of the mixer.
#[test]
fn wavpack_is_refused_before_the_decoder_sees_it() {
assert!(!is_playable_ext("wv"));
assert!(is_audio_ext("wv"));
assert_eq!(
unplayable_path_reason(Path::new("C:\\m\\a.WV")),
Some("WavPack isn't supported yet"),
"case must not decide whether audio plays"
);
}
#[test]
fn case_and_paths() {
assert!(is_playable_ext("FLAC"));
assert!(is_playable_path(Path::new("C:\\m\\01 - Song.Mp3")));
assert!(!is_audio_path(Path::new("C:\\m\\cover.jpg")));
assert!(!is_audio_path(Path::new("C:\\m\\noext")));
}
}
+1
View File
@@ -6,6 +6,7 @@ rust-version.workspace = true
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
audio-formats = { path = "../audio-formats" }
serde.workspace = true
thiserror.workspace = true
log.workspace = true
+1353 -76
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
rust-version.workspace = true
[dependencies]
audio-formats = { path = "../audio-formats" }
rodio = { version = "0.21", features = ["symphonia-all"] }
symphonia = { version = "0.5", features = ["all"] }
thiserror.workspace = true
+187
View File
@@ -0,0 +1,187 @@
//! Ask the REAL decoder what it can open.
//!
//! The app carries several hand-maintained lists of "audio extensions", and a
//! list is a claim, not a capability — the only thing that settles whether a
//! file plays is building the same decoder the player builds.
//!
//! cargo run -p player --example probe_formats -- <file-or-folder> [more...]
//!
//! A folder is walked and one file per extension is probed, which is what makes
//! this cheap to point at a whole library.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
eprintln!("usage: probe_formats <file-or-folder>...");
std::process::exit(2);
}
// One representative per extension: the question is "can this FORMAT be
// decoded", and a thousand MP3s answer it no better than one.
let mut sample: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for a in &args {
collect(Path::new(a), &mut sample, &mut counts, 0);
}
println!("{:<8} {:>7} {:<9} {}", "EXT", "FILES", "VERDICT", "DETAIL");
for (ext, path) in &sample {
let (verdict, detail) = probe(path);
println!(
"{:<8} {:>7} {:<9} {}",
ext,
counts.get(ext).copied().unwrap_or(0),
verdict,
detail
);
}
}
fn collect(
p: &Path,
sample: &mut BTreeMap<String, PathBuf>,
counts: &mut BTreeMap<String, usize>,
depth: u8,
) {
if depth > 8 {
return;
}
if p.is_dir() {
let Ok(entries) = std::fs::read_dir(p) else { return };
for e in entries.flatten() {
collect(&e.path(), sample, counts, depth + 1);
}
return;
}
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { return };
let ext = ext.to_ascii_lowercase();
// Anything that is plausibly audio, INCLUDING formats no list mentions —
// the point is to discover the gap, not to confirm the lists.
const PLAUSIBLE: &[&str] = &[
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "aac", "alac", "ogg", "oga", "opus",
"wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm", "wma", "ape", "wv", "mpc",
"tta", "shn", "dsf", "dff", "ra", "au", "amr", "3gp", "spx",
];
if !PLAUSIBLE.contains(&ext.as_str()) {
return;
}
*counts.entry(ext.clone()).or_insert(0) += 1;
sample.entry(ext).or_insert_with(|| p.to_path_buf());
}
/// Build exactly what `player` builds for a local file.
fn probe(path: &Path) -> (&'static str, String) {
use rodio::Decoder;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => return ("ERROR", format!("open failed: {e}")),
};
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let reader = std::io::BufReader::new(file);
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
if len > 0 {
builder = builder.with_byte_len(len);
}
// What did symphonia actually RECOGNISE? A verdict of "plays" is worth
// little if the container was mistaken for something else, so name the
// format and codec rather than inferring capability from the extension.
let detected = detect(path).unwrap_or_else(|| "?".into());
match builder.build() {
Ok(d) => {
// Opening is not playing. A container can parse while its codec is
// missing, and a truncated decode looks like success if you only
// pull a few thousand samples — so decode the WHOLE thing and check
// the duration against the file. A 30 MB lossless track that yields
// two seconds of audio has not been decoded, it has been guessed at.
use rodio::Source as _;
let rate = d.sample_rate();
let ch = d.channels();
let mut src = d;
// Amplitude tells apart "decoded" from "mis-identified". Compressed
// bytes read as raw PCM are full-scale noise: near-100% of samples
// land loud, where real music spends most of its time quiet.
let (mut n, mut sumsq, mut hot, mut bad) = (0u64, 0f64, 0u64, 0u64);
for s in src.by_ref() {
let v = s as f64;
// A non-finite sample is proof on its own: no decoder emits
// NaN from real audio. It comes from compressed bytes being
// reinterpreted as floats, and it poisons every mixer and
// normaliser stage downstream of here.
if !v.is_finite() {
bad += 1;
n += 1;
continue;
}
sumsq += v * v;
if v.abs() > 0.5 {
hot += 1;
}
n += 1;
}
let rms = if n > 0 { (sumsq / n as f64).sqrt() } else { 0.0 };
let hot_pct = if n > 0 { hot as f64 * 100.0 / n as f64 } else { 0.0 };
let frames = n as f64 / (ch.max(1) as f64);
let secs = frames / rate.max(1) as f64;
let mb = len as f64 / 1_048_576.0;
if secs < 1.0 {
return ("NO AUDIO", format!("{rate} Hz, {ch}ch — {secs:.2}s from {mb:.1} MB"));
}
// Even the densest lossless runs well under 10 MB per second.
if mb / secs > 10.0 {
return (
"PARTIAL",
format!("{rate} Hz, {ch}ch — only {secs:.1}s from {mb:.1} MB"),
);
}
// Half the samples above half scale is not music. This is the
// WavPack case: a RIFF header inside the wrapper makes symphonia
// read the compressed body as PCM, so it "succeeds" into noise —
// which is worse than refusing, because nothing reports an error.
if bad > 0 {
return (
"GARBAGE",
format!(
"{detected} mis-identified — {} of {n} samples are NaN/Inf",
bad
),
);
}
if hot_pct > 40.0 && rms > 0.35 {
return (
"NOISE",
format!("{detected} mis-identified — rms {rms:.2}, {hot_pct:.0}% of samples above half scale"),
);
}
(
"PLAYS",
format!("{detected} {rate} Hz, {ch}ch, {secs:.0}s, rms {rms:.2}"),
)
}
Err(e) => ("CANNOT", format!("{detected} {e}")),
}
}
/// The codec symphonia identifies, straight from the probe — the ground truth
/// behind a "plays" verdict.
fn detect(path: &Path) -> Option<String> {
use symphonia::core::io::MediaSourceStream;
use symphonia::core::probe::Hint;
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(&hint, mss, &Default::default(), &Default::default())
.ok()?;
let track = probed.format.default_track()?;
let codec = track.codec_params.codec;
let name = symphonia::default::get_codecs()
.get_codec(codec)
.map(|d| d.short_name.to_string())
.unwrap_or_else(|| format!("codec:{codec:?}"));
Some(name)
}
+14
View File
@@ -8,6 +8,7 @@
mod loudness;
mod source;
pub mod vocals;
mod waveform;
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
@@ -494,6 +495,19 @@ fn start_decoder(
.into(),
)
.spawn(move || {
// Refuse formats there is no decoder for, BEFORE opening them.
//
// Most simply fail to build and report themselves. WavPack does
// not: a `.wv` carrying the original RIFF header in its wrapper is
// probed as raw PCM, so the decoder reports success and streams the
// compressed body as samples — measured at 136,815 NaN/Inf values
// in a single track, straight into the mixer and the normaliser.
// An extension check is the only thing that can stop that, because
// by the time there is a decoder there is no error to notice.
if let Some(reason) = audio_formats::unplayable_path_reason(&track.path) {
let _ = tx.send(Err((generation, reason.to_string())));
return;
}
let (reader, byte_len): (Box<dyn source::MediaRead>, u64) = match track.stream.clone() {
Some(progress) => {
match GrowingFile::open(&track.path, track.total_bytes, progress) {
+512
View File
@@ -0,0 +1,512 @@
//! Vocal-band DSP for the lyrics feature: a leading-silence probe (auto-sync
//! evidence) and an offline vocal-reduction renderer (the classic band-limited
//! center-cancel, NOT a learned separator — honest about what it is).
//!
//! Everything here is offline file work. Nothing touches the live audio
//! engine: the renderer writes an ordinary WAV that plays through the normal
//! local-file path, so a bug in this module can never destabilize playback.
use std::io::Write as _;
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// Longest source the renderer accepts. A WAV render holds the whole track
/// in memory (16-bit stereo ≈ 10.6 MB/min at 44.1k); twenty minutes bounds
/// that at ~212 MB and excludes accidental full-set files.
const MAX_RENDER_SECS: f64 = 20.0 * 60.0;
/// RMS floor treated as "audible": −50 dBFS, sustained. Modern releases sit
/// far above this; dithered lead-in noise sits below it.
const SILENCE_RMS: f32 = 0.003_16;
/// Frame size for the silence scan (about 20 ms at 44.1 kHz).
const SILENCE_FRAME: usize = 1024;
/// How many consecutive audible frames end the silence (~60 ms) — a single
/// vinyl tick or DC blip must not count as the music starting.
const SILENCE_RUN: usize = 3;
/// The silence probe never reads past this point; a track that has not
/// started within thirty seconds is not a case auto-sync should judge.
const SILENCE_SCAN_SECS: f64 = 30.0;
// ---- decoding --------------------------------------------------------------
struct Decoded {
/// Interleaved f32 frames.
samples: Vec<f32>,
channels: usize,
sample_rate: u32,
}
/// Decode `path` fully (or up to `limit_secs`) into interleaved f32.
fn decode(path: &Path, limit_secs: Option<f64>) -> Result<Decoded, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| e.to_string())?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or("no default audio track")?
.clone();
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| e.to_string())?;
let mut samples = Vec::new();
let mut channels = 0usize;
let mut sample_rate = 0u32;
let mut buffer: Option<SampleBuffer<f32>> = None;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(_)) => break,
Err(symphonia::core::errors::Error::ResetRequired) => break,
Err(_) => break,
};
if packet.track_id() != track.id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(_) => break,
};
let spec = *decoded.spec();
channels = spec.channels.count();
sample_rate = spec.rate;
let out = buffer.get_or_insert_with(|| {
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec)
});
if out.capacity() < decoded.capacity() * channels {
*out = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
}
out.copy_interleaved_ref(decoded);
samples.extend_from_slice(out.samples());
if let Some(limit) = limit_secs {
if sample_rate > 0
&& (samples.len() / channels.max(1)) as f64 / sample_rate as f64 >= limit
{
break;
}
}
}
if samples.is_empty() || channels == 0 || sample_rate == 0 {
return Err("nothing decodable".into());
}
Ok(Decoded {
samples,
channels,
sample_rate,
})
}
// ---- leading silence -------------------------------------------------------
/// Milliseconds of leading silence in `path`, or None when the file cannot be
/// decoded / never becomes audible inside the scan window.
pub fn leading_silence_ms(path: &Path) -> Option<u64> {
let decoded = decode(path, Some(SILENCE_SCAN_SECS)).ok()?;
let frames_rms = fold_rms(&decoded.samples, decoded.channels, SILENCE_FRAME);
let onset_frame = onset_from_rms(&frames_rms)?;
Some(
(onset_frame as u64 * SILENCE_FRAME as u64 * 1000)
/ decoded.sample_rate.max(1) as u64,
)
}
/// Interleaved samples → per-frame mono RMS.
fn fold_rms(samples: &[f32], channels: usize, frame: usize) -> Vec<f32> {
let channels = channels.max(1);
let per_frame = frame * channels;
samples
.chunks(per_frame)
.map(|chunk| {
let sum: f32 = chunk.iter().map(|s| s * s).sum();
(sum / chunk.len().max(1) as f32).sqrt()
})
.collect()
}
/// First frame index beginning [`SILENCE_RUN`] consecutive audible frames.
fn onset_from_rms(rms: &[f32]) -> Option<usize> {
let mut run = 0usize;
for (index, &value) in rms.iter().enumerate() {
if value > SILENCE_RMS {
run += 1;
if run >= SILENCE_RUN {
return Some(index + 1 - run);
}
} else {
run = 0;
}
}
None
}
// ---- vocal activity --------------------------------------------------------
/// A coarse "is someone likely singing here" envelope: the energy of the
/// CENTER (mid) channel band-passed to the vocal range, one value per
/// `frame_ms`, normalized 0..1 against its own loud end. The same physics
/// the vocal reducer exploits — center-mixed vocals dominate the mid band —
/// read instead of removed. A proxy, not a detector: instruments living
/// center-mid (lead guitar, synth hooks) light it up too, which is why the
/// rough-sync generator that consumes it is labeled ESTIMATED and hands the
/// result to the correction editor rather than presenting it as truth.
pub struct VocalActivity {
pub frame_ms: u32,
/// 0..1 per frame.
pub frames: Vec<f32>,
}
pub fn vocal_activity(path: &Path, frame_ms: u32) -> Result<VocalActivity, String> {
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
let frame_ms = frame_ms.clamp(40, 500);
let frame_len = (decoded.sample_rate as usize * frame_ms as usize) / 1000;
let channels = decoded.channels;
let mut low = Biquad::highpass(decoded.sample_rate as f32, 180.0);
let mut high = Biquad::lowpass(decoded.sample_rate as f32, 7_500.0);
let mut frames = Vec::new();
let mut acc = 0.0f64;
let mut n = 0usize;
for frame in decoded.samples.chunks(channels) {
let mid = if channels >= 2 {
0.5 * (frame[0] + frame[1])
} else {
frame[0]
};
let banded = high.process(low.process(mid));
acc += (banded * banded) as f64;
n += 1;
if n >= frame_len {
frames.push((acc / n as f64).sqrt() as f32);
acc = 0.0;
n = 0;
}
}
if n > frame_len / 4 {
frames.push((acc / n.max(1) as f64).sqrt() as f32);
}
if frames.is_empty() {
return Err("nothing decodable".into());
}
// Normalize against the loud end (95th percentile), so one hot chorus
// does not flatten the whole song to zero.
let mut sorted: Vec<f32> = frames.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95 = sorted[(sorted.len() * 95 / 100).min(sorted.len() - 1)].max(1e-6);
for value in &mut frames {
*value = (*value / p95).min(1.0);
}
Ok(VocalActivity { frame_ms, frames })
}
// ---- vocal reduction -------------------------------------------------------
/// What the renderer produced (for the caller's labels and sanity checks).
pub struct VocalReduction {
pub duration_secs: f64,
pub sample_rate: u32,
}
/// Render a vocal-reduced copy of `src` to `dest_wav` (16-bit stereo WAV).
///
/// The method is the classic out-of-phase cancellation with band limiting:
/// the center (mid) channel is removed in the vocal band while the lows
/// (bass, kick) and the highest air band are kept, and the side content
/// passes through untouched. It works in proportion to how center-mixed the
/// lead vocal is — modern wide/reverbed vocals survive partially. That limit
/// is inherent to the technique; the honest fix is the future learned
/// separator the research bundle scopes.
pub fn render_vocal_reduced(src: &Path, dest_wav: &Path) -> Result<VocalReduction, String> {
// The decode itself stops just past the cap, so an accidental multi-hour
// file cannot balloon memory before the refusal fires.
let decoded = decode(src, Some(MAX_RENDER_SECS + 2.0))?;
if decoded.channels < 2 {
return Err("This recording is mono — there is no center channel to cancel".into());
}
let frames = decoded.samples.len() / decoded.channels;
let duration = frames as f64 / decoded.sample_rate as f64;
if duration > MAX_RENDER_SECS {
return Err("Track is too long for the instrumental renderer (20 min cap)".into());
}
// First two channels carry the stereo image; extra channels are dropped.
let mut stereo: Vec<f32> = Vec::with_capacity(frames * 2);
for frame in decoded.samples.chunks(decoded.channels) {
stereo.push(frame[0]);
stereo.push(frame[1]);
}
process_center_cancel(&mut stereo, decoded.sample_rate);
write_wav_16(dest_wav, &stereo, decoded.sample_rate)?;
Ok(VocalReduction {
duration_secs: duration,
sample_rate: decoded.sample_rate,
})
}
/// In-place band-limited center cancellation over interleaved stereo.
///
/// out_L = side + LP(mid) + HP(mid); out_R = −side + LP(mid) + HP(mid) —
/// removing the mid band where vocals live (~180 Hz–7.5 kHz) and keeping the
/// side image, the lows, and the air.
fn process_center_cancel(stereo: &mut [f32], sample_rate: u32) {
let mut low = Biquad::lowpass(sample_rate as f32, 180.0);
let mut high = Biquad::highpass(sample_rate as f32, 7_500.0);
let keep_air = sample_rate >= 22_050;
for frame in stereo.chunks_mut(2) {
let (l, r) = (frame[0], frame[1]);
let mid = 0.5 * (l + r);
let side = 0.5 * (l - r);
let lows = low.process(mid);
let air = if keep_air { high.process(mid) } else { 0.0 };
let kept = lows + air;
frame[0] = ((side + kept) * 0.95).clamp(-1.0, 1.0);
frame[1] = ((kept - side) * 0.95).clamp(-1.0, 1.0);
}
}
/// RBJ-cookbook biquad, Butterworth Q.
struct Biquad {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl Biquad {
fn new(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Biquad {
Biquad {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
fn lowpass(sample_rate: f32, fc: f32) -> Biquad {
let w = 2.0 * std::f32::consts::PI * fc / sample_rate;
// Butterworth: alpha = sin(w)/(2·Q) with Q = 1/√2.
let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2;
let c = w.cos();
Biquad::new(
(1.0 - c) / 2.0,
1.0 - c,
(1.0 - c) / 2.0,
1.0 + alpha,
-2.0 * c,
1.0 - alpha,
)
}
fn highpass(sample_rate: f32, fc: f32) -> Biquad {
let w = 2.0 * std::f32::consts::PI * fc / sample_rate;
let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2;
let c = w.cos();
Biquad::new(
(1.0 + c) / 2.0,
-(1.0 + c),
(1.0 + c) / 2.0,
1.0 + alpha,
-2.0 * c,
1.0 - alpha,
)
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
}
/// Minimal 16-bit PCM stereo WAV writer (44-byte canonical header).
fn write_wav_16(dest: &Path, interleaved: &[f32], sample_rate: u32) -> Result<(), String> {
let data_len = (interleaved.len() * 2) as u32;
let mut out = std::io::BufWriter::new(
std::fs::File::create(dest).map_err(|e| e.to_string())?,
);
let byte_rate = sample_rate * 2 * 2;
let mut header = Vec::with_capacity(44);
header.extend_from_slice(b"RIFF");
header.extend_from_slice(&(36 + data_len).to_le_bytes());
header.extend_from_slice(b"WAVEfmt ");
header.extend_from_slice(&16u32.to_le_bytes());
header.extend_from_slice(&1u16.to_le_bytes()); // PCM
header.extend_from_slice(&2u16.to_le_bytes()); // stereo
header.extend_from_slice(&sample_rate.to_le_bytes());
header.extend_from_slice(&byte_rate.to_le_bytes());
header.extend_from_slice(&4u16.to_le_bytes()); // block align
header.extend_from_slice(&16u16.to_le_bytes()); // bits
header.extend_from_slice(b"data");
header.extend_from_slice(&data_len.to_le_bytes());
out.write_all(&header).map_err(|e| e.to_string())?;
let mut bytes = Vec::with_capacity(interleaved.len() * 2);
for &sample in interleaved {
bytes.extend_from_slice(&((sample.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes());
}
out.write_all(&bytes).map_err(|e| e.to_string())?;
out.flush().map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn rms(stereo: &[f32]) -> f32 {
(stereo.iter().map(|s| s * s).sum::<f32>() / stereo.len() as f32).sqrt()
}
fn sine_stereo(freq: f32, secs: f32, rate: u32, pan: &str) -> Vec<f32> {
let frames = (secs * rate as f32) as usize;
let mut out = Vec::with_capacity(frames * 2);
for i in 0..frames {
let s = (2.0 * std::f32::consts::PI * freq * i as f32 / rate as f32).sin() * 0.5;
match pan {
"center" => {
out.push(s);
out.push(s);
}
"left" => {
out.push(s);
out.push(0.0);
}
// Anti-phase: pure side content.
_ => {
out.push(s);
out.push(-s);
}
}
}
out
}
/// The whole point: a center-panned vocal-band tone dies, a center bass
/// tone survives, side content survives.
#[test]
fn center_vocal_band_is_cancelled_lows_and_sides_survive() {
let rate = 44_100;
let mut vocal = sine_stereo(1_000.0, 0.5, rate, "center");
let before = rms(&vocal);
process_center_cancel(&mut vocal, rate);
assert!(
rms(&vocal) < before * 0.06,
"center 1kHz must drop >24dB, got {} → {}",
before,
rms(&vocal)
);
let mut bass = sine_stereo(60.0, 0.5, rate, "center");
let bass_before = rms(&bass);
process_center_cancel(&mut bass, rate);
assert!(
rms(&bass) > bass_before * 0.6,
"center 60Hz must survive the low-band keep, got {} → {}",
bass_before,
rms(&bass)
);
let mut side = sine_stereo(1_000.0, 0.5, rate, "side");
let side_before = rms(&side);
process_center_cancel(&mut side, rate);
assert!(
rms(&side) > side_before * 0.7,
"side content must pass through, got {} → {}",
side_before,
rms(&side)
);
}
#[test]
fn onset_ignores_a_lone_tick() {
// 40 silent frames, one loud blip, more silence, then real audio.
let mut frames = vec![0.0005f32; 40];
frames[10] = 0.2; // vinyl tick — one frame only
frames.extend(vec![0.1f32; 20]);
assert_eq!(onset_from_rms(&frames), Some(40));
assert_eq!(onset_from_rms(&[0.0001f32; 50]), None, "never audible");
}
/// End-to-end through the WAV writer and back through symphonia: the
/// rendered file decodes, keeps the length, and is quieter in the mid.
#[test]
fn renders_a_playable_wav() {
let dir = std::env::temp_dir().join("ssplus-vocals-test");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("src.wav");
let dest = dir.join("out.wav");
let rate = 44_100u32;
// A "song": center 1kHz vocal + left-panned 3kHz guitar.
let mut mix = sine_stereo(1_000.0, 1.0, rate, "center");
let guitar = sine_stereo(3_000.0, 1.0, rate, "left");
for (m, g) in mix.iter_mut().zip(guitar.iter()) {
*m = (*m + g).clamp(-1.0, 1.0);
}
write_wav_16(&src, &mix, rate).unwrap();
let result = render_vocal_reduced(&src, &dest).unwrap();
assert_eq!(result.sample_rate, rate);
assert!((result.duration_secs - 1.0).abs() < 0.05);
let back = decode(&dest, None).unwrap();
assert_eq!(back.channels, 2);
let frames = back.samples.len() / 2;
assert!((frames as f64 / rate as f64 - 1.0).abs() < 0.05);
// Mono files are refused, not mangled.
let mono = dir.join("mono.wav");
{
use std::io::Write as _;
let samples: Vec<f32> = sine_stereo(440.0, 0.2, rate, "center")
.chunks(2)
.map(|c| c[0])
.collect();
let data_len = (samples.len() * 2) as u32;
let mut out = std::fs::File::create(&mono).unwrap();
let mut header = Vec::new();
header.extend_from_slice(b"RIFF");
header.extend_from_slice(&(36 + data_len).to_le_bytes());
header.extend_from_slice(b"WAVEfmt ");
header.extend_from_slice(&16u32.to_le_bytes());
header.extend_from_slice(&1u16.to_le_bytes());
header.extend_from_slice(&1u16.to_le_bytes());
header.extend_from_slice(&rate.to_le_bytes());
header.extend_from_slice(&(rate * 2).to_le_bytes());
header.extend_from_slice(&2u16.to_le_bytes());
header.extend_from_slice(&16u16.to_le_bytes());
header.extend_from_slice(b"data");
header.extend_from_slice(&data_len.to_le_bytes());
out.write_all(&header).unwrap();
for s in &samples {
out.write_all(&((s * 32767.0) as i16).to_le_bytes()).unwrap();
}
}
assert!(render_vocal_reduced(&mono, &dest).is_err());
}
}
+1
View File
@@ -6,6 +6,7 @@ rust-version.workspace = true
[dependencies]
soulseek-proto = { path = "../soulseek-proto" }
audio-formats = { path = "../audio-formats" }
tokio.workspace = true
tokio-util = "0.7"
bytes.workspace = true
+5 -2
View File
@@ -93,8 +93,11 @@ async fn run_parent(inner: &Arc<Inner>, mut stream: TcpStream) -> std::io::Resul
responder::respond_to_search(inner.clone(), username, token, query);
}
Ok(dist::DistributedEvent::BranchLevel(level)) => {
// Report our level (parent + 1) to the server.
let _ = inner.send_server(server::branch_level(level + 1));
// Report our level (parent + 1) to the server. Saturating: the
// level is a peer-supplied u32, and one parent really did send
// a value at the top of the range — `+ 1` panicked this worker
// (debug overflow checks) mid-session on 2026-08-14.
let _ = inner.send_server(server::branch_level(level.saturating_add(1)));
}
Ok(dist::DistributedEvent::BranchRoot(root)) => {
let _ = inner.send_server(server::branch_root(&root));
+13 -9
View File
@@ -11,9 +11,10 @@ use std::sync::RwLock;
use soulseek_proto::types::{Directory, File};
const AUDIO_EXTS: [&str; 10] = [
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
];
// Shared with the library scanner and the row pruner (`audio-formats`). The
// share index deliberately offers formats this app cannot itself PLAY — a peer
// asking for `.ape` is well served by a file we hold, and refusing to share it
// helps nobody.
/// One shared file: its remote (virtual) path and where it lives locally.
#[derive(Debug, Clone)]
@@ -161,8 +162,14 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
let path = entry.path();
if path.is_dir() {
// The download staging pen is not part of anyone's library —
// half-finished transfers are not shareable records.
if path.file_name().is_some_and(|n| n == "(Cache)") {
// half-finished transfers are not shareable records. `.soulstream`
// holds the library's own database, review session and artwork
// cache; only audio is ever indexed, so it could not be served
// anyway, but it has no business appearing in a browse listing.
if path
.file_name()
.is_some_and(|n| n == "(Cache)" || n == ".soulstream")
{
continue;
}
walk(&path, base, root, index);
@@ -203,10 +210,7 @@ fn to_remote_path(path: &Path, base: &str, root: &Path) -> String {
}
fn is_audio(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
audio_formats::is_audio_path(path)
}
#[cfg(test)]
+111
View File
@@ -26,6 +26,10 @@ pub struct TagSummary {
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
/// Which disc of the release, 1-based, when the file says so. Every disc
/// numbers its tracks from 1, so without this a two-disc rip reads as two
/// track 1s, two track 2s, and so on down the album.
pub disc_no: Option<u32>,
pub year: Option<u32>,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
@@ -50,6 +54,7 @@ pub fn read_summary(path: &Path) -> Result<TagSummary> {
summary.album = non_empty(tag.album().as_deref());
summary.title = non_empty(tag.title().as_deref());
summary.track_no = tag.track();
summary.disc_no = tag.disk();
summary.year = tag.year();
summary.album_artist = non_empty(
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
@@ -69,6 +74,10 @@ pub struct TagWrite {
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
/// Written into the file, not just the database: a release joined from
/// disc folders loses that structure the moment it is filed flat, and the
/// tag is the only place the answer survives being read by anything else.
pub disc_no: Option<u32>,
pub year: Option<u32>,
pub label: Option<String>,
pub genre: Option<String>,
@@ -109,6 +118,7 @@ pub fn write_tags(
|| write.album.is_some()
|| write.title.is_some()
|| write.track_no.is_some()
|| write.disc_no.is_some()
|| write.year.is_some()
|| write.label.is_some()
|| write.genre.is_some();
@@ -147,6 +157,9 @@ pub fn write_tags(
if let Some(v) = write.track_no {
tag.set_track(v);
}
if let Some(v) = write.disc_no {
tag.set_disk(v);
}
if let Some(v) = write.year {
tag.set_year(v);
}
@@ -222,6 +235,104 @@ pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
Ok(())
}
/// Lyrics carried inside the audio file itself.
#[derive(Debug, Clone, Default)]
pub struct EmbeddedLyrics {
/// Unsynchronized text (ID3 USLT or the generic LYRICS field).
pub plain: Option<String>,
/// Synchronized lines from an ID3v2 SYLT frame, (milliseconds, text),
/// sorted by time. Empty when the file has none.
pub synced: Vec<(u32, String)>,
}
/// Read embedded lyrics. The plain field comes from the generic tag on every
/// format lofty knows; SYLT lives only in ID3v2, which lofty exposes as a
/// binary frame on the concrete MPEG file type, so that pass is MP3-only.
pub fn read_lyrics(path: &Path) -> Result<EmbeddedLyrics> {
use lofty::id3::v2::{Frame, SynchronizedTextFrame, TimestampFormat};
use lofty::prelude::*;
let mut out = EmbeddedLyrics::default();
let tagged = Probe::open(path)?.read()?;
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
out.plain = non_empty(
tag.get_string(&lofty::tag::ItemKey::Lyrics)
.map(|s| s.to_string())
.as_deref(),
);
}
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("mp3"));
if is_mp3 {
if let Ok(mut file) = std::fs::File::open(path) {
if let Ok(mpeg) = lofty::mpeg::MpegFile::read_from(
&mut file,
lofty::config::ParseOptions::new(),
) {
if let Some(id3) = mpeg.id3v2() {
for frame in id3 {
let Frame::Binary(binary) = frame else { continue };
if frame.id().as_str() != "SYLT" {
continue;
}
let Ok(sylt) = SynchronizedTextFrame::parse(
&binary.data,
lofty::id3::v2::FrameFlags::default(),
) else {
continue;
};
// MPEG-frame-counted timestamps cannot be turned into
// time without decoding; only millisecond SYLT is usable.
if sylt.timestamp_format != TimestampFormat::MS {
continue;
}
let mut lines: Vec<(u32, String)> = sylt
.content
.into_iter()
.map(|(ms, text)| (ms, text.trim().to_string()))
.collect();
lines.sort_by_key(|(ms, _)| *ms);
if lines.iter().any(|(_, t)| !t.is_empty()) {
out.synced = lines;
break;
}
}
}
}
}
}
Ok(out)
}
/// The file's own ISRC tag, normalized to the bare 12-character form
/// (CC-XXX-YY-NNNNN with the dashes people sometimes write stripped), or
/// None when absent or malformed. An ISRC names the exact RECORDING, which
/// makes it the one tag strong enough to match against external catalogs
/// without touching names.
pub fn read_isrc(path: &Path) -> Result<Option<String>> {
let tagged = Probe::open(path)?.read()?;
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
return Ok(None);
};
let raw = tag
.get_string(&lofty::tag::ItemKey::Isrc)
.map(|s| s.to_string());
Ok(raw.and_then(|s| {
let normalized: String = s
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
let shaped = normalized.len() == 12
&& normalized.chars().take(2).all(|c| c.is_ascii_alphabetic())
&& normalized.chars().skip(5).all(|c| c.is_ascii_digit());
shaped.then_some(normalized)
}))
}
fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
}
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"test": "node --test tests/preview/*.test.mjs"
"test": "node --test tests/preview/"
},
"license": "ISC",
"devDependencies": {
+6
View File
@@ -29,6 +29,11 @@ image.workspace = true
tokio.workspace = true
base64 = "0.22"
# XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs
# or external entities, which is exactly the safety posture the importer wants.
quick-xml = "0.37"
# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate.
reqwest = { version = "0.12", features = ["json"] }
keyring = { version = "3", features = ["windows-native"] }
sysinfo = "0.33"
soulseek-proto = { path = "../crates/soulseek-proto" }
@@ -37,6 +42,7 @@ player = { path = "../crates/player" }
tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
library = { path = "../crates/library" }
audio-formats = { path = "../crates/audio-formats" }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
+29 -109
View File
@@ -143,6 +143,9 @@ pub async fn set_settings(
volume_normalization: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
.as_deref()
.is_some_and(|d| Some(d) != cfg.download_dir.as_deref());
if let Some(d) = download_dir {
cfg.download_dir = Some(d);
}
@@ -208,6 +211,25 @@ pub async fn set_settings(
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
// at the next launch, so what the shelves show is what the folder holds —
// the old behaviour left the previous folder's albums standing forever,
// and emptying the new folder could not remove them.
if library_folder_moved {
let app2 = app.clone();
let root = config::download_dir(&app, &cfg);
tauri::async_runtime::spawn(async move {
let lib = app2.state::<crate::pipeline::LibraryState>().library.clone();
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
log::info!("library folder changed: dropped {dropped} tracks from the old folder");
}
let _ = crate::pipeline::rescan_library(app2.clone()).await;
let _ = app2.emit("soulseek:library-changed", ());
});
}
// Tighter limits take effect right away, not at the next stream/startup.
if retention_changed {
let app2 = app.clone();
@@ -1263,10 +1285,13 @@ pub struct CatalogHeroPreviewResult {
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
/// The CHOSEN hero per artist — a decision about this library, not a render
/// cache — so it travels with the library. (The upscaled results keyed by URL
/// and recipe stay in the machine's cache dir: they regenerate cheaply and
/// would otherwise put gigabytes of derived pixels on the drive.)
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_dir()
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg)
.map_err(|e| e.to_string())?
.join("artist-heroes");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -4366,7 +4391,7 @@ pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> R
Ok(())
}
// ---- playlists ----
// ---- playlists live in src-tauri/src/playlists.rs ----
fn now_secs() -> i64 {
std::time::SystemTime::now()
@@ -4375,111 +4400,6 @@ fn now_secs() -> i64 {
.unwrap_or(0)
}
#[tauri::command]
pub async fn list_playlists(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::Playlist>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn create_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
name: String,
) -> Result<i64, String> {
let l = lib.library.clone();
let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(id)
}
#[tauri::command]
pub async fn delete_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn playlist_tracks(
lib: State<'_, LibraryState>,
id: i64,
) -> Result<Vec<library::PlaylistTrack>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[derive(serde::Deserialize)]
pub struct TrackInput {
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub path: String,
pub art_path: Option<String>,
}
#[tauri::command]
pub async fn add_to_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
tracks: Vec<TrackInput>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
for t in tracks {
l.add_to_playlist(
playlist_id,
&library::PlaylistTrack {
position: 0, // assigned by insert
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs,
path: t.path,
art_path: t.art_path,
},
)?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn remove_from_playlist(
lib: State<'_, LibraryState>,
playlist_id: i64,
position: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(())
}
/// Compute (or fetch cached) waveform peaks for a local audio file.
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
#[tauri::command]
+142
View File
@@ -258,6 +258,148 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
music.join("SoulStream")
}
/// The folder holding everything the app knows ABOUT this library — the
/// database, the review session, the artwork it has chosen and cached.
///
/// It lives inside the library folder because the library has to be portable:
/// carry the drive to another machine and the records, the decisions and the
/// covers arrive together. State kept in AppData does not travel, and an
/// import saved at one machine and opened at another lost every choice it had
/// made while the audio arrived intact.
///
/// AppData keeps only what is about the APP on THIS machine: credentials, the
/// audio device, window state, and caches of REMOTE data (`net-cache-v1`,
/// `stream-cache`, the GPU render caches) which are regenerable and would
/// otherwise bloat the drive.
///
/// The name starts with a dot so it sorts out of the way, and the share index
/// skips it by name as well as by only ever indexing audio.
pub const LIBRARY_DATA_DIRNAME: &str = ".soulstream";
pub fn library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
download_dir(app, cfg).join(LIBRARY_DATA_DIRNAME)
}
/// `library_data_dir`, created if missing. Errors are the caller's to handle:
/// with the drive unplugged this cannot succeed, and inventing a fallback in
/// AppData is exactly the split-brain this whole change removes.
pub fn ensure_library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> std::io::Result<PathBuf> {
let dir = library_data_dir(app, cfg);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// Everything that used to live in AppData and describes the library, moved
/// once into the library folder. Ordered by how much it hurts to lose.
///
/// `(appdata_relative_name, is_dir)` — the name is kept identical on the far
/// side so a migrated folder is recognisable, and so a half-finished migration
/// can be resumed by simply running again.
const MIGRATED_LIBRARY_STATE: &[(&str, bool)] = &[
("library.db", false),
("import-session.json", false),
("import-art", true),
("art", true),
("artist-identity-v1", true),
("artist-heroes", true),
("loudness-cache.json", false),
];
/// Move library state out of AppData and into the library folder, once.
///
/// Rules, in order of importance:
/// - A destination that already exists WINS and the source is left alone.
/// The library folder is the truth; a stale AppData copy must never
/// overwrite it, and running this twice must be a no-op.
/// - Nothing is deleted until the copy is verified in place. A rename across
/// volumes fails on Windows, so this is copy-then-remove, and the remove is
/// skipped if anything went wrong — a leftover AppData copy is harmless,
/// a half-moved database is not.
/// - The SQLite sidecars (`-wal`, `-shm`) travel WITH the database. Moving
/// `library.db` alone can discard committed transactions still in the WAL.
///
/// Returns the names actually moved, for the log.
pub fn migrate_library_state_into_library(app: &tauri::AppHandle, cfg: &AppConfig) -> Vec<String> {
let Ok(dest_root) = ensure_library_data_dir(app, cfg) else {
return Vec::new();
};
let Ok(src_root) = app.path().app_data_dir() else {
return Vec::new();
};
let mut moved = Vec::new();
for (name, is_dir) in MIGRATED_LIBRARY_STATE {
let src = src_root.join(name);
let dest = dest_root.join(name);
if dest.exists() || !src.exists() {
continue;
}
let ok = if *is_dir {
copy_dir_all(&src, &dest).is_ok()
} else {
// The database's write-ahead log is part of the database.
let mut all = std::fs::copy(&src, &dest).is_ok();
for suffix in ["-wal", "-shm"] {
let extra = src_root.join(format!("{name}{suffix}"));
if extra.exists() {
all &= std::fs::copy(&extra, dest_root.join(format!("{name}{suffix}"))).is_ok();
}
}
all
};
if !ok {
log::warn!("library migration: could not move {name} — leaving it in AppData");
continue;
}
// Verified across: retire the original rather than delete it outright,
// so a mistake here is recoverable by hand.
let retired = src_root.join(format!("{name}.moved-to-library"));
let _ = std::fs::remove_dir_all(&retired);
let _ = std::fs::remove_file(&retired);
if std::fs::rename(&src, &retired).is_ok() {
for suffix in ["-wal", "-shm"] {
let extra = src_root.join(format!("{name}{suffix}"));
let _ = std::fs::rename(&extra, src_root.join(format!("{name}{suffix}.moved-to-library")));
}
}
moved.push((*name).to_string());
}
moved
}
fn copy_dir_all(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let to = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &to)?;
} else {
std::fs::copy(entry.path(), to)?;
}
}
Ok(())
}
/// Where an import writes. The chosen destination, or — when none has been
/// chosen — the library folder itself.
///
/// These were two separately-nullable settings that both mean "the library",
/// and `library_dir` being null silently disabled importing: apply refused
/// with "no library folder chosen yet" and the picker's save-and-bank returned
/// without doing anything or saying so. There is no sensible third answer —
/// music imported into the library goes into the library folder — so an unset
/// destination now means exactly that instead of meaning "refuse".
///
/// A destination OUTSIDE the library folder is still honoured if explicitly
/// set, but it is a strange thing to want: `prune_outside_root` will drop
/// those rows again at the next launch, because they are not in the library.
pub fn import_dest_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
match cfg.library_dir.as_deref().map(str::trim) {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => download_dir(app, cfg),
}
}
pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}";
pub fn library_layout(cfg: &AppConfig) -> &str {
+499 -33
View File
@@ -20,7 +20,7 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use tauri::{Emitter, State};
use crate::config;
use crate::pipeline::{self, LibraryState};
@@ -368,9 +368,82 @@ pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), String> {
}
}
/// Where the source folder sits RELATIVE to the library folder, when that can
/// be said at all. Saved beside the absolute path so a session can find its
/// source again after the drive is carried somewhere else.
///
/// "I moved the whole folder" is the case this serves: source and library keep
/// their arrangement, only the prefix changes. A source on a different drive
/// from the library has no relative form and gets `None` — its absolute path
/// is the only thing that could ever have identified it.
#[tauri::command]
pub fn import_portable_root(app: tauri::AppHandle, root: String) -> Option<String> {
let cfg = config::load(&app);
relative_to(&config::download_dir(&app, &cfg), Path::new(&root))
}
fn relative_to(from: &Path, to: &Path) -> Option<String> {
let key = |c: &std::path::Component| c.as_os_str().to_string_lossy().to_lowercase();
let (a, b): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
// Different drives (or one relative, one not) share no prefix worth having.
if a.first().map(key) != b.first().map(key) {
return None;
}
let common = a
.iter()
.zip(b.iter())
.take_while(|(x, y)| key(x) == key(y))
.count();
let mut out = PathBuf::new();
for _ in common..a.len() {
out.push("..");
}
for c in &b[common..] {
out.push(c.as_os_str());
}
Some(out.to_string_lossy().into_owned())
}
/// The source folder this session was reviewing, as it exists NOW.
///
/// Tries the path as saved first — nothing has moved in the common case. Then
/// the relative form against the current library folder, which is what makes a
/// review started at one machine resumable at another. `None` means the folder
/// is genuinely not there and only the user can say where it went.
#[tauri::command]
pub fn import_resolve_root(
app: tauri::AppHandle,
saved: Option<String>,
rel: Option<String>,
) -> Option<String> {
if let Some(s) = saved.as_deref() {
if Path::new(s).is_dir() {
return Some(s.to_string());
}
}
let rel = rel?;
let cfg = config::load(&app);
let mut candidate = config::download_dir(&app, &cfg);
for part in rel.split(['/', '\\']) {
match part {
"" | "." => {}
".." => {
candidate.pop();
}
p => candidate.push(p),
}
}
candidate
.is_dir()
.then(|| candidate.to_string_lossy().into_owned())
}
/// In the LIBRARY folder, not AppData: an import saved at one machine and
/// carried to another used to arrive with the audio intact and every decision
/// gone, because the review's session never left the machine that made it.
fn session_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg).map_err(|e| e.to_string())?;
Ok(dir.join("import-session.json"))
}
@@ -394,20 +467,19 @@ pub struct ImportDest {
#[tauri::command]
pub fn import_get_dest(app: tauri::AppHandle) -> Result<ImportDest, String> {
let cfg = config::load(&app);
let suggested = if cfg.library_dir.is_none() {
Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned())
} else {
None
};
let free_bytes = cfg
// `dir` is the destination that WILL be written to, never null. It used to
// report only an explicitly-chosen folder, so a null read as "no
// destination" and every import path disabled itself — the review's Import
// button greyed out and the picker's save-and-bank returned in silence.
let dir = config::import_dest_dir(&app, &cfg);
let suggested = cfg
.library_dir
.as_deref()
.or(suggested.as_deref())
.and_then(|d| free_space_of(Path::new(d)));
.map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None);
Ok(ImportDest {
dir: cfg.library_dir.clone(),
free_bytes: free_space_of(&dir),
dir: Some(dir.to_string_lossy().into_owned()),
suggested,
free_bytes,
layout: config::library_layout(&cfg).to_string(),
})
}
@@ -484,13 +556,62 @@ pub async fn import_local_art(
app: tauri::AppHandle,
source_root: String,
rel: String,
parts: Option<Vec<String>>,
) -> Result<Option<LocalArt>, String> {
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel))
let parts = parts.unwrap_or_default();
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel, &parts))
.await
.map_err(|e| e.to_string())
}
fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<LocalArt> {
/// One folder's audio, sorted, and NOT what is underneath it.
fn audio_in_dir(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut here: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && audio_formats::is_audio_path(p))
.collect();
here.sort();
out.append(&mut here);
}
/// The audio a record actually OWNS: its own folder, plus the part folders the
/// review named — in part order, sorted within each part. The same walk apply
/// does, and for the same reason ([`ApplyAlbum::parts`]).
///
/// It is deliberately not recursive. A recursive walk is how the picker came to
/// show a NEIGHBOUR's cover as "yours": an artist folder holding one loose file
/// beside its albums — `Sweet\` is a single 52 MB "So Serene" rip, while
/// `Sweet\Sweet Valley - SV [2013]\` is a separate 12-track record — collected
/// both sets, and sorting the full paths put the subfolder first ('V' 0x56
/// before 'o' 0x6F), so the record was shown the other record's artwork. That
/// picture also became the record's `art_dhash`, which is what the discography
/// pass compares against the Cover Art Archive.
fn own_audio_files(dir: &Path, parts: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
if parts.is_empty() {
audio_in_dir(dir, &mut out);
} else {
for part in parts {
// An empty entry is the record's own folder — a container holding
// loose audio beside its parts.
if part.is_empty() {
audio_in_dir(dir, &mut out);
} else {
audio_in_dir(&dir.join(part), &mut out);
}
}
}
out
}
fn local_art_of(
app: &tauri::AppHandle,
source_root: &str,
rel: &str,
parts: &[String],
) -> Option<LocalArt> {
let dir = Path::new(source_root).join(rel);
if !dir.is_dir() {
return None;
@@ -499,14 +620,22 @@ fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<
// Embedded front cover of the first files that carry one — materialized
// into app data so the webview can read it (the audio file itself can't
// be handed to an <img>).
let mut files: Vec<PathBuf> = Vec::new();
pipeline::collect_audio_files(&dir, &mut files, 0);
files.sort();
let files = own_audio_files(&dir, parts);
for f in files.iter().take(5) {
let Ok(Some(bytes)) = tagger::extract_art(f) else { continue };
let Ok(img) = image::load_from_memory(&bytes) else { continue };
let (w, h) = (img.width(), img.height());
let cache_dir = app.path().app_data_dir().ok()?.join("import-art");
// In the library folder: these are the covers the review is choosing
// BETWEEN, and a choice made at one machine has to still be there when
// the drive is opened at another.
// v2: entries under the old name were extracted by a RECURSIVE walk, so
// a record sharing a folder with another one cached the neighbour's
// cover — and `if !target.exists()` below means a stale entry is never
// rewritten. Versioning the directory retires all of them at once,
// which is what `cover-logos-v3` and `artist-identity-v1` do.
let cache_dir = config::ensure_library_data_dir(app, &config::load(app))
.ok()?
.join("import-art-v2");
std::fs::create_dir_all(&cache_dir).ok()?;
// Stable name per rel so reopening a record reuses the extraction.
let mut hash: u64 = 0xcbf29ce484222325;
@@ -673,6 +802,92 @@ pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf {
.join(pipeline::safe_component(&flat))
}
/// Where one of a found copy's files lands in the pen. Shared by the download
/// and by the completeness check below, so the two can never disagree about
/// which file on disk is which track.
fn pen_target(base: &Path, remote_path: &str) -> PathBuf {
let leaf = remote_path.rsplit('\\').next().unwrap_or(remote_path);
base.join(pipeline::safe_component(leaf))
}
/// Which of a found copy's files the pen does not (fully) hold, by index into
/// `files`.
///
/// Transfers do not survive the app; the files they wrote do. A restored
/// audition therefore LOOKS live and is not, and its arrived-count is a
/// running total kept by events that stopped when the process did — after a
/// crash, a power cut or a restart it is a number about a session that no
/// longer exists. The pen is the only witness left, so it is what gets asked.
///
/// A SHORT file counts as missing: a transfer killed mid-write leaves a
/// partial behind, and an existence check would call that track arrived and
/// go on to import a truncated one.
#[tauri::command]
pub async fn import_audition_missing(
app: tauri::AppHandle,
rel: String,
files: Vec<AuditionFile>,
) -> Result<Vec<usize>, String> {
tauri::async_runtime::spawn_blocking(move || {
let cfg = config::load(&app);
let base = audition_dir(&config::download_dir(&app, &cfg), &rel);
missing_in_pen(&base, &files)
})
.await
.map_err(|e| e.to_string())
}
/// Empty one record's pen, and report the bytes it freed.
///
/// Called when the review ABANDONS a peer. Switching cancels that peer's
/// transfers, but it cannot un-write the files they already delivered, and the
/// pen is keyed by record rather than by peer — so an abandoned copy sat there
/// until the whole session was cleared, 189 MB of a stranger's Centipede Hz
/// among them. Apply no longer files those (it takes the chosen copy's own
/// files by name), so this is about disk, not correctness.
///
/// The path is DERIVED from `rel` here, exactly as the download and the
/// apply-time move derive it. Nothing outside the pen can be named, whatever
/// the caller passes.
#[tauri::command]
pub async fn import_audition_clear(app: tauri::AppHandle, rel: String) -> Result<u64, String> {
tauri::async_runtime::spawn_blocking(move || {
let cfg = config::load(&app);
let dir = audition_dir(&config::download_dir(&app, &cfg), &rel);
if !dir.is_dir() {
return Ok(0);
}
let freed: u64 = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum();
std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(freed)
})
.await
.map_err(|e| e.to_string())?
}
/// The rule itself, without an app handle so it can be tested. A file the pen
/// does not hold is missing; so is one it holds only part of. A size the peer
/// never stated (0) cannot be checked against, and a file that is there is
/// taken at its word rather than called missing forever.
fn missing_in_pen(base: &Path, files: &[AuditionFile]) -> Vec<usize> {
files
.iter()
.enumerate()
.filter(|(_, f)| match std::fs::metadata(pen_target(base, &f.path)) {
Ok(m) => f.size > 0 && m.len() < f.size,
Err(_) => true,
})
.map(|(i, _)| i)
.collect()
}
/// Download a found album copy into `(Cache)\_audition\<rel>\` for the
/// version comparison — the WHOLE album (his call: keeping it later is a
/// move, not a re-download, and one search's worth of requests).
@@ -701,8 +916,7 @@ pub async fn import_audition(
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
let mut ids = Vec::with_capacity(files.len());
for (i, f) in files.iter().enumerate() {
let leaf = f.path.rsplit('\\').next().unwrap_or(&f.path);
let target = base.join(pipeline::safe_component(leaf));
let target = pen_target(&base, &f.path);
// Claimed BEFORE the transfer task is spawned: it emits Connecting
// immediately, and an event whose id is not yet registered is one the
// UI throws away — and worse, one commands.rs routes down the ordinary
@@ -755,13 +969,67 @@ pub async fn import_probe_file(path: String) -> Result<ProbeResult, String> {
/// opened — never a batch (decision 12). Deezer down the cleaned try-list,
/// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the
/// whole flow (metadata::identify_album). Read-only.
///
/// `title` is what the record calls ITSELF (the tag majority); `folder` is the
/// folder it sits in, asked after it as the second witness for a record whose
/// tags are wrong (metadata::identify_titles).
///
/// The rest is the record's SHAPE, which is what the discography pass compares
/// when no phrase can find it: how many files it has, what they are called, the
/// year, and — read here rather than shipped over IPC, because a master cover
/// can be 8 MB — the perceptual hash of its own embedded artwork.
#[tauri::command]
pub async fn import_identify(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist: String,
title: String,
folder: Option<String>,
tracks: Option<usize>,
track_titles: Option<Vec<String>>,
year: Option<u32>,
source_root: Option<String>,
rel: Option<String>,
parts: Option<Vec<String>>,
) -> Result<metadata::IdentifyResult, String> {
Ok(state.metadata.identify_album(&artist, &title).await)
// The record's OWN parts, so the hash is its own cover — a record sharing a
// folder with another one used to hash the neighbour's.
let parts = parts.unwrap_or_default();
let art_dhash = match (source_root.as_deref(), rel.as_deref()) {
(Some(root), Some(rel)) => local_art_dhash(&app, root, rel, &parts).await,
_ => None,
};
let shape = metadata::RecordShape {
tracks,
track_titles: track_titles.unwrap_or_default(),
year,
art_dhash,
};
Ok(state
.metadata
.identify_album(&artist, &title, folder.as_deref(), &shape)
.await)
}
/// The perceptual hash of a record's OWN cover, for comparison against an
/// artist's discography. Reuses `local_art_of`, so it reads the extraction that
/// the artwork panel already cached rather than decoding the audio again.
async fn local_art_dhash(
app: &tauri::AppHandle,
source_root: &str,
rel: &str,
parts: &[String],
) -> Option<u64> {
let art = {
let app = app.clone();
let (root, rel) = (source_root.to_string(), rel.to_string());
let parts = parts.to_vec();
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &root, &rel, &parts))
.await
.ok()??
};
let bytes = tokio::fs::read(&art.path).await.ok()?;
metadata::cover_dhash(&bytes)
}
/// The artwork panel's data: reference + candidates with decoded sizes and
@@ -835,6 +1103,15 @@ pub struct ApplyChoices {
/// fewer than this means the transfer never finished, and importing it
/// would file a truncated album — apply refuses instead.
pub audio_tracks: Option<usize>,
/// The chosen copy's own files, as the peer named them. The pen is keyed
/// by RECORD, not by peer, so a record that changed peers mid-audition has
/// TWO copies in one folder: switching cancels the old transfers but
/// cannot un-write the files they already delivered. Taking "every audio
/// file in the pen" then files both — Centipede Hz landed as 17 tracks,
/// deletia's 11 plus the 6 smash2 got through before it was dropped.
///
/// None (an older session, or "yours") keeps the previous behaviour.
pub audio_files: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -880,9 +1157,9 @@ pub async fn import_apply(
albums: Vec<ApplyAlbum>,
) -> Result<ApplyReport, String> {
let cfg = config::load(&app);
let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else {
return Err("no library folder chosen yet".into());
};
// An unset destination means the library folder, not a refusal — see
// `config::import_dest_dir`.
let dest_root = config::import_dest_dir(&app, &cfg);
let layout = config::library_layout(&cfg).to_string();
let embed_max_edge = config::embed_art_max_edge(&cfg);
let replace_embedded = config::replace_embedded_art(&cfg);
@@ -1023,16 +1300,43 @@ fn apply_one(
// match the one the review displayed, because `track_titles` is
// positional: disagree here and every title past the first part lands on
// the wrong file. A found copy overrides this — the pen is flat.
// Which disc each file belongs to, aligned with `files`. Empty (all None)
// for anything that is not a folded release — there, the disc is whatever
// the file's own tag says, read back after the copy.
let mut discs: Vec<Option<u32>> = Vec::new();
let mut files: Vec<PathBuf> = if take_theirs || a.parts.is_empty() {
let mut f = audio_files_in(&src_dir);
// A found copy is the files the REVIEW chose, not whatever the folder
// happens to hold. Same derivation as the download used, so a name the
// peer sent maps to exactly the file it wrote. Sorting stays where it
// was: `track_titles` is positional against this order.
if take_theirs {
if let Some(names) = a.choices.as_ref().and_then(|c| c.audio_files.as_ref()) {
if !names.is_empty() {
let chosen: std::collections::HashSet<PathBuf> =
names.iter().map(|n| pen_target(&pen_dir, n)).collect();
f.retain(|p| chosen.contains(p));
}
}
}
f.sort();
f
} else {
let mut out = Vec::new();
for part in &a.parts {
for (n, part) in a.parts.iter().enumerate() {
let dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) };
let mut f = audio_files_in(&dir);
f.sort();
// The parts ARE the discs, in the order the review joined them.
// Every part numbers its own tracks from 1, so without this the
// flattened album reads as eleven track-1s in alphabetical order —
// which is exactly what Analord's eleven volumes did.
//
// ONE part is not a multi-disc release: it is a container folder
// whose tracks live a level down, and numbering it "disc 1" would
// put a disc heading on an ordinary album.
let disc = if a.parts.len() > 1 { Some(n as u32 + 1) } else { None };
discs.extend(std::iter::repeat(disc).take(f.len()));
out.append(&mut f);
}
out
@@ -1217,16 +1521,24 @@ fn apply_one(
// chosen cover embeds at the same bounded size downloads use, and
// replaces an existing picture when config says so — an explicit
// pick in the review is the strongest consent that config models.
if let Some(ch) = &a.choices {
// The disc is STRUCTURE, not a review choice: it is what the record is
// made OF, and a record nobody opened still has to come out readable.
// So it is written even when there are no choices at all — and into
// the FILE, because the folder that carried the answer is gone the
// moment the parts are filed flat.
let disc = discs.get(i).copied().flatten();
if a.choices.is_some() || disc.is_some() {
let ch = a.choices.as_ref();
let write = tagger::TagWrite {
artist: ch.artist.clone(),
artist: ch.and_then(|c| c.artist.clone()),
album_artist: None,
album: ch.album.clone(),
album: ch.and_then(|c| c.album.clone()),
title: track_titles.and_then(|t| t.get(i).cloned().flatten()),
track_no: None,
year: ch.year,
label: ch.label.clone(),
genre: ch.genre.clone(),
disc_no: disc,
year: ch.and_then(|c| c.year),
label: ch.and_then(|c| c.label.clone()),
genre: ch.and_then(|c| c.genre.clone()),
};
let embed = embed_art.as_deref();
if let Err(e) = tagger::write_tags(
@@ -1249,6 +1561,9 @@ fn apply_one(
library
.upsert_track(
album_id,
// The fold's answer first: it is the structure we just imposed
// on this record. Everything else takes the file at its word.
disc.or(s.disc_no),
s.track_no,
&title,
&artist,
@@ -1328,6 +1643,39 @@ fn apply_one(
mod tests {
use super::*;
/// What the pen holds is what an audition actually got — and a PARTIAL
/// file is not an arrival. A transfer killed mid-write (the app closing, a
/// power cut) leaves a short file behind, and an existence check would
/// call that track arrived, stop asking for it, and go on to import a
/// truncated one. This is the check that unsticks a record whose
/// transfers died with the process that was watching them.
#[test]
fn the_pen_counts_a_short_file_as_missing_not_arrived() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let f = |name: &str, size: u64| AuditionFile {
path: format!("@peer\\Music\\Album\\{name}"),
size,
};
let files = vec![
f("01 whole.flac", 10),
f("02 partial.flac", 10),
f("03 never came.flac", 10),
f("04 size unstated.flac", 0),
];
std::fs::write(base.join("01 whole.flac"), b"0123456789").unwrap();
std::fs::write(base.join("02 partial.flac"), b"012").unwrap();
// 03 never arrived at all.
std::fs::write(base.join("04 size unstated.flac"), b"anything").unwrap();
assert_eq!(
missing_in_pen(base, &files),
vec![1, 2],
"the partial and the absent are owed; the whole one and the \
unstated-size one are not"
);
}
/// A record laid out the way apply sees one: the user's folder, the
/// audition pen beside the download root, and a library root to file
/// into. Files carry distinct bytes so "which version landed" is a
@@ -1378,6 +1726,7 @@ mod tests {
track_titles: None,
audio: Some(audio.into()),
audio_tracks: want,
audio_files: None,
}
}
@@ -1422,6 +1771,123 @@ mod tests {
out
}
/// A folded release is ONE album made of disc folders, and every folder
/// numbers its tracks from 1. The disc has to survive the flattening or
/// the album becomes unreadable: eleven volumes of Analord filed as one
/// record put eleven "track 1"s at the top of the list in alphabetical
/// order, with nothing anywhere to say which volume any of them was from.
#[test]
fn a_folded_release_keeps_its_discs() {
let b = bed(0);
for (part, names) in [("CD1", ["01 one.mp3", "02 two.mp3"]), ("CD2", ["01 three.mp3", "02 four.mp3"])] {
let dir = b.src_root.join(&b.rel).join(part);
std::fs::create_dir_all(&dir).unwrap();
for n in names {
std::fs::write(dir.join(n), format!("{part} {n}")).unwrap();
}
}
let mut a = album(&b, None);
a.parts = vec!["CD1".into(), "CD2".into()];
let (tracks, _, _) = run(&b, &a).unwrap();
assert_eq!(tracks, 4);
let album_id = b
.library
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
let rows = b.library.album_tracks(album_id).unwrap();
assert_eq!(
rows.iter().map(|t| t.disc_no).collect::<Vec<_>>(),
vec![Some(1), Some(1), Some(2), Some(2)],
"disc-major order, both discs numbered in the order the fold joined them"
);
// A single part is a container folder, not a two-disc set — numbering
// it would put a disc heading on an ordinary album.
let mut solo = album(&b, None);
solo.parts = vec!["CD1".into()];
let b2 = bed(0);
let dir = b2.src_root.join(&b2.rel).join("CD1");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("01 one.mp3"), "only").unwrap();
run(&b2, &solo).unwrap();
let solo_id = b2
.library
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
assert!(
b2.library.album_tracks(solo_id).unwrap().iter().all(|t| t.disc_no.is_none()),
"one part is not a disc"
);
}
/// The pen path is DERIVED from the record, never passed in — and the
/// record is a folder name off somebody's disk, so it can contain
/// anything. `import_audition_clear` deletes this directory, so "it always
/// lands inside the cache, and is always exactly one folder deep" is a
/// property worth holding on to rather than assuming.
#[test]
fn a_records_pen_can_never_name_anything_outside_the_cache() {
let root = Path::new("D:\\Music\\Library");
let pen = root.join("(Cache)").join("_audition");
for rel in [
"..\\..\\Windows\\System32",
"../../../etc/passwd",
"C:\\Windows",
"Artist\\Album",
".",
"",
] {
let dir = audition_dir(root, rel);
assert!(dir.starts_with(&pen), "{rel:?} escaped the pen: {dir:?}");
assert_eq!(
dir.components().count(),
pen.components().count() + 1,
"{rel:?} is not exactly one folder inside the pen: {dir:?}"
);
}
}
/// A record that changed peers mid-audition has TWO copies in one pen. The
/// switch cancels the abandoned peer's transfers, but it cannot un-write
/// the files they already delivered, and the pen is keyed by RECORD rather
/// than by peer. Taking "every audio file in the folder" filed both:
/// Centipede Hz landed as 17 tracks — deletia's 11 plus the 6 smash2 got
/// through before it was dropped — each duplicate under its own peer's
/// naming. The copy the REVIEW chose is the one that imports.
#[test]
fn the_dropped_peers_files_do_not_import_beside_the_copy_that_was_kept() {
let b = bed(2); // the chosen copy: "01 track.flac", "02 track.flac"
let pen = audition_dir(&b.download_root, &b.rel);
// What the abandoned peer left behind: the same music, its own naming.
for n in 1..=2 {
std::fs::write(
pen.join(format!("Weezer-Pinkerton-{n:02}-track-[FLAC 16bit].flac")),
format!("dropped peer {n}"),
)
.unwrap();
}
let mut ch = choices("theirs", Some(2));
ch.audio_files = Some(vec![
"@peer\\Music\\Pinkerton\\01 track.flac".into(),
"@peer\\Music\\Pinkerton\\02 track.flac".into(),
]);
let (tracks, _, _) = run(&b, &album(&b, Some(ch))).unwrap();
assert_eq!(tracks, 2, "only the chosen copy's tracks are filed");
let names: Vec<String> = landed(&b).into_iter().map(|(n, _)| n).collect();
assert!(
names.iter().all(|n| !n.contains("[FLAC 16bit]")),
"the dropped peer's files must not reach the library: {names:?}"
);
assert_eq!(
names.iter().filter(|n| n.ends_with(".flac")).count(),
2,
"eleven-plus-six is what this test exists to prevent: {names:?}"
);
}
/// Re-importing the same record must not copy it again — even though the
/// imported files no longer match their sources byte for byte.
///
@@ -1659,7 +2125,7 @@ mod tests {
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
b.library
.upsert_track(album_id, None, "Stray", "Weezer", None, None, None, None, Some(5),
.upsert_track(album_id, None, None, "Stray", "Weezer", None, None, None, None, Some(5),
&stray.to_string_lossy(), 0)
.unwrap();
+38 -6
View File
@@ -6,8 +6,11 @@ mod hero_crop;
mod import;
mod hero_detector;
mod loudness;
mod lyrics;
mod pipeline;
mod playback;
mod playlist_formats;
mod playlists;
mod preview;
mod smtc;
mod state;
@@ -93,14 +96,41 @@ pub fn run() {
commands::unfollow_artist,
commands::home_artist_news,
commands::home_warm,
commands::list_playlists,
commands::create_playlist,
commands::delete_playlist,
commands::playlist_tracks,
commands::add_to_playlist,
commands::remove_from_playlist,
playlists::list_playlists,
playlists::create_playlist,
playlists::rename_playlist,
playlists::set_playlist_description,
playlists::set_playlist_pinned,
playlists::duplicate_playlist,
playlists::delete_playlist,
playlists::playlist_items,
playlists::playlist_add_items,
playlists::playlist_remove_items,
playlists::playlist_set_order,
playlists::playlist_relink_item,
playlists::playlist_cover,
playlists::playlist_cover_pick,
playlists::playlist_cover_clear,
playlists::playlist_import_probe,
playlists::playlist_import_folder,
playlists::playlist_import_text,
playlists::playlist_import_commit,
playlists::playlist_export,
playlists::library_track_search,
lyrics::lyrics_get,
lyrics::lyrics_search,
lyrics::lyrics_select,
lyrics::lyrics_set_offset,
lyrics::lyrics_set_auto_sync,
lyrics::lyrics_prepare_instrumental,
lyrics::lyrics_vocal_activity,
lyrics::lyrics_save_lines,
lyrics::lyrics_save_text,
lyrics::lyrics_clear,
commands::catalog_search,
import::import_scan,
import::import_portable_root,
import::import_resolve_root,
import::import_session_get,
import::import_session_set,
import::import_session_clear,
@@ -111,6 +141,8 @@ pub fn run() {
import::import_cover_report,
import::import_local_art,
import::import_audition,
import::import_audition_missing,
import::import_audition_clear,
import::import_probe_file,
pipeline::rescan_library,
commands::catalog_artist,
+12 -5
View File
@@ -101,8 +101,14 @@ pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
)
}
/// Keyed by track path, so it describes THIS library's files and belongs
/// beside them. Measuring a whole library again on another machine is hours
/// of work that the drive was already carrying the answer to.
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
let cfg = crate::config::load(app);
crate::config::ensure_library_data_dir(app, &cfg)
.ok()
.map(|d| d.join(CACHE_FILE))
}
pub fn init(app: &tauri::AppHandle) {
@@ -947,8 +953,10 @@ mod tests {
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
// Measuring means decoding, so this is the PLAYABLE table, not
// the recognised one — an `.opus` or `.wv` here only produces a
// failed decode (or, for WavPack, NaN).
if audio_formats::is_playable_ext(&ext) {
files.push(path);
}
}
@@ -1092,8 +1100,7 @@ mod tests {
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
if !audio_formats::is_playable_ext(&ext) {
continue;
}
let artist = path
File diff suppressed because it is too large. Load diff
+90 -20
View File
@@ -11,11 +11,9 @@ use library::Library;
use metadata::{AlbumMeta, MetadataClient};
use tagger::{TagSummary, TagWrite};
// aiff/aif joined for the import tool: the reference library carries 13 AIFF
// tracks that a scan would otherwise silently drop, and "silently" is the
// problem — the album imports incomplete with no warning.
pub(crate) const AUDIO_EXTS: [&str; 10] =
["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"];
// The one table, in `audio-formats`. This used to be a local list that
// disagreed with the share index and the row pruner — the scanner indexed
// `.aiff` and the pruner deleted it on the next startup.
pub struct LibraryState {
pub library: Arc<Library>,
@@ -140,12 +138,53 @@ fn spawn_identity_warmer(app: tauri::AppHandle) {
}
pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
// AppData keeps only what is about the APP on THIS machine — the cache of
// remote provider responses. Everything describing the LIBRARY lives in
// the library folder so it travels with the music.
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
let cfg = crate::config::load(app);
let library_root = crate::config::download_dir(app, &cfg);
let moved = crate::config::migrate_library_state_into_library(app, &cfg);
if !moved.is_empty() {
log::info!("library state moved into the library folder: {}", moved.join(", "));
}
// With the drive unplugged there is no library to open. Fall back to a
// machine-local database rather than refusing to start — the real one
// returns with the drive, and nothing here writes into the wrong library
// because every path is compared against the configured root.
let (data_dir, portable) = match crate::config::ensure_library_data_dir(app, &cfg) {
Ok(dir) => (dir, true),
Err(e) => {
log::warn!(
"library folder {} is unavailable ({e}) — running with a local database until it returns",
library_root.display()
);
(app_dir.clone(), false)
}
};
let art_dir = data_dir.join("art");
std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?;
let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?);
if portable {
// The art cache moved with the database, so rows written before the
// move still point into AppData. Re-point them before anything paints.
match library.rewrite_path_prefix(&app_dir.join("art"), &art_dir) {
Ok(n) if n > 0 => log::info!("library: {n} artwork paths follow the art cache into the library folder"),
Err(e) => log::warn!("library: could not re-point artwork paths: {e}"),
_ => {}
}
// And the library itself may have arrived at a new drive letter or on
// a different machine entirely.
match library.rebase_to_root(&library_root) {
Ok(n) if n > 0 => log::info!("library moved: re-based {n} stored paths onto {}", library_root.display()),
Err(e) => log::warn!("library: could not re-base stored paths: {e}"),
_ => {}
}
}
let repair_library = library.clone();
let repair_art_dir = art_dir.clone();
@@ -156,10 +195,13 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
metadata: Arc::new({
let client = MetadataClient::new()
.with_disk_cache(data_dir.join("net-cache-v1"))
// Provider responses are about the WORLD, not this library, and
// they are cheap to refetch — they stay on the machine.
.with_disk_cache(app_dir.join("net-cache-v1"))
// Its own directory, NOT inside the cache above: that one is
// pruned by mtime, and these are verdicts rather than cached
// responses.
// responses. Verdicts about the library's artists are library
// state, so they travel with it.
.with_identity_store(data_dir.join("artist-identity-v1"));
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
// resolution can be compared against `hero_source_probe` — the two
@@ -193,8 +235,33 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
}
}
let state = handle.state::<LibraryState>();
// Before anything asks the disk: the library folder decides what is IN
// the library. Rows from a folder that used to hold it are not "still
// there because their files exist" — they belong to another library.
let lib = state.library.clone();
let root = downloads_dir(&handle);
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
if dropped > 0 {
log::info!("library: dropped {dropped} tracks outside the library folder");
}
}
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
// After the prune, every surviving track has a file — so "the folder
// this album lives in" is answerable, and any cover pointing somewhere
// else is another album's. Files move (a re-import into a new layout, a
// drive back at a different letter) and nothing else re-asks.
let lib = state.library.clone();
let art = state.art_dir.clone();
if let Ok(Ok(moved)) =
tauri::async_runtime::spawn_blocking(move || lib.reanchor_album_art(&art)).await
{
if moved > 0 {
log::info!("library repair: re-anchored artwork for {moved} albums");
}
}
let lib = state.library.clone();
if let Ok(Ok(removed)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await
@@ -459,16 +526,15 @@ pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8)
// "(Cache)" is the download staging pen — a completed file
// sitting there is seconds from being organized out, and
// indexing it would pin a library row to a staging path.
if path.file_name().is_some_and(|n| n == "(Cache)") {
// ".soulstream" is the library's own state, not music.
if path
.file_name()
.is_some_and(|n| n == "(Cache)" || n == crate::config::LIBRARY_DATA_DIRNAME)
{
continue;
}
collect_audio_files(&path, out, depth + 1);
} else if path
.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
{
} else if audio_formats::is_audio_path(&path) {
out.push(path);
}
}
@@ -659,6 +725,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
album: summary.album.is_none().then_some(final_album.clone()),
title: summary.title.is_none().then_some(title.clone()),
track_no: summary.track_no.or(parsed.track_no),
// Nothing to add: a downloaded file either carries its disc or has no
// disc to carry, and there is nowhere here to learn one from.
disc_no: None,
year: summary.year.is_none().then(|| final_year).flatten(),
label: None,
genre: None,
@@ -701,6 +770,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
let p = library_path.to_string_lossy().into_owned();
let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned());
let track_no = summary.track_no.or(parsed.track_no);
// Only what the file itself says. There is no filename convention for
// a disc worth guessing from, and a wrong disc reorders an album.
let disc_no = summary.disc_no;
let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
@@ -724,6 +796,7 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
}
lib.upsert_track(
album_id,
disc_no,
track_no,
&title2,
&artist,
@@ -853,10 +926,7 @@ pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result<usize, Str
}
pub fn is_audio_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
audio_formats::is_audio_path(path)
}
/// Move a newly completed download into one album directory under the managed
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4
View File
@@ -4,6 +4,8 @@
import PlayerBar from "./components/PlayerBar.svelte";
import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LyricsOverlay from "./components/LyricsOverlay.svelte";
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
import ImportWindow from "./components/ImportWindow.svelte";
import LoginModal from "./components/LoginModal.svelte";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
@@ -126,6 +128,8 @@
<FollowToast />
<ImportToast />
<PreviewOverlay />
<LyricsOverlay />
<LyricsSyncEditor />
<ArtworkViewer />
<ImportWindow />
<LoginModal />
+7
View File
@@ -76,6 +76,13 @@
"brand-bandcamp": '<path d="M22 7h-9.5L2 17h9.5L22 7z"/>',
"brand-soundcloud": '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
"hard-drive": '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1z"/>',
// Drag handle for reorderable rows (six dots, reads at 14px).
grip: '<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>',
export: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
"file-text": '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
link: '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
};
</script>
+136 -14
View File
@@ -16,7 +16,7 @@
import { onDestroy } from "svelte";
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
import { evidence as matchEvidence, normx } from "../lib/import-evidence.js";
import { IDENTIFY_ENGINE } from "../lib/import-title.js";
import { IDENTIFY_ENGINE, majority } from "../lib/import-title.js";
let {
album: a, // derived album from ImportWindow (rel, title, …)
@@ -101,14 +101,79 @@
(async () => {
try {
const ask = albumName ?? a.title;
const r = await api.importIdentify(artist, ask);
// The folder rides along as the second witness: `ask` is the
// tags' claim, and tags carry typos the folder does not
// ("Nonagon Infiniry" on all nine files). Asked after it, and
// deduped away when they agree.
//
// The SHAPE goes too — count, track titles, year, and where the
// files live. A name is only one fact about a record, and it is
// the one most often wrong; the discography pass compares the
// rest when no phrase can find it.
const shape = {
tracks: a.tracks?.length ?? null,
trackTitles: (a.tracks ?? []).map((t) => t.title).filter(Boolean),
year: a.year ?? null,
// `parts` bounds the artwork hash to the record's OWN audio.
// Without it the backend walked the folder recursively, and
// a record sharing a folder with another one hashed the
// neighbour's cover — see own_audio_files in import.rs.
sourceRoot, rel, parts: a.parts ?? [],
};
let r = await api.importIdentify(artist, ask, a.title, shape);
let askedArtist = artist;
// The FOLDER is asked first, and stays the default: it is one
// deliberate decision, where track artist tags carry featured
// guests, compilation contributors and classical performers.
//
// But a folder is sometimes not the artist at all — "Band Of
// Gypsys" holding six files that every one of them tags
// "Jimi Hendrix" — and then the ask is wrong twice over,
// because the try-list strips that "artist" off the front of
// the title and asks the providers for what is left of it.
//
// Only when the first ask came back with NOTHING, or with a
// record the judge calls wrong. A candidate that already
// stands is not this function's business.
const judge = (res, who) => (res?.candidate
? matchEvidence(res.candidate, who, ask, a.tracks.length).verdict
: "none");
if (["none", "bad"].includes(judge(r, artist))) {
const tagged = majority((a.tracks ?? []).map((t) => t.artist));
if (tagged && normx(tagged) !== normx(artist)) {
const alt = await api.importIdentify(tagged, ask, a.title, shape);
// Taken only if it is actually better. A second wrong
// answer is not an improvement on the first.
if (["ok", "warn"].includes(judge(alt, tagged))) {
r = alt;
askedArtist = tagged;
}
}
}
// Stamped with the engine that produced it: a cached "nothing
// found" is only trustworthy while the thing that looked has
// not learned anything new. See IDENTIFY_ENGINE.
found[rel] = { state: "done", result: r, engine: IDENTIFY_ENGINE };
// `askedArtist` rides along so the judge compares against the
// name the provider was ASKED about — the §3.2 rule, which
// would otherwise call this record's own match a wrong one.
// A lookup that FAILED is not an answer about the record. MB's
// 1 req/s limiter makes a 503 routine, and filing one as "done"
// made "Murder of the Universe" a permanent deep cut — the
// record MB holds, scored 100, and gave up on the next ask.
// `persist()` only saves `done`, so an error re-asks; nothing
// else here treats the two alike.
if (r.incomplete && !r.candidate) {
found[rel] = {
state: "error", result: null,
error: "the provider did not answer — this record has not been looked up yet",
};
return;
}
found[rel] = { state: "done", result: { ...r, askedArtist }, engine: IDENTIFY_ENGINE };
if (r.candidate?.cover) {
const rep = await api.importCoverReport(
artist, r.used ?? ask, r.candidate.cover);
askedArtist, r.used ?? ask, r.candidate.cover);
reports[rel] = rep;
}
} catch (e) {
@@ -133,7 +198,10 @@
const rel = a.rel;
if (localArts[rel] !== undefined) return;
localArts[rel] = null;
api.importLocalArt(sourceRoot, rel).then(
// `parts` for the same reason apply takes them: the record's audio is
// its own folder and the parts it named, never a walk of what happens
// to sit underneath.
api.importLocalArt(sourceRoot, rel, a.parts ?? []).then(
(v) => { localArts[rel] = v; },
() => {});
});
@@ -226,7 +294,10 @@
// an exact hit look doubtful: "Mirrored" against
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
// match wore a "!" on its title row.
const ev = $derived(matchEvidence(candidate, artist, albumName ?? a.title, a.tracks.length));
// Against the artist the provider was ASKED about — which is the folder
// unless the tags found the record where the folder could not.
const ev = $derived(matchEvidence(
candidate, idn?.result?.askedArtist ?? artist, albumName ?? a.title, a.tracks.length));
const verdictLine = $derived.by(() => {
const c = candidate;
@@ -285,15 +356,57 @@
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
const resolvedTrack = (n) => choice?.tracks?.[n] ?? "yours";
/** Row order is the RECORD's order; `n` stays the SCAN's.
*
* The scan lists a folder with a plain byte sort (import.rs `paths.sort()`),
* where '0' (0x30) beats '_' (0x5F) — so "10_LONELY WANDERER" precedes
* "1_SEQUENTIAL CIRCUITS" and a record numbered without zero-padding reads
* 10, 11, 12, 13, 1, 2 … 9. Pairing that against a provider tracklist by
* position compares track 10 with track 1 the whole way down, and the
* length differences it reports are the arithmetic of that mispairing.
*
* Ordering is therefore for DISPLAY ONLY. `n` remains each track's index
* in `a.tracks`, because that is the index apply writes by: `track_titles`
* is positional against apply's own `f.sort()` of the same folder
* (import.rs:1238). Reordering `a.tracks` itself would move the review
* without moving apply, and land every chosen title on the wrong file.
*
* A folded release is left in the order the fold built: its tracks are in
* PART order and every part numbers its own tracks from 1, so ordering the
* flattened list by track number would interleave the discs — the trap
* import.rs:1235 and import-fold.js:172 both already guard. */
const trackRows = $derived.by(() => {
const rows = a.tracks.map((t, n) => ({ t, n }));
if ((a.parts?.length ?? 0) > 1) return rows;
// The tags decide when they ALL carry a distinct number: a rarities
// folder where every file claims track 1 orders by nothing, and a
// partial set would sort the numbered ahead of the unnumbered.
const nos = rows.map((r) => r.t.trackNo);
if (nos.every((x) => typeof x === "number" && x > 0)
&& new Set(nos).size === nos.length) {
return [...rows].sort((x, y) => x.t.trackNo - y.t.trackNo);
}
// Otherwise the filename, compared with its digit runs as NUMBERS —
// which is the same comparison the fold already uses to order parts.
return [...rows].sort((x, y) => x.t.name.localeCompare(
y.t.name, undefined, { numeric: true, sensitivity: "base" }));
});
/** What the file calls the track. The tag first — it is the thing apply
* writes and compares — falling back to the filename for a file carrying
* no title at all. */
const titleOfTrack = (t) => (t.title ?? "").trim()
|| t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const trackPairs = $derived.by(() => {
const theirs = candidate?.tracklist ?? [];
return a.tracks.map((t, n) => {
const mine = t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const their = theirs[n];
return trackRows.map(({ t, n }, row) => {
const mine = titleOfTrack(t);
const their = theirs[row];
const dY = t.seconds ?? null;
const dT = their?.secs ?? null;
const drift = dY && dT ? Math.abs(dY - dT) : null;
return { n, mine, their: their?.title ?? null, dY, dT, drift,
return { n, row, mine, their: their?.title ?? null, dY, dT, drift,
aligned: drift !== null && drift <= 2 };
});
});
@@ -542,8 +655,15 @@
const tags = {};
for (const [k, , theirs] of tagPairs)
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
const trackTitles = trackPairs.map((p) =>
p.their && resolvedTrack(p.n) === "theirs" ? p.their : null);
// Built by SCAN index, never by row: apply matches `trackTitles`
// position-for-position against its own sorted file list
// (import.rs:1238), and the rows above are ordered for reading. Writing
// this in row order would land every chosen title on the wrong file —
// silently, and on the user's actual music.
const trackTitles = a.tracks.map(() => null);
for (const p of trackPairs) {
if (p.their && resolvedTrack(p.n) === "theirs") trackTitles[p.n] = p.their;
}
onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks });
});
</script>
@@ -718,7 +838,9 @@
<div class="im-ab-find">
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
<small>{audition?.status === "none"
? "No peer offered this album just now — worth asking again another day."
? audition.silent
? "Soulseek answered nothing at all — the server refuses some queries outright, and quiet spells happen. Worth asking again."
: "No peer offered this album just now — worth asking again another day."
: audition?.status === "failed"
? "The peer stopped answering — try the search again."
: "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."}</small>
@@ -864,7 +986,7 @@
<div class="im-tl-h">{candidate.source}</div>
<div class="im-tl-h"></div>
{#each trackPairs as p (p.n)}
<div class="im-tl-n">{p.n + 1}</div>
<div class="im-tl-n">{p.row + 1}</div>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "yours"}
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}
+2
View File
@@ -55,6 +55,8 @@
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""}</span>
{/if}
{:else if importStatus.error}
<span>{importStatus.error} <b>Click to fix</b></span>
{:else}
<span>The import hit a problem — <b>click to see</b></span>
{/if}
+464 -19
View File
@@ -29,6 +29,11 @@
let step = $state("source");
let root = $state(null); // the folder being imported
// Where `root` sits relative to the LIBRARY folder. An absolute path is
// only true on the machine that wrote it; this is what survives the drive
// being carried somewhere else and coming back at another letter.
let rootRel = $state(null);
let rootMissing = $state(false); // saved source folder could not be found
let dest = $state(null); // { dir, freeBytes, layout } from the backend
let scan = $state(null); // ScanResult
let scanProg = $state(null); // latest per-file tick; running totals ride along
@@ -93,7 +98,7 @@
return [rel, rest];
}));
await api.importSessionSet(JSON.stringify({
step, root, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
step, root, rootRel, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
}));
} catch (e) {
@@ -108,8 +113,29 @@
if (!raw) return;
const v = JSON.parse(raw);
if (v?.root) {
root = v.root;
// The saved path first — nothing has moved in the common case.
// Then the same folder relative to where the library is NOW,
// which is how a review saved at one machine finds its source
// again at another. Both `root` and the scan's own copy are
// repointed: apply passes `scan.root`, so leaving that stale
// would resume onto a folder that is not there.
// NOT `found` — that name is the identify-results state, and
// shadowing it here throws on the assignment further down.
const liveRoot = await api
.importResolveRoot(v.root, v.rootRel ?? null)
.catch(() => null);
root = liveRoot ?? v.root;
// A session saved before the portable form existed learns it
// now, while its source is still findable — waiting until the
// next save would strand any review already in progress.
rootRel = v.rootRel
?? (liveRoot ? await api.importPortableRoot(liveRoot).catch(() => null) : null);
rootMissing = !liveRoot;
scan = v.scan ?? null;
if (scan && liveRoot) scan.root = liveRoot;
// Learned or repointed: write it back now rather than waiting
// for a transition that may never come.
const learned = rootRel !== (v.rootRel ?? null) || root !== v.root;
layoutChoice = v.layout ?? null;
choices = v.choices ?? {};
found = v.found ?? {};
@@ -123,6 +149,7 @@
auditions = v.auditions ?? {};
keepSeparate = v.keepSeparate ?? [];
step = v.step === "scanning" ? "source" : (v.step ?? "source");
if (learned) schedulePersist();
}
} catch (e) {
console.warn("import: session restore failed", e);
@@ -161,8 +188,7 @@
// The dev seed ({ root } form) still overrides the source on open.
$effect(() => {
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
root = ui.importOpen.root;
scan = null;
setRoot(ui.importOpen.root);
step = "source";
}
});
@@ -206,9 +232,18 @@
}
// ---- source ----------------------------------------------------------
/** Every route to a source folder goes through here, so the portable form
* is recorded at the same moment as the absolute one and cannot drift. */
async function setRoot(p) {
root = p;
scan = null;
rootMissing = false;
rootRel = p ? await api.importPortableRoot(p).catch(() => null) : null;
}
async function pickSource() {
const p = await api.pickFolder().catch(() => null);
if (p) { root = p; scan = null; }
if (p) await setRoot(p);
}
async function pickDest() {
@@ -473,7 +508,9 @@
else
k = matchEvidence(
f.result.candidate,
artistOf.get(x.rel) ?? x.artistFolder,
// The artist the answer was found under — the folder,
// unless the tags found what the folder could not.
f.result.askedArtist ?? artistOf.get(x.rel) ?? x.artistFolder,
albumOf.get(x.rel) ?? x.title,
x.tracks.length,
).verdict;
@@ -708,6 +745,125 @@
}
}
/** Ask again for the tracks a found copy still owes us.
*
* Transfers do not survive the app; the files they wrote do. So a session
* restored after a crash, a power cut or an ordinary restart brings back
* an audition that LOOKS live and is not: `restStarted` is already true,
* so nothing ever asks for the tracks that never landed, and every future
* apply refuses the record for the same reason — "the found copy is still
* arriving" — with nothing arriving. Two records sat at 12 of 13 and 6 of
* 11 that way.
*
* The pen decides how far it got, not the counter: `arrived` is a running
* total kept by events, and those events stopped with the process that
* was listening for them. A count that was already right costs one stat
* per file to confirm; a count that was wrong is corrected before it can
* refuse anything again.
*
* Asked at most once per record per run. A peer that has gone away should
* cost one round of requests, not a fresh one every time the record is
* looked at — "Try another peer" is the answer to that, and it is a
* deliberate act. */
const resumeAsked = new Set();
async function resumeAudition(rel) {
const au = auditions[rel];
if (!au || resumeAsked.has(rel)) return;
const files = au.candidate?.files ?? [];
if (!files.length || ["searching", "none", "failed"].includes(au.status)) return;
resumeAsked.add(rel);
const peer = au.candidate.username;
let missing;
try {
missing = await api.importAuditionMissing(
rel,
files.map((f) => ({ path: f.path, size: f.size ?? 0 })),
);
} catch (e) {
console.warn("import: could not read the pen", e);
resumeAsked.delete(rel);
return;
}
// Anything could have happened across the await — a switch to another
// peer, a fresh search. Credit this answer only to the copy it is about.
const cur = auditions[rel];
if (!cur || cur.candidate?.username !== peer) return;
cur.expected = files.length;
cur.arrived = files.length - missing.length;
// WHICH files are owed, not just how many — the strip states a size
// only when it knows exactly which files make it up.
cur.missingIdx = missing;
if (!missing.length) {
// Everything is already here. The record was refused by a stale
// number, and nothing needs downloading to unstick it.
cur.status = "complete";
schedulePersist();
return;
}
for (const i of missing) {
try {
const { ids } = await startAudition(rel, cur.candidate, { from: i, count: 1 });
cur.ids = [...(cur.ids ?? []), ...ids];
} catch (e) {
console.warn("import: could not re-request a missing track", e);
break;
}
}
cur.status = cur.track1Path ? "ready" : "downloading";
cur.xfer = { ...(cur.xfer ?? {}), phase: "requested", at: Date.now() };
schedulePersist();
}
/** Ask again for every refusal that is a found copy still owed tracks.
*
* Called from BOTH applies. They refuse the same way and have to recover
* the same way: wiring only the full one left the picker's partial apply
* — the one that runs when you import from the review itself — repeating
* "it imports as soon as the rest land" over records nothing was asking
* for. No-ops on a refusal that is not about an audition. */
function resumeRefused(skipped) {
for (const s of skipped ?? []) resumeAudition(s.rel);
}
// Opening a record is when we find out whether its found copy is still
// owed anything — and the only moment a resumed transfer has somewhere to
// be seen. Deliberately not a sweep at window-open: that would put a
// dozen peers' worth of requests on the wire for records nobody is
// looking at. The refused set after an apply is handled there.
$effect(() => {
if (step !== "upgrades" || !open) return;
const rel = curPick?.rel;
const au = rel ? auditions[rel] : null;
const want = au?.candidate?.files?.length ?? 0;
if (!want || (au.arrived ?? 0) >= (au.expected ?? want)) return;
resumeAudition(rel);
});
/** Throw away what the abandoned peer left in the pen.
*
* The cancel stops its transfers; this removes what they already wrote.
* The pen is keyed by RECORD, not by peer, so without it the dropped
* copy's files sit beside the replacement's until the session is cleared
* — 189 MB of a stranger's Centipede Hz did exactly that, and apply filed
* all 17 tracks. Apply takes the chosen copy's files by name now, so this
* is about disk rather than correctness; the ORDER still matters, because
* a file written after the clear would be a stray nothing asked for.
*
* Deliberately unconditional: "another peer" means this copy is not the
* one, and a partial copy of a rip you have walked away from is not worth
* the gigabytes. The automatic switch only fires before track 1 lands, so
* it can never discard more than one unfinished file. */
async function clearPen(rel, why) {
try {
const freed = await api.importAuditionClear(rel);
if (freed) {
console.info(`import: pen cleared for ${rel} — ${Math.round(freed / 1e6)} MB (${why})`);
}
} catch (e) {
console.warn("import: could not clear the pen", e);
}
}
/** Stop waiting on this peer and drop to the next ranked copy.
*
* Cancels the transfers first — without that, a switch left the old peer
@@ -721,6 +877,7 @@
for (const id of au.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
await clearPen(rel, reason ?? "peer switched");
const [next, ...rest] = au.alts ?? [];
if (!next) {
auditions[rel] = { status: "none", dead, exhausted: true, reason };
@@ -751,10 +908,13 @@
for (const id of prev?.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
// A fresh search abandons the current copy just as squarely as a
// switch does; whatever answers will be a different peer's rip.
if (prev?.candidate) await clearPen(rel, "searched again");
auditions[rel] = { status: "searching", dead };
try {
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
const list = await findAlbumCopies({
const { copies, silent } = await findAlbumCopies({
artist: pickerArtist(x),
// The record's own name leads; its folder is the narrower
// fallback for when the tags are wrong or absent.
@@ -763,15 +923,18 @@
candidate: trusted ? found[rel]?.result?.candidate : null,
wantTracks: x.tracks.length,
});
if (!list.length) {
auditions[rel] = { status: "none", dead };
if (!copies.length) {
// `silent` separates "the network never answered" from "peers
// answered and none had it" — they read identically on the
// card otherwise, and the first one is not about this record.
auditions[rel] = { status: "none", dead, silent };
schedulePersist();
return;
}
await beginAudition(
rel,
slimCandidate(list[0]),
list.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
slimCandidate(copies[0]),
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
{ dead },
);
} catch (e) {
@@ -812,6 +975,7 @@
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
if (p.done) {
au.arrived = (au.arrived ?? 0) + 1;
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
if (idx === 0) {
au.track1Path = p.localPath;
au.status = "ready";
@@ -900,6 +1064,105 @@
};
}
/* ---- what the upgrade step is still waiting on ---------------------- */
/** Records whose found copy is not fully in the pen yet, plus ones that
* ARE and have not been imported — a finished copy belongs here too,
* because it is the thing standing between you and pressing Import.
*
* Deliberately not "every audition": a record you listened to and then
* kept your own copy of is not something you are waiting for. */
const arrivals = $derived(
Object.entries(auditions)
.flatMap(([rel, au]) => {
const files = au?.candidate?.files ?? [];
const want = au?.expected ?? files.length;
if (!files.length || !want) return [];
const have = Math.max(0, Math.min(au.arrived ?? 0, want));
const owed = want - have;
const theirs = choices[rel]?.audio === "theirs";
if (choices[rel]?.applied || (!owed && !theirs)) return [];
// Bytes only when the pen check told us WHICH files are owed.
// Owed-count times an average would be a number that looks
// measured and is not.
const idx = au.missingIdx;
return [{
rel, au, want, have, owed, theirs,
name: rel.split(/[\\/]/).pop() || rel,
peer: au.candidate.username,
live: liveOf(au),
bytes: idx ? idx.reduce((n, i) => n + (files[i]?.size ?? 0), 0) : null,
}];
})
.sort((a, b) => b.owed - a.owed || a.name.localeCompare(b.name)));
const arrivalsOwed = $derived(arrivals.filter((a) => a.owed > 0));
const arrivalFiles = $derived(arrivalsOwed.reduce((n, a) => n + a.owed, 0));
const arrivalStalled = $derived(arrivalsOwed.some((a) => a.live?.stalled));
const arrivalBytes = $derived(
arrivalsOwed.length && arrivalsOwed.every((a) => a.bytes != null)
? arrivalsOwed.reduce((n, a) => n + a.bytes, 0) : null);
let trayOpen = $state(false);
/** What one row says, in the language the picker already uses. Nothing
* here invents a state: every branch is something the transfer events
* actually said, or the plain absence of them — after a restart there is
* no `xfer` at all, and "asked, no answer yet" is the truth then. */
function arrivalWords(a) {
if (!a.owed) return { cls: "ok", label: `all ${a.want} cached`, detail: "ready to import" };
const x = a.live;
if (x?.stalled) {
const mins = Math.max(1, Math.round((x.quietMs ?? 0) / 60000));
return { cls: "stall", label: `nothing for ${mins} min`, detail: `${a.peer} stopped sending` };
}
if (x?.phase === "transferring") {
const mbs = (x.speedBps ?? 0) / 1e6;
return { cls: "move", label: "arriving", detail: mbs >= 0.05 ? `${mbs.toFixed(1)} MB/s` : "slowly" };
}
if (x?.place != null || x?.phase === "queued") {
return {
cls: "wait", label: `queued at ${a.peer}`,
detail: x.place != null ? `${nfmt(x.place)} ahead of you` : "nothing moving yet",
};
}
return { cls: "wait", label: `asked ${a.peer}`, detail: "no answer yet" };
}
/** The container is what the peer sent, the depth and bitrate are what the
* probe MEASURED of the file that landed. */
function arrivalFormat(a) {
const first = a.au.candidate.files?.[0];
const ext = String(first?.name ?? first?.path ?? "").split(".").pop().toUpperCase();
const p = a.au.probe;
if (a.au.candidate.lossless === a.au.candidate.trackCount) {
return p?.bitDepth ? `${ext} ${p.bitDepth}-bit` : ext;
}
const k = p?.bitrate ?? a.au.candidate.avgBitrate;
return k ? `${ext} ${nfmt(k)}k` : ext;
}
const arrivalInitials = (name) =>
name.replace(/^.*?[-–—]\s*/, "").split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase();
/** Jump the picker to a record in the tray. The lens can be narrower than
* the tray — a record filtered out of the current bucket is still one you
* are waiting for — so widen rather than fail silently. */
function goToArrival(rel) {
if (!seekTo(rel)) {
bucket = "all";
seekTo(rel);
}
trayOpen = false;
schedulePersist();
}
/** Refusals the tray is not already speaking for. A record whose found
* copy is still arriving is IN the tray — live, with the peer and the
* count — and saying it again in the past tense above is the same fact
* twice, the staler of the two winning the eye. */
const leftBehind = $derived(
(report?.skipped ?? []).filter((s) => !arrivals.some((a) => a.rel === s.rel)));
/** The picker's resolved snapshot — what apply writes. Stored beside the
* choices but NOT a "seen": looking is not deciding. */
function snapshot(rel, snap) {
@@ -1182,6 +1445,12 @@
if (c && !refused.has(rel)) c.applied = true;
if (refused.size) {
await persist();
// A refusal is usually "the found copy is short", and the copy
// is short because a transfer died. Ask for the rest NOW, so
// "it imports as soon as the rest land" is something that can
// actually happen — otherwise the next run refuses the same
// records for the same reason, forever.
resumeRefused(report.skipped);
} else {
await api.importSessionClear().catch(() => {});
importResume.active = false;
@@ -1221,6 +1490,17 @@
// copy out of the source folder.
audio: r.audio === "theirs" ? "theirs" : null,
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : null,
// WHICH files the chosen copy is made of. The pen is
// keyed by RECORD, not by peer, so a record that
// changed peers has both copies sitting in it — the
// abandoned peer's transfers were cancelled, but the
// files they had already written stayed. Apply took
// everything in the folder and filed Centipede Hz as
// 17 tracks: deletia's 11 plus the 6 smash2 had
// managed before it was dropped.
audioFiles: r.audio === "theirs"
? (auditions[p.rel]?.candidate?.files ?? []).map((f) => f.path)
: null,
},
};
});
@@ -1231,7 +1511,17 @@
* doesn't redo them. The eventual full apply still covers everything —
* idempotent copies make the banked files no-ops. */
async function applyPartial() {
if (!dest?.dir || applying || !scan) return;
// Banking on the way out must never fail in silence. Closing the
// picker after deciding 25 records and finding nothing in the library —
// with no toast, no error and nothing in the log — is what a bare
// `return` here looks like from the outside.
if (applying || !scan) return;
if (!dest?.dir) {
importStatus.phase = "error";
importStatus.error = "No library folder is set, so nothing could be imported.";
console.warn("import: banking skipped — no destination");
return;
}
const seen = new Set(Object.entries(choices)
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
if (!seen.size) return;
@@ -1255,6 +1545,7 @@
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
for (const r of seen)
if (choices[r] && !refused.has(r)) choices[r].applied = true;
resumeRefused(rep.skipped);
importStatus.done = rep.albums;
importStatus.label = fmtGB(rep.bytes);
importStatus.skipped = refused.size;
@@ -1297,6 +1588,8 @@
}
step = "source";
root = null;
rootRel = null;
rootMissing = false;
scan = null;
scanProg = null;
scanEta = null;
@@ -1364,14 +1657,14 @@
show it on, so without this the only trace of a refusal was
a toast reading "Imported 0 records". Not dismissible while
it is still true — it clears itself when they import. -->
{#if step !== "done" && report?.skipped?.length}
{#if step !== "done" && leftBehind.length}
<div class="im-left">
<b>{nfmt(report.skipped.length)} record{report.skipped.length === 1 ? "" : "s"}
{report.skipped.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {report.skipped.length === 1 ? "it" : "them"} and
{report.skipped.length === 1 ? "it is" : "they are"} still on the list —
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
importing again once the reason below is gone will pick
{report.skipped.length === 1 ? "it" : "them"} up.
{leftBehind.length === 1 ? "it" : "them"} up.
<div class="im-left-rows">
<!-- The FOLDER, not the tag album name: this record's
tags call it "user48736353001", which names
@@ -1397,7 +1690,13 @@
<div class="im-drop-t">
<div class="im-drop-h">The music — read from</div>
<div class="im-path">{root ?? "Choose a folder…"}</div>
{#if scan}
{#if rootMissing}
<!-- A resumed review whose source could not be
found: say so HERE, where the folder is
named, rather than letting apply fail on
a path the review still displays. -->
<small class="missing">this folder isn't here — choose where it lives now</small>
{:else if scan}
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
{fmtGB(totals.bytes)} · scanned</small>
{:else if root}
@@ -1638,7 +1937,83 @@
{label}<i>{nfmt(n)}</i>
</button>
{/each}
<!-- Not a lens: the outcome chips choose what the picker
WALKS, this one discloses what the found copies still
owe. It rides here because it belongs to the same
glance, and it costs no space at all when nothing is
owed — which is the normal case. -->
{#if arrivals.length}
<button class="im-bucket ar-chip" class:stalled={arrivalStalled}
aria-expanded={trayOpen}
onclick={() => (trayOpen = !trayOpen)}
title={arrivalFiles
? "What the found copies still owe"
: "Copies cached and waiting to be imported"}>
{#if arrivalFiles}<span class="ar-dot"></span>{/if}
{arrivalStalled ? "Stalled" : arrivalFiles ? "Arriving" : "Arrived"}
<i>{nfmt(arrivalFiles || arrivals.length)}</i>
</button>
{/if}
</div>
{#if trayOpen && arrivals.length}
<div class="ar-tray" class:stalled={arrivalStalled} class:done={!arrivalFiles}>
<div class="ar-h">
{#if !arrivalFiles}
<b>Everything arrived.</b>
{arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`}
cached — importing now takes {arrivals.length === 1 ? "it" : "them"}.
{:else if arrivalStalled}
<b>Stalled.</b> {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}
still owed, and nothing is moving.
{:else}
<b>Still arriving.</b> {nfmt(arrivalsOwed.length)}
record{arrivalsOwed.length === 1 ? "" : "s"} ·
{nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} ·
{nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}.
{/if}
</div>
<div class="ar-rows">
{#each arrivals as a (a.rel)}
{@const w = arrivalWords(a)}
<div class="ar-row">
<div class="ar-art" class:ok={!a.owed}>{arrivalInitials(a.name)}</div>
<div class="ar-nm">
<b title={a.rel}>{a.name}</b>
<div class="ar-meta">
<div class="im-bar" class:live={a.owed > 0 && a.live?.phase === "transferring"}>
<i style:width="{Math.round((a.have / a.want) * 100)}%"></i>
</div>
<span>{nfmt(a.have)} of {nfmt(a.want)} cached</span>
<span>·</span><span>{arrivalFormat(a)}</span>
<b class="ar-state {w.cls}">{w.label}</b>
<span>{w.detail}</span>
</div>
</div>
<div class="ar-acts">
<button class="pill ghost" onclick={() => goToArrival(a.rel)}>Go to record</button>
{#if a.owed}
<button class="pill ghost"
onclick={() => switchPeer(a.rel, "you asked for another peer")}
>Try another peer</button>
{/if}
</div>
</div>
{/each}
</div>
<div class="ar-foot">
{#if arrivalFiles}
Nothing here needs you — they import themselves on the next run once the
files land. "Keep mine" on the record takes your own copy instead.
{:else}
<!-- The picker's own bank, not the full run:
it applies what has been decided and not
yet written, which is exactly these. -->
<button class="pill" onclick={applyPartial} disabled={applying}>
Import {nfmt(arrivals.length)} record{arrivals.length === 1 ? "" : "s"}</button>
{/if}
</div>
</div>
{/if}
{#if curPick}
{#key curPick.rel}
<ImportPicker
@@ -2117,6 +2492,72 @@
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
.im-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- what the found copies still owe ------------------------------
The chip rides in the outcome row but is NOT one of them: the others
choose what the picker walks, this one discloses a state. Accent fill
and a pulse say so, and it is absent entirely when nothing is owed. */
.im-bucket.ar-chip {
background: color-mix(in srgb, var(--accent) 26%, rgba(10, 10, 12, 0.5));
color: #fff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.im-bucket.ar-chip:hover {
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
}
.im-bucket.ar-chip.stalled {
background: rgba(233, 185, 73, 0.2); color: #f0cd77;
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.4);
}
.ar-dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
animation: ar-pulse 1.4s ease-in-out infinite;
}
@keyframes ar-pulse { 50% { opacity: 0.25; } }
.ar-tray {
position: relative; z-index: 1;
margin: -6px 0 16px; padding: 11px 13px; border-radius: var(--radius);
max-width: 96ch;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.ar-h {
display: flex; align-items: baseline; gap: 9px;
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
}
.ar-h b { color: color-mix(in srgb, var(--accent) 55%, #fff); font-weight: 800; }
.ar-tray.stalled .ar-h b { color: #f0cd77; }
.ar-tray.done .ar-h b { color: #7fe0a4; }
.ar-rows { margin-top: 9px; display: flex; flex-direction: column; gap: 7px; }
.ar-row {
display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
padding: 8px 9px; border-radius: 6px; background: rgba(255, 255, 255, 0.03);
}
.ar-art {
width: 34px; height: 34px; border-radius: 4px; display: grid; place-items: center;
font-size: 10px; font-weight: 800; color: rgba(255, 255, 255, 0.78);
background: color-mix(in srgb, var(--accent) 30%, rgba(255, 255, 255, 0.06));
}
.ar-art.ok { background: rgba(127, 224, 164, 0.22); }
.ar-nm { min-width: 0; }
.ar-nm > b {
display: block; font-size: 13px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ar-meta {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-top: 3px; font-size: 11.5px; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.ar-meta .im-bar { width: 120px; flex: none; }
.ar-state { font-weight: 700; }
.ar-state.wait { color: var(--text-secondary); }
.ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); }
.ar-state.stall { color: #f0cd77; }
.ar-state.ok { color: #7fe0a4; }
.ar-acts { display: flex; gap: 6px; align-items: center; }
.ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
.im-left {
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
@@ -2137,6 +2578,9 @@
}
.im-reset b { color: #f0a0a0; }
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
/* The source folder a resumed review could not find. Amber, not red: it
reports something that happened rather than asking to destroy anything. */
.im-drop-t small.missing { color: rgb(232, 178, 96); }
.pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
@@ -2177,5 +2621,6 @@
@media (prefers-reduced-motion: reduce) {
.im-bar.live i { animation: none; }
.led-pulse { animation: none; }
.ar-dot { animation: none; }
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script>
// Fullscreen lyrics: the whole window washed in the playing record's
// accent, the same LyricsPane rendered at reading-room scale. Opened from
// the pane's expand button; Escape or ✕ returns to the app.
import { ui, player, theme } from "../lib/state.svelte.js";
import LyricsPane from "./LyricsPane.svelte";
import Icon from "./Icon.svelte";
const artist = $derived.by(() => {
const track = player.track;
if (!track) return "";
return (track.artist ?? "").trim() || String(track.sub ?? "").split(" — ")[0].trim();
});
function onkeydown(e) {
if (e.key === "Escape") ui.lyricsFull = false;
}
// Close with the track when playback stops entirely — a lyrics room with
// nothing playing is a dead end.
$effect(() => {
if (ui.lyricsFull && !player.track) ui.lyricsFull = false;
});
</script>
<svelte:window onkeydown={ui.lyricsFull ? onkeydown : undefined} />
{#if ui.lyricsFull}
<div class="room" style="--wash: {theme.accent}">
<header>
<div class="who">
<p class="t">{player.track?.title ?? ""}</p>
{#if artist}<p class="a">{artist}</p>{/if}
</div>
<button class="close" title="Close (Esc)" onclick={() => (ui.lyricsFull = false)}>
<Icon name="shrink" size={18} />
</button>
</header>
<div class="body">
<LyricsPane big />
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 90% at 20% 0%, color-mix(in srgb, var(--wash) 32%, transparent), transparent 60%),
radial-gradient(120% 90% at 90% 100%, color-mix(in srgb, var(--wash) 14%, transparent), transparent 55%),
var(--bg-base);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 38px 10px;
flex: none;
}
.who {
min-width: 0;
}
.t {
font-size: 17px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
flex: none;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.body {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
padding: 6px 38px 30px;
}
.body :global(.lyr) {
width: min(820px, 100%);
height: 100%;
}
</style>
+911
View File
@@ -0,0 +1,911 @@
<script>
// The Now Playing lyrics pane: synced lines that follow playback with the
// active line held at SEAT, click-to-seek, a sync nudge, a candidate
// picker and a paste door for tracks nothing automatic can cover. Also
// rendered by the fullscreen overlay in `big` mode — one component, two
// type scales.
import { player, ui, playLocalFile } from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
activeWordIndex,
totalOffsetMs,
nudgeOffset,
setAutoSync,
searchCandidates,
closeCandidates,
chooseCandidate,
savePastedLyrics,
resetLyrics,
openSyncEditor,
} from "../lib/lyrics.svelte.js";
import { lyricLinesOf } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { untrack } from "svelte";
let { big = false } = $props();
let box = $state(null);
let pasting = $state(false);
let pasteText = $state("");
let menuOpen = $state(false);
// While the user is scrolling the box themselves, auto-follow stands
// down; it resumes a few seconds after the wheel goes quiet.
let holdUntil = 0;
// Load on mount and again whenever the playing track changes while the
// pane is visible. noteTrackChange clears stale lines synchronously, and
// an open paste box or menu goes with them — a paste typed for one song
// must never be saved under the identity of the next.
let shownKey = null;
$effect(() => {
const identity = lyricIdentity();
const key = identity ? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}` : "";
if (key !== shownKey) {
shownKey = key;
pasting = false;
pasteText = "";
menuOpen = false;
}
// untrack: ensureLyrics reads AND writes lyricsStore.status/key
// synchronously, so tracked it makes a failed fetch retrigger this
// effect forever (error → loading → error…) — the exact boundary
// NowPlaying's loadDiscovery documents. The effect keys off the
// playing IDENTITY alone.
untrack(() => {
noteTrackChange();
void ensureLyrics();
});
});
const active = $derived(
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
);
// ---- instrumental (offline vocal reduction) ----
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
const canInstrumental = $derived(
!!currentItem?.path && !currentItem?.stream && lyricsStore.status !== "loading",
);
let instrumentalBusy = $state(false);
let instrumentalNote = $state("");
async function playInstrumental() {
const item = currentItem;
if (instrumentalBusy || !item?.path || item.stream) return;
instrumentalBusy = true;
instrumentalNote = "";
try {
const res = await api.lyricsPrepareInstrumental(item.path);
// The prepare can take seconds; if the queue moved on meanwhile,
// starting the OLD track's instrumental would hijack whatever is
// playing now.
const current = player.queue?.[player.queueIndex] ?? null;
if (current?.path !== item.path) {
instrumentalNote = "The track changed while preparing — ready under ⋯ next time.";
return;
}
menuOpen = false;
const artist =
(item.artist ?? "").trim() || String(item.sub ?? "").split(" — ")[0].trim();
// The ORIGINAL title on purpose: the lyric identity stays with the
// recording, so the words keep following the instrumental.
await playLocalFile(res.path, item.title, `${artist} — instrumental`, item.art ?? null);
} catch (e) {
instrumentalNote = String(e).replace(/^Error:\s*/, "").slice(0, 140);
} finally {
instrumentalBusy = false;
}
}
// Where the active line is held inside the box: 0 = its top edge, 1 = its
// bottom. Above centre on purpose — dead centre spends half the box on
// words already sung, and the lead spacer it needed marooned a song's
// first lines below a screenful of nothing. The stylesheet's .tail-space
// is this value's complement, so the LAST line still reaches the same
// seat as every other one.
const SEAT = 0.42;
// Follow the active line, held at SEAT, unless the user is exploring.
$effect(() => {
const index = active;
if (index < 0 || !box || lyricsStore.status !== "synced") return;
if (performance.now() < holdUntil) return;
const el = box.querySelector(`[data-line="${index}"]`);
if (!el) return;
// Measured against the BOX, not offsetTop. offsetTop counts from
// offsetParent, which here is .pane (its panein keyframes animate a
// transform, so Chromium hands it the role) — the toolbar's height
// rode along in every target, seating the line ~30px above wherever
// the number said. Rects and scrollTop are origin-independent, so
// SEAT means what it reads.
const top = box.scrollTop + (el.getBoundingClientRect().top - box.getBoundingClientRect().top);
const target = top - box.clientHeight * SEAT + el.offsetHeight / 2;
box.scrollTo({
top: Math.max(0, target),
behavior: ui.windowActive ? "smooth" : "auto",
});
});
function userScrolled() {
holdUntil = performance.now() + 3500;
}
function seekTo(line) {
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
async function submitPaste() {
const ok = await savePastedLyrics(pasteText);
if (ok) {
pasting = false;
pasteText = "";
}
}
const SOURCE_LABEL = {
sidecar: "from a lyrics file",
embedded: "from the audio file",
lrclib: "via LRCLIB",
amll: "via AMLL DB · word-synced",
picked: "picked on LRCLIB",
user: "pasted by you",
};
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
</script>
<div class="lyr" class:big>
{#if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
<div class="shimmer" aria-hidden="true">
<span style="width: 72%"></span>
<span style="width: 58%"></span>
<span style="width: 81%"></span>
<span style="width: 47%"></span>
</div>
{:else if lyricsStore.candidates !== null}
<div class="picker">
<div class="pick-head">
<span>Choose the right lyrics</span>
<button class="tool" title="Close" onclick={closeCandidates}>
<Icon name="x" size={14} />
</button>
</div>
{#if lyricsStore.candidatesLoading}
<p class="note">Searching LRCLIB…</p>
{:else if lyricsStore.candidates.length === 0}
<p class="note">Nothing found for this track name.</p>
{:else}
<div class="cands">
{#each lyricsStore.candidates as c (c.id)}
<button class="cand" onclick={() => chooseCandidate(c.id)}>
<span class="c-main">
<span class="c-t">{c.title}</span>
<span class="c-a">{c.artist}{c.album ? ` — ${c.album}` : ""}</span>
</span>
{#if c.synced}<span class="c-tag">synced</span>{/if}
{#if c.instrumental}<span class="c-tag inst">instrumental</span>{/if}
<span class="c-d">{fmtDur(c.durationSecs)}</span>
</button>
{/each}
</div>
{/if}
</div>
{:else if pasting}
<div class="paste">
<p class="note">
Paste lyrics — plain text, or LRC with <span class="mono">[mm:ss]</span> timestamps
for synced display.
</p>
<textarea bind:value={pasteText} spellcheck="false" placeholder="[00:12.00] First line…"></textarea>
<div class="paste-actions">
<button class="pbtn ghost" onclick={() => (pasting = false)}>Cancel</button>
<button class="pbtn" onclick={submitPaste} disabled={!pasteText.trim()}>Save</button>
</div>
</div>
{:else if lyricsStore.status === "synced"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""}</span>
{#if lyricsStore.autoOffsetMs !== 0}
<button
class="autochip"
title="Timing auto-corrected from this file's extra lead-in — click to turn off"
onclick={() => setAutoSync(false)}
>
auto {lyricsStore.autoOffsetMs > 0 ? "+" : ""}{(lyricsStore.autoOffsetMs / 1000).toFixed(1)}s
</button>
{/if}
<span class="nudge" title="Shift lyric timing">
<button class="tool" title="Lyrics earlier" onclick={() => nudgeOffset(-500)}>−</button>
<span class="off" class:on={lyricsStore.offsetMs !== 0}>
{lyricsStore.offsetMs === 0
? "sync"
: `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`}
</span>
<button class="tool" title="Lyrics later" onclick={() => nudgeOffset(500)}>+</button>
</span>
{@render tools()}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="lines"
bind:this={box}
onwheel={userScrolled}
ontouchmove={userScrolled}
>
<div class="lead-space"></div>
{#each lyricsStore.lines as line, index (index)}
<button
class="line"
class:on={index === active}
class:past={index < active}
class:gap={!line.text}
data-line={index}
onclick={() => seekTo(line)}
title="Play from here"
>
{#if index === active && line.words?.length}
<!-- Real word timing from the source: crisp per-word.
This is the ONLY karaoke mode. A line-level source
gets the whole-line highlight and nothing more —
a sweep interpolated across the line's window
tracked the layout, not the voice (it crawled
through instrumental gaps), so it read as broken.
Word times are carried, never invented (the
Noctis rule). -->
{@const sungTo = activeWordIndex(line, player.position ?? 0)}
<span class="wline">
{#each line.words as word, wi (wi)}
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
{/each}
</span>
{:else}
{line.text || "· · ·"}
{/if}
</button>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "plain"}
<div class="toolbar">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""} · not synced</span>
{@render tools()}
</div>
<button
class="syncnow"
title="Play the song once and tap each line as it begins"
onclick={() => openSyncEditor(lyricLinesOf(lyricsStore.plain))}
>
<Icon name="clock" size={14} /> Sync these lyrics
</button>
<!-- The same box, the same type, the same fade as the synced list —
a lyric sheet is a lyric sheet whether or not anything timed it.
Only the parts that need a clock are absent: no line is lit, and
none of them seek. -->
<div class="lines plain" bind:this={box}>
<div class="lead-space"></div>
{#each lyricsStore.plain.split("\n") as pline, i (i)}
<p class="line" class:gap={!pline.trim()}>{pline.trim() ? pline : "· · ·"}</p>
{/each}
<div class="tail-space"></div>
</div>
{:else if lyricsStore.status === "instrumental"}
<div class="statebox">
<span class="state-glyph"><Icon name="music" size={26} /></span>
<p class="state-t">Instrumental</p>
<p class="state-s">Sit back — there are no words to follow.</p>
</div>
{:else if lyricsStore.status === "error"}
<div class="statebox">
<p class="state-t">Couldn’t look up lyrics</p>
<p class="state-s">Something failed along the way.</p>
<button class="pbtn" onclick={() => ensureLyrics(true)}>Try again</button>
</div>
{:else}
<div class="statebox">
<p class="state-t">No lyrics found</p>
<p class="state-s">
Nothing local and no LRCLIB match for this exact recording.
</p>
<div class="state-actions">
<button class="pbtn" onclick={searchCandidates}>
<Icon name="search" size={13} /> Search LRCLIB
</button>
<button class="pbtn ghost" onclick={() => (pasting = true)}>
<Icon name="file-text" size={13} /> Paste lyrics
</button>
</div>
</div>
{/if}
</div>
{#snippet tools()}
<span class="tools">
{#if !big}
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
<Icon name="expand" size={13} />
</button>
{/if}
<span class="tool-menu">
<button
class="tool"
title="Lyrics options"
onclick={(e) => {
e.stopPropagation();
menuOpen = !menuOpen;
}}
>
<Icon name="more-horizontal" size={15} />
</button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if canInstrumental}
<button class="mi" onclick={playInstrumental} disabled={instrumentalBusy}>
<Icon name="music" size={14} />
{instrumentalBusy ? "Preparing instrumental…" : "Play with reduced vocals"}
<span class="mi-hint">beta</span>
</button>
{#if instrumentalNote}
<p class="mi-note">{instrumentalNote}</p>
{/if}
<div class="mi-rule"></div>
{/if}
{#if lyricsStore.autoSyncOff}
<button
class="mi"
onclick={() => {
menuOpen = false;
setAutoSync(true);
}}
><Icon name="refresh" size={14} /> Re-enable auto-sync</button>
{/if}
{#if lyricsStore.status === "synced"}
<button
class="mi"
onclick={() => {
menuOpen = false;
openSyncEditor(lyricsStore.lines);
}}
><Icon name="clock" size={14} /> Re-time by hand</button>
{/if}
<button
class="mi"
onclick={() => {
menuOpen = false;
searchCandidates();
}}
><Icon name="search" size={14} /> Wrong lyrics? Search again</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
pasting = true;
}}
><Icon name="file-text" size={14} /> Paste your own</button>
<button
class="mi"
onclick={() => {
menuOpen = false;
resetLyrics();
}}
><Icon name="refresh" size={14} /> Forget and look again</button>
</div>
{/if}
</span>
</span>
{/snippet}
<svelte:window onclick={menuOpen ? () => (menuOpen = false) : undefined} />
<style>
.lyr {
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── loading shimmer ── */
.shimmer {
display: flex;
flex-direction: column;
gap: 13px;
padding: 18px 4px;
}
.shimmer span {
height: 13px;
border-radius: 7px;
background: linear-gradient(
100deg,
var(--bg-highlight) 35%,
var(--bg-press) 50%,
var(--bg-highlight) 65%
);
background-size: 240% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
/* ── toolbar ── */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 2px 8px;
min-height: 30px;
}
.src {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* The label owns the slack: everything after it sits at the right. */
margin-right: auto;
}
.nudge {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--bg-highlight);
border-radius: 12px;
padding: 1px 4px;
flex: none;
}
.nudge .off {
min-width: 38px;
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.nudge .off.on {
color: var(--accent-hover);
}
.tools {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.tool {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.tool:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.tool-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 220px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 60;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.mi:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
/* ── synced lines ── */
.lines {
overflow-y: auto;
max-height: 46vh;
min-height: 180px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
mask-image: linear-gradient(
180deg,
transparent 0,
#000 28px,
#000 calc(100% - 34px),
transparent 100%
);
}
.lines::-webkit-scrollbar {
width: 0;
display: none;
}
/* Just a breath above the first line — enough that the words clear the
toolbar's mask fade, not so much that a song opens on an empty box.
The tail is SEAT's complement (1 - 0.42), which is what lets the final
line scroll up to the same seat the rest of them ride at. */
.lead-space {
height: 7%;
flex: none;
}
.tail-space {
height: 58%;
flex: none;
}
.line {
display: block;
width: 100%;
text-align: left;
padding: 5px 6px;
border-radius: 7px;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
color: var(--text-muted);
transition:
color 0.25s ease,
transform 0.25s ease,
opacity 0.25s ease;
transform-origin: left center;
white-space: pre-wrap;
}
.line:hover {
color: var(--text-secondary);
background: var(--bg-highlight);
}
.line.past {
color: var(--text-secondary);
opacity: 0.55;
}
.line.on {
color: var(--text-primary);
transform: scale(1.03);
opacity: 1;
}
.line.gap {
color: var(--text-muted);
opacity: 0.4;
font-size: 12px;
letter-spacing: 0.3em;
}
.line.gap.on {
color: var(--accent-hover);
opacity: 0.8;
}
/* ── karaoke rendering inside the active line ──
Word mode: crisp steps on the source's own word times. */
.wline {
display: block;
}
.w {
color: var(--text-secondary);
transition: color 0.12s ease;
}
.w + .w {
margin-left: 0.32em;
}
.w.sung {
color: #fff;
}
.autochip {
flex: none;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
padding: 2.5px 8px;
border-radius: 9px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.autochip:hover {
background: color-mix(in srgb, var(--accent) 26%, transparent);
}
.mi:disabled {
opacity: 0.6;
}
.mi .mi-hint {
margin-left: auto;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding-left: 10px;
}
.mi-note {
font-size: 11px;
color: #e8a33d;
padding: 2px 10px 6px;
max-width: 240px;
}
.mi-rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
/* Fullscreen scale — same skeleton, bigger voice. */
.lyr.big .lines {
max-height: none;
height: 100%;
min-height: 0;
flex: 1;
}
.lyr.big .line {
font-size: clamp(22px, 3.1vw, 38px);
font-weight: 800;
line-height: 1.35;
padding: 9px 12px;
border-radius: 12px;
letter-spacing: -0.01em;
}
.lyr.big .line.gap {
font-size: 16px;
}
.lyr.big .toolbar {
padding-bottom: 14px;
}
.syncnow {
display: inline-flex;
align-items: center;
gap: 7px;
align-self: flex-start;
margin: 0 2px 10px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 800;
}
.syncnow:hover {
background: var(--accent-hover);
}
/* ── plain text ──
Everything about the box, the type and the fade comes from .lines and
.line above; only the differences live here. Nothing is seekable, so
the rows stay quiet under the pointer, and with no line ever lit there
is no muted/active contrast to carry — the whole sheet reads at the
secondary tone the synced list gives a line once it has been sung.
The tail mirrors the lead rather than reserving a seat (SEAT's
complement) that nothing will ever scroll up to. */
.lines.plain .line {
color: var(--text-secondary);
cursor: default;
}
.lines.plain .line:hover {
background: none;
color: var(--text-secondary);
}
.lines.plain .tail-space {
height: 7%;
}
/* ── states ── */
.statebox {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 34px 12px 22px;
gap: 4px;
}
.state-glyph {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--bg-highlight);
color: var(--text-muted);
margin-bottom: 8px;
}
.state-t {
font-size: 14.5px;
font-weight: 700;
}
.state-s {
font-size: 12px;
color: var(--text-muted);
max-width: 260px;
}
.state-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pbtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border-radius: 16px;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 700;
margin-top: 4px;
}
.pbtn:hover:not(:disabled) {
background: var(--accent-hover);
}
.pbtn:disabled {
opacity: 0.5;
}
.pbtn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.pbtn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
/* ── candidate picker ── */
.pick-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
font-weight: 700;
color: var(--text-secondary);
padding: 2px 2px 8px;
}
.note {
font-size: 12px;
color: var(--text-muted);
padding: 4px 2px 8px;
}
.mono {
font-family: Consolas, monospace;
font-size: 11px;
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 4px;
color: var(--text-primary);
}
.cands {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 42vh;
overflow-y: auto;
scrollbar-width: none;
}
.cands::-webkit-scrollbar {
width: 0;
display: none;
}
.cand {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
border-radius: 7px;
text-align: left;
}
.cand:hover {
background: var(--bg-highlight);
}
.c-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.c-t {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-a {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c-tag {
flex: none;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 7px;
background: var(--accent-soft);
color: var(--accent-hover);
}
.c-tag.inst {
background: var(--bg-highlight);
color: var(--text-muted);
}
.c-d {
flex: none;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ── paste ── */
.paste textarea {
width: 100%;
min-height: 180px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.6;
outline: none;
}
.paste textarea:focus {
border-color: var(--accent);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@media (prefers-reduced-motion: reduce) {
.shimmer span {
animation: none;
}
.line {
transition: none;
}
}
</style>
+633
View File
@@ -0,0 +1,633 @@
<script>
// The tap-to-sync editor — the research bundle's "minimal correction
// editor", first scope: play the song and press Space (or click) as each
// line begins; nudge or ripple-shift stamps afterwards; optionally
// pre-fill ESTIMATED times from the measured vocal-activity envelope.
// Saving writes the track's own user document, which outranks every
// automatic source in the waterfall.
import { untrack } from "svelte";
import { player } from "../lib/state.svelte.js";
import {
syncEditor,
closeSyncEditor,
saveSyncEditor,
} from "../lib/lyrics.svelte.js";
import { roughSync } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { floatScroll } from "../lib/scrollbar.js";
let cursor = $state(0); // the next row a tap stamps
let ripple = $state(true); // ± adjustments shift later stamps too
let confirmDiscard = $state(false);
let saving = $state(false);
let saveError = $state("");
let activity = $state(null); // { frameMs, frames } | null
let activityFailed = $state(false);
let roughBusy = $state(false);
let listEl = $state(null);
// Position interpolation: ticks land ~5/s; a tap between ticks reads the
// last tick plus wall-clock elapsed, so stamps carry tap precision.
let lastPos = 0;
let lastPosAt = 0;
$effect(() => {
lastPos = player.position ?? 0;
lastPosAt = performance.now();
});
function nowMs() {
const base = (player.status === "playing"
? lastPos + (performance.now() - lastPosAt) / 1000
: (player.position ?? 0));
return Math.max(0, Math.round(base * 1000));
}
// Reset per open; pull the activity envelope for the strip + rough fill.
// Keyed on the open FLIP alone — the untrack boundary is load-bearing
// twice over. Without it this effect (a) read `cursor` back after writing
// it, so the all-stamped re-time path ping-ponged -1 ↔ rows.length until
// Svelte hit effect_update_depth_exceeded and tore the subtree down,
// leaving a painted room no click could close; and (b) depended on every
// row's time_ms through findIndex, so each tap re-ran the whole reset —
// wiping the discard guard and re-running the DSP decode per stamp.
$effect(() => {
if (!syncEditor.open) return;
untrack(() => {
const firstUnstamped = syncEditor.rows.findIndex((r) => r.time_ms == null);
cursor = firstUnstamped < 0 ? syncEditor.rows.length : firstUnstamped;
confirmDiscard = false;
activity = null;
activityFailed = false;
const path = syncEditor.itemPath;
if (path) {
api.lyricsVocalActivity(path)
.then((a) => (activity = a))
.catch(() => (activityFailed = true));
} else {
activityFailed = true;
}
});
});
const stampedCount = $derived(syncEditor.rows.filter((r) => r.time_ms != null).length);
const allStamped = $derived(
syncEditor.rows.length > 0 && stampedCount === syncEditor.rows.length,
);
function stampCursor() {
if (cursor >= syncEditor.rows.length) return;
syncEditor.rows[cursor].time_ms = nowMs();
syncEditor.estimated = false;
cursor += 1;
scrollCursorIntoView();
}
function stampRow(index) {
syncEditor.rows[index].time_ms = nowMs();
syncEditor.estimated = false;
cursor = Math.max(cursor, index + 1);
}
function unstampLast() {
const last = cursor - 1;
if (last < 0) return;
syncEditor.rows[last].time_ms = null;
cursor = last;
scrollCursorIntoView();
}
function adjustRow(index, deltaMs) {
const row = syncEditor.rows[index];
if (row.time_ms == null) return;
row.time_ms = Math.max(0, row.time_ms + deltaMs);
if (ripple) {
for (let i = index + 1; i < syncEditor.rows.length; i++) {
if (syncEditor.rows[i].time_ms != null) {
syncEditor.rows[i].time_ms = Math.max(0, syncEditor.rows[i].time_ms + deltaMs);
}
}
}
}
function scrollCursorIntoView() {
requestAnimationFrame(() => {
listEl
?.querySelector(`[data-row="${Math.min(cursor, syncEditor.rows.length - 1)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function roughFill() {
if (!activity || roughBusy) return;
roughBusy = true;
try {
const filled = roughSync(
syncEditor.rows.map((r) => r.text).join("\n"),
activity.frames,
activity.frameMs,
);
if (filled && filled.length === syncEditor.rows.length) {
for (let i = 0; i < filled.length; i++) {
syncEditor.rows[i].time_ms = filled[i].time_ms;
}
syncEditor.estimated = true;
cursor = 0; // re-tap from the top to correct
} else {
activityFailed = true; // not enough vocal evidence — refused
}
} finally {
roughBusy = false;
}
}
async function restartSong() {
await api.playerControl("seek", 0).catch(() => {});
if (player.status !== "playing") await api.playerControl("resume").catch(() => {});
}
function back5() {
api.playerControl("seek", Math.max(0, (player.position ?? 0) - 5)).catch(() => {});
}
function togglePlay() {
api.playerControl(player.status === "playing" ? "pause" : "resume").catch(() => {});
}
async function save() {
if (!allStamped || saving) return;
saving = true;
saveError = "";
try {
const ok = await saveSyncEditor();
if (!ok) {
saveError =
"Saving failed — if the app was just updated, restart it once so the sync backend is loaded.";
}
} finally {
saving = false;
}
}
function requestClose() {
if (stampedCount > 0 && !confirmDiscard) {
confirmDiscard = true;
return;
}
closeSyncEditor();
}
function onkeydown(e) {
if (e.key === " ") {
e.preventDefault();
e.stopPropagation();
stampCursor();
} else if (e.key === "Backspace") {
e.preventDefault();
unstampLast();
} else if (e.key === "Escape") {
requestClose();
}
}
// Playing another track mid-edit: stamps would land on the wrong clock.
const wrongTrack = $derived.by(() => {
if (!syncEditor.open) return false;
const item = player.queue?.[player.queueIndex] ?? null;
const title = player.track?.title ?? "";
return title !== (syncEditor.identity?.title ?? "") && !(item?.path === syncEditor.itemPath);
});
const playheadPct = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
if (!duration) return 0;
return Math.min(100, ((player.position ?? 0) / duration) * 100);
});
// The activity strip, downsampled to ~180 columns of inline SVG.
const strip = $derived.by(() => {
if (!activity?.frames?.length) return [];
const columns = 180;
const per = Math.max(1, Math.floor(activity.frames.length / columns));
const out = [];
for (let i = 0; i < activity.frames.length; i += per) {
let max = 0;
for (let j = i; j < Math.min(i + per, activity.frames.length); j++) {
max = Math.max(max, activity.frames[j]);
}
out.push(max);
}
return out;
});
const totalMs = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
return duration * 1000 || (activity ? activity.frames.length * activity.frameMs : 0);
});
function fmt(ms) {
if (ms == null) return "–:––";
const total = Math.floor(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
</script>
<svelte:window onkeydown={syncEditor.open ? onkeydown : undefined} />
{#if syncEditor.open}
<div class="room">
<header>
<div class="who">
<p class="eyebrow">Sync lyrics {syncEditor.estimated ? "· estimated — tap to correct" : ""}</p>
<p class="t">{syncEditor.identity?.title ?? ""}</p>
<p class="a">{syncEditor.identity?.artist ?? ""}</p>
</div>
<div class="head-actions">
<span class="count">{stampedCount} / {syncEditor.rows.length} stamped</span>
<button class="btn" onclick={save} disabled={!allStamped || saving}>
{saving ? "Saving…" : "Save timing"}
</button>
<button class="close" title={confirmDiscard ? "Discard timing?" : "Close (Esc)"} onclick={requestClose}>
{#if confirmDiscard}<span class="discard">Discard?</span>{:else}<Icon name="x" size={17} />{/if}
</button>
</div>
</header>
{#if saveError}
<div class="warnbar">
<Icon name="alert" size={13} />
{saveError}
</div>
{/if}
{#if wrongTrack}
<div class="warnbar">
<Icon name="alert" size={13} />
Playback moved to another track — stamps follow the PLAYING clock. Play
“{syncEditor.identity?.title}” again to keep tapping.
</div>
{/if}
<div class="stripwrap" aria-hidden="true">
{#if strip.length}
<svg class="strip" preserveAspectRatio="none" viewBox="0 0 {strip.length} 40">
{#each strip as v, i (i)}
<rect x={i} y={40 - v * 38} width="0.85" height={v * 38} rx="0.3" />
{/each}
{#each syncEditor.rows as row (row)}
{#if row.time_ms != null && totalMs}
<rect
class="mark"
x={(row.time_ms / totalMs) * strip.length}
y="0"
width="0.28"
height="40"
/>
{/if}
{/each}
</svg>
<span class="playhead" style="left: {playheadPct}%"></span>
{:else if activityFailed}
<p class="strip-note">
{syncEditor.itemPath
? "No usable vocal-band evidence for the estimate — tap by hand."
: "Streamed track — the activity strip and estimate need a local file."}
</p>
{:else}
<p class="strip-note">Reading the vocal band…</p>
{/if}
</div>
<div class="controls">
<button class="tbtn" title="Restart the song" onclick={restartSong}>
<Icon name="skip-back" size={14} />
</button>
<button class="tbtn" title="Back 5 seconds" onclick={back5}>−5s</button>
<button class="tbtn main" title="Play / pause" onclick={togglePlay}>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<span class="clock">{fmt((player.position ?? 0) * 1000)}</span>
<span class="spacer"></span>
<button
class="tbtn wide"
title="Estimate rough times from the vocal band, then correct by tapping"
onclick={roughFill}
disabled={!activity || roughBusy}
>
Rough-fill (estimated)
</button>
<label class="ripple">
<input type="checkbox" bind:checked={ripple} />
ripple ± to later lines
</label>
</div>
<p class="hint">
Press <span class="key">Space</span> (or click the line) as each line begins ·
<span class="key">Backspace</span> re-arms the previous line
</p>
<div class="rows" bind:this={listEl} use:floatScroll>
{#each syncEditor.rows as row, index (index)}
<div
class="row"
class:next={index === cursor}
class:stamped={row.time_ms != null}
data-row={index}
>
<button
class="stamp"
title={row.time_ms != null ? "Re-stamp at the playhead" : "Stamp at the playhead"}
onclick={() => stampRow(index)}
>
{fmt(row.time_ms)}
</button>
<span class="text">{row.text}</span>
{#if row.time_ms != null}
<span class="adj">
<button class="abtn" title="Earlier (−0.25s)" onclick={() => adjustRow(index, -250)}>−</button>
<button class="abtn" title="Later (+0.25s)" onclick={() => adjustRow(index, 250)}>+</button>
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 95;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 80% at 15% 0%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%),
var(--bg-base);
padding: 22px 34px 18px;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex: none;
}
.eyebrow {
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent-hover);
}
.t {
font-size: 20px;
font-weight: 800;
margin-top: 2px;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 1px;
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
flex: none;
}
.count {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.btn {
padding: 9px 18px;
border-radius: 18px;
background: var(--accent);
color: #000;
font-size: 13px;
font-weight: 800;
}
.btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn:disabled {
opacity: 0.45;
}
.close {
display: grid;
place-items: center;
min-width: 36px;
height: 36px;
border-radius: 18px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
padding: 0 10px;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.discard {
font-size: 12px;
font-weight: 800;
color: #f15e6c;
}
.warnbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 13px;
border-radius: 9px;
background: rgba(232, 163, 61, 0.13);
color: #e8a33d;
font-size: 12.5px;
font-weight: 700;
flex: none;
}
.stripwrap {
position: relative;
height: 52px;
margin-top: 14px;
border-radius: 8px;
background: var(--bg-elevated);
overflow: hidden;
flex: none;
}
.strip {
position: absolute;
inset: 4px 6px;
width: calc(100% - 12px);
height: calc(100% - 8px);
}
.strip rect {
fill: color-mix(in srgb, var(--accent) 55%, transparent);
}
.strip rect.mark {
fill: #fff;
opacity: 0.85;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1.5px;
background: #fff;
opacity: 0.9;
}
.strip-note {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 12px;
color: var(--text-muted);
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex: none;
}
.tbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 38px;
height: 34px;
padding: 0 12px;
border-radius: 17px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 700;
}
.tbtn:hover:not(:disabled) {
background: var(--bg-press);
color: var(--text-primary);
}
.tbtn:disabled {
opacity: 0.45;
}
.tbtn.main {
background: var(--accent);
color: #000;
}
.tbtn.main:hover {
background: var(--accent-hover);
}
.clock {
font-size: 12.5px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 44px;
}
.spacer {
flex: 1;
}
.ripple {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.ripple input {
accent-color: var(--accent);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
flex: none;
}
.key {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
background: var(--bg-highlight);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
}
.rows {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-top: 10px;
padding-right: 8px;
}
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 7px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.row.next {
background: var(--accent-soft);
}
.row.next .text {
color: var(--text-primary);
font-weight: 800;
}
.stamp {
flex: none;
width: 64px;
text-align: center;
padding: 4px 0;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.row.stamped .stamp {
background: var(--accent-soft);
color: var(--accent-hover);
}
.stamp:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.text {
flex: 1;
min-width: 0;
font-size: 15.5px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.45;
}
.row.stamped .text {
color: var(--text-primary);
}
.adj {
display: none;
gap: 4px;
flex: none;
}
.row:hover .adj {
display: inline-flex;
}
.abtn {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 14px;
font-weight: 800;
}
.abtn:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>
+35 -3
View File
@@ -28,6 +28,8 @@
} from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
import LyricsPane from "./LyricsPane.svelte";
import PlaylistMenu from "./PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import { floatScroll } from "../lib/scrollbar.js";
import { splitDescription } from "../lib/editions.js";
@@ -1087,6 +1089,27 @@
{metaParts[1] || player.track?.title || "Nothing playing"}
</span>
</div>
{#if currentItem}
<PlaylistMenu
compact
align="right"
iconSize={15}
title="Add this track to a playlist"
tracks={() => [
{
title: currentItem.title,
artist: currentItem.artist ?? metaParts[0] ?? null,
album: currentItem.album ?? metaParts[1] ?? null,
duration_secs: currentItem.duration ?? null,
// A stream item's path names a file on the PEER's
// machine — the row goes in as a placeholder that
// streams on click, not as a fake local file.
path: currentItem.stream ? null : currentItem.path,
art_path: currentItem.stream ? null : (currentItem.art ?? null),
},
]}
/>
{/if}
<!-- Saving what you are streaming lived in the popover. It is the
only ACTION that did, so it comes up here rather than being
lost with it. -->
@@ -1272,9 +1295,7 @@
{/if}
{:else if pane === "lyrics"}
<p class="pane-empty">
Lyrics aren't wired up yet — this pane is a placeholder.
</p>
<LyricsPane />
{:else if pane === "artist"}
{@render artistChip()}
@@ -1825,6 +1846,17 @@
color: var(--text-secondary);
}
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
/* The add-to-playlist trigger wears the header-icon look exactly. */
.np-head :global(.plm-trigger) {
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.np-head :global(.plm-trigger:hover) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.np-scroll {
/* Fills the absolutely-positioned .full-view rather than sizing to its
+554
View File
@@ -0,0 +1,554 @@
<script>
// Staged playlist import: parse → resolve → REVIEW → one-transaction
// commit. The preview names what matched and what did not; unresolved
// rows stay visible placeholders unless the user explicitly leaves them
// out. Nothing touches the database until "Create playlist".
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
draft = null,
onimported = () => {},
} = $props();
let pasteText = $state("");
let localDraft = $state(null);
let name = $state("");
let includeUnresolved = $state(true);
let committing = $state(false);
let parsing = $state(false);
let error = $state("");
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
// Re-arm the form whenever the modal opens with a new draft.
$effect(() => {
if (!open) return;
error = "";
committing = false;
localDraft = null;
pasteText = "";
includeUnresolved = true;
name = draft?.name ?? "";
});
async function previewPaste() {
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
localDraft = parsed;
if (!name) name = parsed.name ?? "";
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
const kept = $derived.by(() => {
if (!working) return [];
return includeUnresolved
? working.entries
: working.entries.filter((e) => e.resolution === "local");
});
async function commit() {
if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim();
committing = true;
error = "";
try {
const id = await api.playlistImportCommit(
finalName,
working.description ?? null,
working.kind,
kept,
);
onimported(id, finalName);
} catch (e) {
error = String(e);
committing = false;
}
}
function close() {
open = false;
}
function fmtTime(ms) {
if (ms == null) return "";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
: `${m}:${String(s).padStart(2, "0")}`;
}
function fmtDur(secs) {
if (!secs) return "";
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function onkeydown(e) {
if (e.key === "Escape") close();
}
const FORMAT_LABEL = {
m3u: "M3U",
pls: "PLS",
xspf: "XSPF",
csv: "CSV",
soulstream: "SoulStream backup",
text: "Pasted text",
folder: "Folder",
};
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={close} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-label="Import playlist"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<header>
<h2>
{#if working}
Review import
{:else}
Paste a tracklist
{/if}
</h2>
<button class="x" title="Close" onclick={close}>
<Icon name="x" size={16} />
</button>
</header>
{#if !working}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
spellcheck="false"
></textarea>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
</button>
</footer>
{:else}
<div class="srcline">
<span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span>
{#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if}
<span class="srcname" title={working.source_name}>{working.source_name}</span>
<span class="counts">
<b class="ok">{working.resolved_count}</b> matched
{#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved
{/if}
</span>
</div>
{#if working.warnings?.length}
<div class="warnings">
{#each working.warnings as w, wi (wi)}
<p><Icon name="alert" size={12} /> {w}</p>
{/each}
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
class="row"
class:unresolved={e.resolution !== "local"}
class:excluded={!includeUnresolved && e.resolution !== "local"}
>
<span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"}
<span class="t">{fmtTime(e.start_ms)}</span>
{/if}
<span class="who" title={e.raw}>
<span class="ti">{e.title}</span>
{#if e.artist}<span class="ar">{e.artist}</span>{/if}
</span>
<span class="ev">
{#if e.resolution === "local"}
<span
class="tag ok"
title={e.matched_by === "path"
? "Matched by exact file path"
: e.matched_by === "relinked"
? "File found under the current library folder"
: "Matched by artist and title tags"}
>
<Icon name="check" size={11} />
{e.matched_by === "path" || e.matched_by === "file"
? "file"
: e.matched_by === "relinked"
? "relinked"
: "tags"}
</span>
{:else}
<span class="tag un" title="No local match — kept as a placeholder">
unresolved
</span>
{/if}
</span>
<span class="du">{fmtDur(e.duration_secs)}</span>
</div>
{/each}
</div>
<div class="form">
<input
class="name"
bind:value={name}
placeholder="Playlist name"
spellcheck="false"
/>
{#if working.unresolved_count > 0}
<label class="keep">
<input type="checkbox" bind:checked={includeUnresolved} />
Keep unresolved entries as placeholders
</label>
{/if}
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
{/if}
<button
class="btn primary"
onclick={commit}
disabled={committing || kept.length === 0}
>
{committing
? "Creating…"
: `Create playlist (${kept.length} ${kept.length === 1 ? "track" : "tracks"})`}
</button>
</footer>
{/if}
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
backdrop-filter: blur(3px);
}
.dialog {
width: min(640px, calc(100vw - 64px));
max-height: min(78vh, 760px);
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h2 {
font-size: 18px;
font-weight: 700;
}
.x {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.x:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.lead {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 10px;
}
.mono {
font-family: Consolas, monospace;
font-size: 12px;
color: var(--text-primary);
background: var(--bg-highlight);
border-radius: 4px;
padding: 1px 5px;
}
textarea {
width: 100%;
min-height: 220px;
resize: vertical;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
.srcline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
min-width: 0;
}
.chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--bg-highlight);
color: var(--text-secondary);
padding: 3px 8px;
border-radius: 8px;
flex: none;
}
.chip.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.srcname {
font-size: 12.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.counts {
margin-left: auto;
flex: none;
font-size: 12px;
color: var(--text-muted);
}
.counts .ok {
color: #3ecf7e;
}
.counts .un {
color: #e8a33d;
}
.warnings {
margin-bottom: 8px;
}
.warnings p {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #e8a33d;
}
.rows {
flex: 1;
min-height: 120px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-base);
padding: 4px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12.5px;
}
.row:hover {
background: var(--bg-highlight);
}
.row.excluded {
opacity: 0.35;
text-decoration: line-through;
}
.n {
width: 26px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.t {
width: 52px;
flex: none;
color: var(--accent-hover);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ti {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ar {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev {
flex: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
padding: 2.5px 7px;
border-radius: 8px;
}
.tag.ok {
background: rgba(62, 207, 126, 0.13);
color: #3ecf7e;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.du {
width: 40px;
flex: none;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 11.5px;
}
.form {
display: flex;
align-items: center;
gap: 14px;
margin-top: 12px;
}
.name {
flex: 1;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
font-size: 13.5px;
font-weight: 600;
outline: none;
}
.name:focus {
border-color: var(--accent);
}
.keep {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
flex: none;
}
.keep input {
accent-color: var(--accent);
}
.error {
margin-top: 8px;
font-size: 12.5px;
color: #f15e6c;
}
footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
transition: background 0.14s;
}
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn.primary:disabled {
opacity: 0.5;
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script>
// "Add to playlist" dropdown — one component for every surface (album
// rail, track rows, Now Playing). Callers hand a THUNK that produces the
// rows when a target is picked, so building the payload costs nothing
// until the user actually commits.
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let {
// () => [{ title, artist, album, durationSecs, path, artPath }]
tracks,
align = "left", // "left" | "right"
iconSize = 13,
title = "Add to playlist",
compact = false, // icon-only trigger
} = $props();
let open = $state(false);
let busy = $state(false);
let doneFor = $state(null); // playlist id that just accepted rows
$effect(() => {
if (open && !playlistStore.loaded) refreshPlaylists();
});
function toggle(e) {
e.stopPropagation();
open = !open;
doneFor = null;
}
function payload() {
return (tracks() ?? []).map((t) => ({
title: t.title ?? "",
artist: t.artist ?? null,
album: t.album ?? null,
duration_secs: t.durationSecs ?? t.duration_secs ?? null,
path: t.path ?? null,
art_path: t.artPath ?? t.art_path ?? null,
}));
}
async function addTo(pl) {
if (busy) return;
const rows = payload().filter((t) => t.title);
if (!rows.length) return;
busy = true;
try {
await api.playlistAddItems(pl.id, rows);
doneFor = pl.id;
await refreshPlaylists();
setTimeout(() => (open = false), 650);
} catch (e) {
console.error("add to playlist failed", e);
} finally {
busy = false;
}
}
async function addToNew() {
if (busy) return;
const rows = payload().filter((t) => t.title);
if (!rows.length) return;
busy = true;
try {
// Name the new playlist after what is being added when it reads
// like an album; otherwise fall back to the numbered default.
const first = rows[0];
const name =
rows.length > 1 && first.album
? first.album
: rows.length === 1
? first.title
: `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await api.playlistAddItems(id, rows);
doneFor = id;
await refreshPlaylists();
setTimeout(() => (open = false), 650);
} catch (e) {
console.error("add to new playlist failed", e);
} finally {
busy = false;
}
}
function windowClick() {
open = false;
}
</script>
<svelte:window onclick={open ? windowClick : undefined} />
<div class="plm">
<button
class="plm-trigger"
class:compact
{title}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
>
<Icon name="list-plus" size={iconSize} />
{#if !compact}<span>Add to playlist</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="plm-menu" class:right={align === "right"} role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="plm-item new" onclick={addToNew} disabled={busy}>
<Icon name="plus" size={14} /> New playlist
</button>
{#if playlistStore.playlists.length}
<div class="plm-rule"></div>
<div class="plm-list">
{#each playlistStore.playlists as pl (pl.id)}
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
{#if doneFor === pl.id}
<span class="ok"><Icon name="check" size={14} /></span>
{:else}
<Icon name="playlist" size={14} />
{/if}
<span class="nm">{pl.name}</span>
<span class="ct">{pl.track_count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.plm {
position: relative;
display: inline-flex;
}
.plm-trigger {
display: inline-flex;
align-items: center;
gap: 7px;
color: inherit;
}
.plm-trigger.compact {
display: grid;
place-items: center;
}
.plm-menu {
position: absolute;
top: calc(100% + 7px);
left: 0;
min-width: 210px;
max-width: 270px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 80;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.plm-menu.right {
left: auto;
right: 0;
}
.plm-list {
max-height: 260px;
overflow-y: auto;
}
.plm-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
}
.plm-item:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.plm-item:disabled {
opacity: 0.6;
}
.plm-item .nm {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plm-item .ct {
flex: none;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.plm-item .ok {
color: #3ecf7e;
display: grid;
place-items: center;
}
.plm-rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
</style>
+40
View File
@@ -13,6 +13,7 @@
goToView,
openRecentAlbum,
getArt,
playlistCoverFor,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -145,6 +146,15 @@
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
// Pinned playlists ride between the nav and the history — quick access
// to the lists the user chose to keep close, with their covers.
const pinnedPlaylists = $derived(
playlistStore.playlists.filter((p) => p.pinned).slice(0, 6),
);
$effect(() => {
for (const pl of pinnedPlaylists) void playlistCoverFor(pl);
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
@@ -226,6 +236,28 @@
</button>
{/each}
{#each pinnedPlaylists as pl (pl.id)}
<button
class="rec"
class:on={ui.view === "playlist" && ui.playlistId === pl.id}
title="{pl.name} — pinned playlist"
onclick={() => goToView("playlist", { playlistId: pl.id, label: pl.name })}
>
{#if playlistStore.covers[pl.id]}
<img src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="playlist" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
<span class="pin-mini" aria-hidden="true"><Icon name="pin" size={10} /></span>
{pl.name}
</span>
<span class="s">{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}</span>
</span>
</button>
{/each}
<div class="rule"></div>
{#each recentRows as row (row.key)}
@@ -698,6 +730,14 @@
opacity: 0;
}
/* Pinned playlist rows share the recents' whole construction; the only
addition is the tiny pin riding the name, which fades with the text. */
.pin-mini {
display: inline-flex;
color: var(--text-muted);
flex: none;
}
.eq {
display: inline-flex;
align-items: flex-end;
+111 -8
View File
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
// Rows are OCCURRENCES with their own ids: the same recording may appear
// twice, and every mutation addresses item ids, never positions.
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
export const setPlaylistDescription = (id, description) =>
invoke("set_playlist_description", { id, description: description ?? null });
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const playlistItems = (id) => invoke("playlist_items", { id });
export const playlistAddItems = (playlistId, items, at = null) =>
invoke("playlist_add_items", { playlistId, items, at });
export const playlistRemoveItems = (playlistId, itemIds) =>
invoke("playlist_remove_items", { playlistId, itemIds });
export const playlistSetOrder = (playlistId, orderedIds) =>
invoke("playlist_set_order", { playlistId, orderedIds });
// Repair one row: fields null = leave as stored; path non-null relinks the
// row to a playable file.
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
invoke("playlist_relink_item", {
playlistId,
itemId,
title: fields.title ?? null,
artist: fields.artist ?? null,
album: fields.album ?? null,
durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null,
artPath: fields.artPath ?? null,
});
// Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) =>
invoke("playlist_cover", { id, maxEdge });
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
// Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder");
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportCommit = (name, description, kind, entries) =>
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
// the written path, or null when the dialog was cancelled.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
export const lyricsGet = (args) =>
invoke("lyrics_get", {
title: args.title,
artist: args.artist,
album: args.album ?? null,
durationSecs: args.durationSecs ?? null,
path: args.path ?? null,
refresh: args.refresh ?? null,
});
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
export const lyricsSaveText = (title, artist, durationSecs, text) =>
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
export const lyricsClear = (title, artist, durationSecs) =>
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
// not a learned separator). Resolves to { path, cached } of a playable WAV.
export const lyricsPrepareInstrumental = (path) =>
invoke("lyrics_prepare_instrumental", { path });
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
// Persist hand-synced lines as the track's own document (wins the waterfall).
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
@@ -232,6 +303,11 @@ export const debugStats = () => invoke("debug_stats");
// source folder is never written to.
export const importScan = (root) => invoke("import_scan", { root });
export const importSessionGet = () => invoke("import_session_get");
// The source folder relative to the library folder, and back again. Saving
// both is what lets a review started on one machine resume on another after
// the drive lands at a different letter.
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
export const importSessionSet = (state) => invoke("import_session_set", { state });
export const importSessionClear = () => invoke("import_session_clear");
export const importGetDest = () => invoke("import_get_dest");
@@ -239,11 +315,29 @@ export const importSetDest = (dir, layout = null) => invoke("import_set_dest", {
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
// On-demand per-record identification (Deezer try-list → MB fallback) and
// the artwork panel's cover report. Both read-only.
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
// `title` is the record's own claim (tags); `folder` is the second witness,
// asked after it — a record whose tags are wrong still has its folder name.
// `shape` is everything ELSE the scan knows — track count, track titles, year,
// and where the files are so Rust can hash the record's own cover. That is what
// the discography pass compares when no phrase can find the record; the cover
// itself never crosses IPC (a master can be 8 MB).
export const importIdentify = (artist, title, folder = null, shape = {}) =>
invoke("import_identify", {
artist, title, folder,
tracks: shape.tracks ?? null,
trackTitles: shape.trackTitles ?? null,
year: shape.year ?? null,
sourceRoot: shape.sourceRoot ?? null,
rel: shape.rel ?? null,
// Bounds the cover hash to the record's own audio — a folded release
// gathers from these, an ordinary one from its folder alone.
parts: shape.parts ?? null,
});
export const importCoverReport = (artist, title, referenceUrl) =>
invoke("import_cover_report", { artist, title, referenceUrl });
// The record's own artwork, measured (embedded first, else folder images).
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
export const importLocalArt = (sourceRoot, rel, parts) =>
invoke("import_local_art", { sourceRoot, rel, parts });
// Prune rows whose files are gone, then index anything new — the real rescan.
export const rescanLibrary = () => invoke("rescan_library");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
@@ -252,6 +346,15 @@ export const rescanLibrary = () => invoke("rescan_library");
export const importAudition = (username, rel, files, startIndex = 0) =>
invoke("import_audition", { username, rel, files, startIndex });
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
// Which of a found copy's files the pen does not (fully) hold. Transfers die
// with the app and the arrived-count dies with them; the files on disk are
// what is left to ask.
export const importAuditionMissing = (rel, files) =>
invoke("import_audition_missing", { rel, files });
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
// The pen is keyed by RECORD, so without this the dropped peer's files sit
// beside the replacement's until the session is cleared.
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
// Measured facts about one audio file (the version card's quality chips).
export const importProbeFile = (path) => invoke("import_probe_file", { path });
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
// How the import finder asks when it is not ALLOWED to ask.
//
// The Soulseek server drops a search outright when the query carries every
// word of one of its excluded phrases. Measured 2026-08-11 against the live
// server, with the record's own copies provably on the network:
//
// animal collective merriweather 578 peers · 2,664 FLAC files
// merriweather pavilion 683 peers
// post pavilion 712 peers
// merriweather post pavilion 0 peers <- every time
// pavilion merriweather post 0 peers <- order is irrelevant
// merriweather post pavilion flac 0 peers <- extra terms don't help
//
// The published exclusions behave identically ("village people" 0, "bryan
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
// this is that same mechanism — but the server publishes only SOME of its
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
// unpublished one like this album's title reaches the wire and is swallowed.
//
// Nothing here can make the server answer a refused query. What it CAN do is
// ask a shorter question the server does answer, and then apply the part we
// were not allowed to say to the answers, where no server is involved.
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
* token nothing will match. */
export function queryWords(s) {
return String(s ?? "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.split(/\s+/)
.filter(Boolean);
}
/** The record's name with its last word dropped — the shortest change that
* breaks the refused word-set while still naming the record.
*
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
* is "the dark side of the", which asks the network for three words that
* carry no meaning and narrow the result set for nothing.
*
* Returns "" when there is nothing left worth asking. A one-word name
* shortens to nothing at all, and a bare artist query asks the network for
* that artist's entire discography — the same thing `findQueries` refuses
* to do. */
export function shortenName(name) {
const w = queryWords(name);
if (w.length < 2) return "";
w.pop();
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
return w.join(" ");
}
/** Does this peer's path claim to BE the record we could not ask for? Every
* word of the full name has to appear in it.
*
* This is the half of the question the server was not allowed to answer, and
* it is what keeps the shortened query honest: "animal collective merriweather
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
* the venue is in the path of every bootleg taped there.
*
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
* a folder that misspells the record is refused rather than guessed at. That
* costs a few real copies whose owners cannot spell; accepting near-misses
* costs correctness, and there is no way to tell the two apart from here. */
export function pathHasTitle(path, name) {
const need = queryWords(name);
if (!need.length) return false;
const have = new Set(queryWords(path));
return need.every((w) => have.has(w));
}
+30 -2
View File
@@ -27,8 +27,36 @@
*
* 1. Deezer try-list, MusicBrainz fallback.
* 2. + the artist's own catalogue, ranked by title similarity — finds records
* whose folder name no release carries ("1989 Pauls Boutique"). */
export const IDENTIFY_ENGINE = 2;
* whose folder name no release carries ("1989 Pauls Boutique").
* 3. + the artist the TAGS name, asked when the folder's artist found nothing
* or the wrong record, and + a try-list that refuses to strip a title down
* to its catalogue number ("Band Of Gypsys (SICP 2643)" → "(SICP 2643)").
* 4. + the title without a trailing release-type word ("Willoughby's Beach EP"
* → "Willoughby's Beach"). Both providers require every word of the
* phrase, so the one appended word hid a record each of them holds.
* 5. + the FOLDER as a second witness, asked after the record's own claim.
* Tags carry typos the folder does not ("Nonagon Infiniry" on all nine
* files, in a correctly-spelled folder), and until now one wrong tag was
* the only thing any provider was ever asked about.
* 6. + the record's TRACK COUNT breaks the tie when one title names several
* MusicBrainz release groups ("Murder of the Universe" is a 21-track Album
* and a 1-track Single, both scored 100, in an unstable order), and a
* provider request that FAILED no longer caches as "nothing found".
* 7. The last resort stopped being a title search dressed as a comparison. It
* ranks the artist's WHOLE MusicBrainz discography — complete, where
* Deezer's listing omitted EPs entirely — and decides on every fact the
* scan has: wording, track count, year, the TRACKLIST, and the record's own
* ARTWORK hashed against the discography's covers. A name is one fact about
* a record and the one most often wrong; `Rock Bottom 7"` scores 0.667
* against the release actually called "Rock Bottom / Octopus", and 2 of 2
* on its songs.
*
* BUMP THIS AFTER THE RUST BUILD LANDS, never before. Vite hot-reloads this
* constant instantly while the capability it describes lives in
* `metadata::identify_titles`, so bumping it against a running old binary
* makes that binary sweep the negatives and re-stamp the record on screen as
* freshly looked at by code that learned nothing. */
export const IDENTIFY_ENGINE = 7;
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
* value seen, which keeps the result stable for a re-render. */
+108
View File
@@ -0,0 +1,108 @@
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
// editor. Never presented as finished timing.
//
// The method deliberately stays above the research doc's forbidden floor
// ("never estimate line positions by evenly distributing text across song
// duration"): lines are laid out only across MEASURED vocal-active segments,
// weighted by line length, and when the measurement shows nothing usable the
// generator refuses instead of guessing. Real generated timing (ASR anchors
// + forced alignment, per the research) is a later, model-gated feature.
//
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
/** Hysteresis segmentation of a 0..1 activity envelope.
* Returns [{ startMs, endMs }], merged and pruned. */
export function activeSegments(frames, frameMs, opts = {}) {
const on = opts.on ?? 0.32; // enter singing
const off = opts.off ?? 0.16; // leave singing…
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
const minSegMs = opts.minSegMs ?? 1200;
const mergeGapMs = opts.mergeGapMs ?? 700;
const segments = [];
let start = null;
let belowRun = 0;
for (let i = 0; i < frames.length; i++) {
const v = frames[i];
if (start == null) {
if (v >= on) {
start = i;
belowRun = 0;
}
} else if (v < off) {
belowRun += frameMs;
if (belowRun >= offRunMs) {
const end = i * frameMs - belowRun + frameMs;
segments.push({ startMs: start * frameMs, endMs: end });
start = null;
belowRun = 0;
}
} else {
belowRun = 0;
}
}
if (start != null) {
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
}
// Merge close neighbours, then drop slivers.
const merged = [];
for (const seg of segments) {
const last = merged[merged.length - 1];
if (last && seg.startMs - last.endMs <= mergeGapMs) {
last.endMs = seg.endMs;
} else {
merged.push({ ...seg });
}
}
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
}
/** Plain text → the lines worth timing (section labels dropped). */
export function lyricLinesOf(plainText) {
return String(plainText ?? "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0)
.filter((l) => !/^\[.*\]$/.test(l));
}
/** Lay `lines` across `segments` by character weight over the concatenated
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
* inside an active segment), or null when there is nothing to lay onto. */
export function distributeLines(lines, segments) {
if (!lines.length || !segments.length) return null;
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
if (activeTotal < 4000) return null;
const weight = (line) => Math.max(line.length, 8);
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
// virtual position (0..activeTotal) → real ms via the segment list.
const toReal = (virtual) => {
let left = virtual;
for (const seg of segments) {
const len = seg.endMs - seg.startMs;
if (left <= len) return seg.startMs + left;
left -= len;
}
return segments[segments.length - 1].endMs;
};
const out = [];
let cumulative = 0;
for (const line of lines) {
const virtual = (cumulative / totalWeight) * activeTotal;
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
cumulative += weight(line);
}
// Monotonic by construction, but rounding can tie neighbours; nudge.
for (let i = 1; i < out.length; i++) {
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
}
return out;
}
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
export function roughSync(plainText, frames, frameMs) {
const lines = lyricLinesOf(plainText);
const segments = activeSegments(frames ?? [], frameMs ?? 100);
return distributeLines(lines, segments);
}
Binary file not shown.
+69 -3
View File
@@ -227,6 +227,8 @@ export const importStatus = $state({
label: "", // applied: the "· 12 GB" tail; otherwise unused
skipped: 0, // applied: records nothing was written for — the toast must
// say so, because "Imported 0 records" on its own reads as a dead end
error: "", // error: what went wrong, in words. "The import hit a problem"
// is not a reason, and a silent failure is not even that.
});
/** A paused import's presence on the Library page: the Import button wears
@@ -267,6 +269,8 @@ export const ui = $state({
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
windowActive: true,
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
lyricsFull: false,
});
export function toggleNowPlaying() {
@@ -311,16 +315,49 @@ export const libraryStore = $state({
export const playlistStore = $state({
playlists: [],
loaded: false,
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
// mutated playlist (new rows → new mosaic) refetches while an untouched
// one costs nothing on re-render.
covers: {},
});
// revision the cached cover was composed against, and in-flight dedupe.
const playlistCoverRev = new Map();
const playlistCoverInflight = new Map();
export async function refreshPlaylists() {
try {
playlistStore.playlists = await api.listPlaylists();
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
} catch {
// backend not available
} finally {
playlistStore.loaded = true;
}
}
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
/// gradient fallback".
export async function playlistCoverFor(pl, maxEdge = 512) {
const id = pl.id;
const cached = playlistStore.covers[id];
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
const req = api
.playlistCover(id, maxEdge)
.then((url) => {
playlistStore.covers[id] = url ?? null;
playlistCoverRev.set(id, pl.revision);
return url ?? null;
})
.catch(() => null)
.finally(() => playlistCoverInflight.delete(id));
playlistCoverInflight.set(id, req);
return req;
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
@@ -3691,9 +3728,18 @@ export const RESOLVER_AUDIO_QUALITY = {
// Formats that decode progressively while the file is still downloading.
// MP4-family files (m4a/aac) put their index at the end and only play once
// complete, so they're save-only.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
//
// `opus` was here and must not be: this build has no Opus decoder, so the
// resolver would pick an Opus source to stream, download it, and fail at the
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
// see crates/audio-formats.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
// Lossless for LABELLING — what the file claims to be, which is a different
// question from whether we can decode it. `.ape` and `.wv` are genuinely
// lossless and genuinely unplayable here; a peer's file is still worth naming
// accurately in search results.
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
// User source-selection preferences, independent per mode. Loaded from
// settings at startup and refreshed by the Settings view after a commit;
@@ -5031,10 +5077,24 @@ export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? [];
}
// Mirrors crates/audio-formats: everything recognised as audio, playable or
// not. Kept in sync by `audio_format_support`, which the backend answers from
// the same table the scanner and share index use.
export const AUDIO_EXTS = new Set([
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
// Recognised but with no decoder in this build. Shown, searchable, shareable
// and downloadable — never handed to the player, which refuses them anyway.
export const UNPLAYABLE_EXTS = new Set([
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
export const isPlayableExt = (ext) =>
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
export const downloads = $state({
// id -> download payload (latest update wins)
items: {},
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
title: t.title,
sub: subLabel,
artist: t.artist ?? null,
album: t.album ?? null,
duration: t.duration_secs ?? null,
art: t.art_path ?? artPath,
quality: localQuality(t),
// Which playlist OCCURRENCE this queue item came from, so the playlist
// page highlights exactly the row that is playing — a recording that
// appears twice must light up once. Null for album playback.
plItemId: t.plItemId ?? null,
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView,
};
}
+162 -29
View File
@@ -22,6 +22,7 @@
import { tick } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import {
libraryStore,
@@ -94,14 +95,32 @@
// the same join the artist page's own "in your library" ticks already
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
let followsByName = $state({}); // normKey(name) -> { artistId, name }
// artist name -> { total } — persisted so counts survive a restart and the
// shelf head can say "9 of 29" without a fetch. Rows are session-only.
/** "Same artist" for filing purposes, and it is not a new rule: the library
* already groups artist portraits with `GROUP BY artist COLLATE NOCASE`
* (crates/library/src/lib.rs:321), so the shelves fold exactly as wide as
* the backend does. Measured on this library, case alone splits 5 artists
* (King Gizzard "& The"/"& the", Lorn/LORN, Mac DeMarco/Demarco, Boards
* Of/of Canada, My Bloody Valentine) and the stricter punctuation-stripping
* normKey merges nothing beyond those — so the wider key would buy no shelf
* and only risk fusing two artists whose names differ by a mark. */
const foldKey = (s) => (s ?? "").toLowerCase();
// foldKey(artist) -> { total } — persisted so counts survive a restart and
// the shelf head can say "9 of 29" without a fetch. Rows are session-only.
const GAPS_KEY = "library.gaps.v1";
let gapTotals = $state((() => {
try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; }
try {
const saved = JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {};
// Written under the raw shelf name before shelves folded by case,
// so a total banked as "King Gizzard & the Lizard Wizard" is still
// found now the shelf is titled "& The". Folding on load rather
// than at every read keeps one shape in memory.
return Object.fromEntries(Object.entries(saved).map(([k, v]) => [foldKey(k), v]));
} catch { return {}; }
})());
let gapRows = $state({}); // artist name -> catalog rows not owned
let gapOpen = $state({}); // artist name -> line expanded
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -138,14 +157,18 @@
}
async function toggleGaps(artistName) {
if (gapOpen[artistName]) {
gapOpen = { ...gapOpen, [artistName]: false };
// Keyed by the FOLD, not by the shelf's title: the total was the one
// thing still filed under whichever spelling happened to be clicked,
// which is why only one of two King Gizzard shelves read "8 of 42".
const key = foldKey(artistName);
if (gapOpen[key]) {
gapOpen = { ...gapOpen, [key]: false };
return;
}
const follow = followFor(artistName);
if (!follow) return;
if (!gapRows[artistName] && !gapLoading[artistName]) {
gapLoading = { ...gapLoading, [artistName]: true };
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await getCatalogArtistPage(follow.artistId);
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
@@ -156,15 +179,15 @@
);
gapRows = {
...gapRows,
[artistName]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapTotals = { ...gapTotals, [artistName]: { total: released.length } };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
gapLoading = { ...gapLoading, [artistName]: false };
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[artistName]?.length || gapLoading[artistName] === false) {
gapOpen = { ...gapOpen, [artistName]: true };
if (gapRows[key]?.length || gapLoading[key] === false) {
gapOpen = { ...gapOpen, [key]: true };
}
}
@@ -309,7 +332,10 @@
// ---- groupings --------------------------------------------------------
const GROUPS = {
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist" },
// `of` names the shelf, `keyOf` decides which shelf a record lands on.
// They differ for Artist alone: two spellings of one name are one
// artist, so the key folds and shelfName() picks the title.
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist", keyOf: (a) => foldKey(a.artist || "Unknown Artist") },
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
format: {
label: "Format",
@@ -360,12 +386,37 @@
};
function completeness(artist) {
const total = gapTotals[artist]?.total || 0;
const total = gapTotals[foldKey(artist)]?.total || 0;
if (!total) return 1;
const own = rows.filter((a) => a.artist === artist).length;
const own = rows.filter((a) => foldKey(a.artist) === foldKey(artist)).length;
return Math.min(1, own / total);
}
/** The one spelling a folded shelf is filed under.
*
* An anchored follow's own name wins, because that is the catalogue's
* spelling and the same anchor the rest of the page already trusts. With
* no anchor there is nothing authoritative to appeal to, so the records
* vote: the spelling on the most albums, ties broken by sort order so a
* shelf never renames itself between loads.
*
* A shelf with ONE spelling keeps it untouched — including when a follow
* disagrees. Renaming a shelf that was never split is a change nobody
* asked for, and this only exists to reunite the ones that were. */
function shelfName(list) {
const counts = new Map();
for (const a of list) {
const n = a.artist || "Unknown Artist";
counts.set(n, (counts.get(n) || 0) + 1);
}
const spellings = [...counts.keys()];
if (spellings.length === 1) return spellings[0];
const follow = followFor(spellings[0]);
if (follow?.name) return follow.name;
return [...counts.entries()]
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
}
function sortList(list) {
const out = [...list].sort(SORTS[sortKey].cmp);
return desc ? out : out.reverse();
@@ -376,13 +427,25 @@
if (group === "none") return [["", sortList(corpus)]];
const by = new Map();
for (const a of corpus) {
const k = g.of(a);
const k = (g.keyOf ?? g.of)(a);
if (!by.has(k)) by.set(k, []);
by.get(k).push(a);
}
// An artist shelf is keyed by the FOLD, which is not a name, so it takes
// the title shelfName() picks. Every other grouping's key already IS its
// heading. Two folds can still title alike — followFor() joins on the
// coarser normKey, so "AC/DC" and "ACDC" would both take one follow's
// name — and that is the same artist by the app's own rule, so the
// shelves join rather than one silently replacing the other.
const named = new Map();
for (const [k, list] of by) {
const title = group === "artist" ? shelfName(list) : k;
const prev = named.get(title);
if (prev) prev.push(...list); else named.set(title, list);
}
// Added is a DATE grouping: newest month first, by the month the key
// names — a string sort put April 2025 above August 2024.
const keys = [...by.keys()].sort(group === "rtype"
const keys = [...named.keys()].sort(group === "rtype"
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
: group === "artist"
@@ -394,7 +457,7 @@
if (group === "artist" && sortKey === "complete") {
keys.sort((x, y) => completeness(x) - completeness(y));
}
return keys.map((k) => [k, sortList(by.get(k))]);
return keys.map((k) => [k, sortList(named.get(k))]);
});
// Albums, EPs and singles under their own headings INSIDE an artist — the
@@ -414,7 +477,7 @@
// ---- the derived stat row --------------------------------------------
const totals = $derived.by(() => ({
albums: corpus.length,
artists: new Set(corpus.map((a) => a.artist)).size,
artists: new Set(corpus.map((a) => foldKey(a.artist || "Unknown Artist"))).size,
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
secs: corpus.reduce((n, a) => n + a.secs, 0),
bytes: corpus.reduce((n, a) => n + a.bytes, 0),
@@ -1151,6 +1214,25 @@
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
} catch { /* backend unavailable */ }
}
// Disc-aware view of the open tracklist — the artist page's own treatment
// (CatalogArtist's `openDiscs`), so a multi-disc record reads the same
// wherever you meet it: quiet disc rules, and each disc numbering from 1.
//
// `i` is the track's index in `openTracks`, kept alongside because that is
// what playback counts from: grouping the rows must not renumber the queue.
const openDiscs = $derived.by(() => {
const by = new Map();
openTracks.forEach((t, i) => {
const disc = t.disc_no ?? 1;
if (!by.has(disc)) by.set(disc, []);
by.get(disc).push({ t, i });
});
return [...by.entries()]
.sort((a, b) => a[0] - b[0])
.map(([disc, rows]) => ({ disc, rows }));
});
const multiDisc = $derived(openDiscs.length > 1);
const isPlayingTrack = (t) =>
player.track?.source === "local" && player.track?.path === t.path;
function folderOf(tracks) {
@@ -1571,6 +1653,22 @@
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
<Icon name="play" size={12} /> Play
</button>
{#if openTracks.length}
<PlaylistMenu
compact
iconSize={14}
title="Add album to a playlist"
tracks={() =>
openTracks.map((t) => ({
title: t.title,
artist: t.artist,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs,
path: t.path,
art_path: openAlbum?.art_path ?? null,
}))}
/>
{/if}
<button
class="pill ghost"
title="Show in folder"
@@ -1622,7 +1720,11 @@
<span>Title</span>
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{#each openTracks as t, ti (t.id)}
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.rows.length} tracks</div>
{/if}
{#each d.rows as { t, i: ti } (t.id)}
<div
class="trow"
class:now={isPlayingTrack(t)}
@@ -1653,6 +1755,7 @@
><Icon name="folder-open" size={13} /></button>
</div>
{/each}
{/each}
{:else}
<div class="tracks-loading" role="status">
<div class="tl-head">No tracks found for this record</div>
@@ -1680,12 +1783,13 @@
{#snippet gapSection(name, list)}
{@const follow = followFor(name)}
{#if follow}
{@const missing = gapRows[name]}
{@const total = gapTotals[name]?.total}
{@const key = foldKey(name)}
{@const missing = gapRows[key]}
{@const total = gapTotals[key]?.total}
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
{#if count !== 0}
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
{#if gapLoading[name]}
{#if gapLoading[key]}
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
{:else if count != null}
<b>{count}</b> more released
@@ -1693,12 +1797,12 @@
See what's missing
{/if}
<span class="lib-gapline-cta">
{gapOpen[name] ? "hide" : "show"}
<Icon name={gapOpen[name] ? "chevron-down" : "chevron-right"} size={12} />
{gapOpen[key] ? "hide" : "show"}
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
</span>
</button>
{/if}
{#if gapOpen[name] && missing?.length}
{#if gapOpen[key] && missing?.length}
<!-- Full-size cards, dimmed — the same card as everything else on
the page, readable, full strength on hover (handoff §4d). -->
<div class="lib-gaps" role="list">
@@ -1772,7 +1876,7 @@
{#snippet shelfHead(name, list)}
{@const follow = followFor(name)}
{@const total = group === "artist" ? gapTotals[name]?.total : null}
{@const total = group === "artist" ? gapTotals[foldKey(name)]?.total : null}
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
click resolves one first (openShelfArtist), so an unfollowed shelf is
@@ -3236,6 +3340,15 @@
.pill:hover { background: var(--accent-hover); }
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
.pill.ghost:hover { background: var(--bg-press, #333); }
/* The add-to-playlist trigger wears the ghost-pill look so the CTA row
reads as one family; the component itself stays unstyled chrome. */
.rail-cta :global(.plm-trigger) {
background: var(--bg-highlight);
color: var(--text-primary);
padding: 6px 11px;
border-radius: 14px;
}
.rail-cta :global(.plm-trigger:hover) { background: var(--bg-press, #333); }
.pill.grow { flex: 1; }
.pill:disabled {
background: var(--bg-highlight);
@@ -3261,6 +3374,26 @@
color: var(--text-muted);
}
.tdur-head { display: flex; justify-content: flex-end; }
/* CatalogArtist's `.disc-head`, verbatim — a multi-disc record has to read
the same here as it does on the artist page. */
.disc-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 10px 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.disc-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
.trow {
position: relative;
display: grid;
+1254 -94
View File
File diff suppressed because it is too large. Load diff
+584 -55
View File
@@ -1,132 +1,661 @@
<script>
// The sidebar's Playlists row needs a destination. Playlists used to be
// listed in the sidebar itself; with the sidebar showing listening history
// instead, this is where they live.
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
// The playlist shelf: every playlist as a cover card — custom art or the
// composed four-cover mosaic — with pinning, duplication, export, and the
// two import doors (a playlist file, a pasted tracklist).
import {
playlistStore,
refreshPlaylists,
playlistCoverFor,
goToView,
playAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
let importOpen = $state(false);
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
let probing = $state(false);
$effect(() => {
if (!playlistStore.loaded) refreshPlaylists();
});
// Covers resolve lazily through the store cache; re-render never refetches.
$effect(() => {
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
});
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
async function newPlaylist() {
if (creating) return;
creating = true;
try {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
} finally {
creating = false;
}
}
async function importFromFile() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportProbe();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("playlist import probe failed", e);
} finally {
probing = false;
}
}
async function importFromFolder() {
newMenuOpen = false;
if (probing) return;
probing = true;
try {
const draft = await api.playlistImportFolder();
if (draft) {
importDraft = draft;
importMode = "file";
importOpen = true;
}
} catch (e) {
console.error("folder import failed", e);
} finally {
probing = false;
}
}
function importFromText() {
newMenuOpen = false;
importDraft = null;
importMode = "paste";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
// Hover-play, straight from the card — the Library's own affordance.
// Generation-guarded: a slower fetch from an EARLIER click must not
// override the playlist clicked after it.
let playCardGen = 0;
async function playCard(pl) {
const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return;
const playable = rows.filter((r) => r.path && !r.missing);
if (!playable.length) {
open(pl); // nothing playable: the page explains why
return;
}
playAlbum(
playable.map((r) => ({
path: r.path,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs: r.duration_secs,
art_path: r.art_path,
plItemId: r.id,
})),
pl.name,
0,
);
}
function toggleMenu(e, pl) {
e.stopPropagation();
confirmDelete = null;
menuFor = menuFor === pl.id ? null : pl.id;
}
async function togglePin(pl) {
menuFor = null;
await api.setPlaylistPinned(pl.id, !pl.pinned);
await refreshPlaylists();
}
async function duplicate(pl) {
menuFor = null;
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
}
async function exportAs(pl, format) {
menuFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
console.error("playlist export failed", e);
}
}
async function remove(pl) {
if (confirmDelete !== pl.id) {
confirmDelete = pl.id;
return;
}
menuFor = null;
confirmDelete = null;
await api.deletePlaylist(pl.id);
await refreshPlaylists();
}
function onImported(id, name) {
importOpen = false;
importDraft = null;
refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function fmtDuration(totalSecs) {
if (!totalSecs) return "";
const h = Math.floor(totalSecs / 3600);
const m = Math.round((totalSecs % 3600) / 60);
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
}
// Close any open menu on a page click.
function pageClick() {
menuFor = null;
newMenuOpen = false;
confirmDelete = null;
}
</script>
<svelte:window onclick={pageClick} />
<div class="page">
<header>
<div class="head-text">
<h1>Playlists</h1>
<button class="new" onclick={newPlaylist}>
{#if playlistStore.playlists.length}
<p class="sub">
{playlistStore.playlists.length}
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
</p>
{/if}
</div>
<div class="head-actions">
<div class="menu-wrap">
<button
class="btn ghost"
onclick={(e) => {
e.stopPropagation();
menuFor = null;
newMenuOpen = !newMenuOpen;
}}
>
<Icon name="download" size={15} /> Import
<Icon name="chevron-down" size={13} />
</button>
{#if newMenuOpen}
<div class="menu" role="menu">
<button class="mi" onclick={importFromFile} disabled={probing}>
<Icon name="file-text" size={15} />
Playlist file…
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
</button>
<button class="mi" onclick={importFromFolder} disabled={probing}>
<Icon name="folder-open" size={15} />
Folder of audio files…
<span class="hint">tag or natural order</span>
</button>
<button class="mi" onclick={importFromText}>
<Icon name="pencil" size={15} />
Paste a tracklist…
<span class="hint">plain lines or timestamped setlist</span>
</button>
</div>
{/if}
</div>
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button>
</div>
</header>
{#if playlistStore.playlists.length === 0}
<p class="empty">No playlists yet.</p>
<div class="empty">
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
<p class="empty-title">No playlists yet</p>
<p class="empty-sub">
Create one, or import an M3U/XSPF file or a pasted tracklist.
</p>
</div>
{:else}
<ul>
{#each playlistStore.playlists as pl (pl.id)}
<li>
<button class="row" onclick={() => open(pl)}>
<span class="ic"><Icon name="playlist" size={18} /></span>
<span class="name">{pl.name}</span>
<span class="count">
{pl.track_count}
{pl.track_count === 1 ? "track" : "tracks"}
</span>
</button>
</li>
{#if pinned.length}
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
<div class="grid">
{#each pinned as pl (pl.id)}
{@render card(pl)}
{/each}
</ul>
</div>
{#if rest.length}
<div class="lab all">All playlists</div>
{/if}
{/if}
<div class="grid">
{#each rest as pl (pl.id)}
{@render card(pl)}
{/each}
</div>
{/if}
</div>
{#snippet card(pl)}
<div class="card-wrap">
<button class="card" type="button" onclick={() => open(pl)}>
<span class="artwrap">
{#if playlistStore.covers[pl.id]}
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
{:else}
<div class="art fallback">
<Icon name="playlist" size={38} />
</div>
{/if}
{#if pl.track_count > pl.unresolved_count}
<span
class="hoverplay"
role="button"
tabindex="0"
title="Play"
onclick={(e) => {
e.stopPropagation();
playCard(pl);
}}
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
><Icon name="play" size={13} /></span>
{/if}
</span>
<div class="card-title" title={pl.name}>
{pl.name}
</div>
<div class="card-sub">
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
{#if pl.duration_secs}&thinsp;·&thinsp;{fmtDuration(pl.duration_secs)}{/if}
</div>
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
<div class="badges">
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
{#if pl.unresolved_count > 0}
<span class="badge un">{pl.unresolved_count} unresolved</span>
{/if}
</div>
{/if}
</button>
{#if pl.pinned}
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
{/if}
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
<Icon name="more-horizontal" size={17} />
</button>
{#if menuFor === pl.id}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="mi" onclick={() => togglePin(pl)}>
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
</button>
<button class="mi" onclick={() => duplicate(pl)}>
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
</button>
</div>
{/if}
</div>
{/snippet}
<PlaylistImportModal
bind:open={importOpen}
mode={importMode}
draft={importDraft}
onimported={onImported}
/>
<style>
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
margin-bottom: 22px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.new {
.sub {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted);
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
padding: 9px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-highlight);
font-weight: 700;
transition:
background 0.14s,
color 0.14s;
color 0.14s,
transform 0.14s;
}
.new:hover {
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.btn.ghost:hover {
background: var(--bg-press);
color: var(--text-primary);
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
.btn:disabled {
opacity: 0.55;
}
.row {
.menu-wrap {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 230px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
z-index: 40;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
}
.mi {
display: flex;
align-items: center;
gap: 13px;
gap: 10px;
width: 100%;
padding: 11px 12px;
padding: 9px 10px;
border-radius: 7px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: background 0.14s;
text-align: left;
white-space: nowrap;
}
.row:hover {
.mi:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.ic {
.mi:disabled {
opacity: 0.5;
}
.mi.danger:hover {
color: #f15e6c;
}
.mi .hint {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
padding-left: 14px;
}
.menu .rule {
height: 1px;
background: var(--border);
margin: 5px 8px;
}
.lab {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 4px 2px 12px;
}
.lab.all {
margin-top: 26px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
}
.card-wrap {
position: relative;
}
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 13px;
width: 100%;
height: 100%;
transition: background 0.2s;
}
.card:hover {
background: var(--bg-highlight);
}
.artwrap {
position: relative;
display: block;
margin-bottom: 11px;
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
/* The Library card's own hover-play, verbatim in spirit: accent circle
riding the art's corner, revealed by the card hover. */
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 5px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.18s ease,
transform 0.18s ease,
background 0.18s ease;
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
.hoverplay:hover {
background: var(--accent-hover);
}
.card-wrap:hover .hoverplay,
.hoverplay:focus-visible {
opacity: 1;
transform: none;
}
.art.fallback {
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
color: var(--text-secondary);
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 12px;
color: var(--text-muted);
flex: none;
.badges {
display: flex;
gap: 5px;
margin-top: 7px;
flex-wrap: wrap;
}
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2.5px 7px;
border-radius: 8px;
}
.badge.set {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;
}
.pinned-mark {
position: absolute;
top: 20px;
left: 20px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(6px);
pointer-events: none;
}
.more {
position: absolute;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition:
opacity 0.15s,
background 0.15s;
}
.card-wrap:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: rgba(0, 0, 0, 0.7);
}
.card-menu {
top: 48px;
right: 14px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 14vh;
text-align: center;
}
.empty-icon {
display: grid;
place-items: center;
width: 92px;
height: 92px;
border-radius: 24px;
background: var(--bg-elevated);
color: var(--text-muted);
margin-bottom: 18px;
}
.empty-title {
font-size: 17px;
font-weight: 700;
}
.empty-sub {
margin-top: 5px;
font-size: 13px;
color: var(--text-muted);
max-width: 380px;
}
</style>
+76
View File
@@ -0,0 +1,76 @@
// Rough-sync generator invariants: segmentation hysteresis, weighted
// distribution inside measured segments only, and the refusal floor (no
// evidence → no guess — the research doc forbids naive even-spread).
import { test } from "node:test";
import assert from "node:assert/strict";
import {
activeSegments,
lyricLinesOf,
distributeLines,
roughSync,
} from "../../src/lib/lyric-sync.js";
const FRAME = 100;
const quiet = (n) => Array(n).fill(0.05);
const loud = (n) => Array(n).fill(0.8);
test("segments form around sustained activity with hysteresis", () => {
// 3s quiet, 5s loud, 3s quiet, 4s loud, 2s quiet.
const frames = [...quiet(30), ...loud(50), ...quiet(30), ...loud(40), ...quiet(20)];
const segs = activeSegments(frames, FRAME);
assert.equal(segs.length, 2);
assert.equal(segs[0].startMs, 3000);
assert.ok(Math.abs(segs[0].endMs - 8000) <= 1000, `end near 8s, got ${segs[0].endMs}`);
assert.equal(segs[1].startMs, 11000);
});
test("a single hot frame is not singing, a short dip is not a break", () => {
const blip = [...quiet(20), 0.9, ...quiet(30)];
assert.equal(activeSegments(blip, FRAME).length, 0, "blip ignored (min length)");
// 4s loud, 0.4s dip, 4s loud → ONE segment (dip shorter than offRun).
const dip = [...loud(40), ...quiet(4), ...loud(40)];
assert.equal(activeSegments(dip, FRAME).length, 1);
});
test("section labels are dropped from the timing lines", () => {
const lines = lyricLinesOf("[Verse 1]\nFirst line\n\nSecond line\n[Chorus]\nThird");
assert.deepEqual(lines, ["First line", "Second line", "Third"]);
});
test("lines land inside segments, in order, weighted by length", () => {
const segments = [
{ startMs: 5000, endMs: 15000 },
{ startMs: 30000, endMs: 40000 },
];
const lines = ["Short", "A much much much longer line of lyric text", "Mid line here", "End"];
const out = distributeLines(lines, segments);
assert.equal(out.length, 4);
assert.equal(out[0].time_ms, 5000, "first line starts at the first vocal onset");
for (let i = 1; i < out.length; i++) {
assert.ok(out[i].time_ms > out[i - 1].time_ms, "monotonic");
}
for (const line of out) {
const inside = segments.some((s) => line.time_ms >= s.startMs && line.time_ms <= s.endMs);
assert.ok(inside, `${line.time_ms} must sit inside a vocal segment`);
}
// The long second line pushes line 3 further than an even split would.
const even = 5000 + (2 / 4) * 20000;
assert.ok(out[2].time_ms > even - 2000);
});
test("no usable activity → refusal, never an even spread", () => {
assert.equal(roughSync("One\nTwo\nThree", quiet(600), FRAME), null);
assert.equal(roughSync("One\nTwo", [], FRAME), null);
assert.equal(distributeLines([], [{ startMs: 0, endMs: 9000 }]), null);
});
test("end to end over a synthetic song", () => {
// intro 8s quiet — verse 20s — break 6s — chorus 20s.
const frames = [...quiet(80), ...loud(200), ...quiet(60), ...loud(200)];
const text = "Line one\nLine two\nLine three\nLine four\nLine five\nLine six";
const out = roughSync(text, frames, FRAME);
assert.equal(out.length, 6);
assert.equal(out[0].time_ms, 8000, "starts at the verse, not at 0:00");
assert.ok(out[5].time_ms >= 34000, "later lines reach the chorus");
});