forked from Keelrleek/SoulStream
playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14404
-584
No files matched your search
Generated
+20
-1
@@ -163,6 +163,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0-alpha.3"
|
version = "0.1.0-alpha.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audio-formats",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
"keyring",
|
"keyring",
|
||||||
@@ -170,6 +171,8 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"metadata",
|
"metadata",
|
||||||
"player",
|
"player",
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"soulseek-client",
|
"soulseek-client",
|
||||||
@@ -231,6 +234,10 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audio-formats"
|
||||||
|
version = "0.1.0-alpha.3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -2299,6 +2306,7 @@ dependencies = [
|
|||||||
name = "library"
|
name = "library"
|
||||||
version = "0.1.0-alpha.3"
|
version = "0.1.0-alpha.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audio-formats",
|
||||||
"log",
|
"log",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3080,6 +3088,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
|||||||
name = "player"
|
name = "player"
|
||||||
version = "0.1.0-alpha.3"
|
version = "0.1.0-alpha.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audio-formats",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
"rodio",
|
"rodio",
|
||||||
@@ -3096,7 +3105,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml",
|
"quick-xml 0.41.0",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3266,6 +3275,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -4079,6 +4097,7 @@ dependencies = [
|
|||||||
name = "soulseek-client"
|
name = "soulseek-client"
|
||||||
version = "0.1.0-alpha.3"
|
version = "0.1.0-alpha.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audio-formats",
|
||||||
"bytes",
|
"bytes",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"igd-next",
|
"igd-next",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ members = [
|
|||||||
"crates/tagger",
|
"crates/tagger",
|
||||||
"crates/metadata",
|
"crates/metadata",
|
||||||
"crates/library",
|
"crates/library",
|
||||||
|
"crates/audio-formats",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "audio-formats"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
audio-formats = { path = "../audio-formats" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
+1353
-76
File diff suppressed because it is too large.
Load diff
+853
-95
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
audio-formats = { path = "../audio-formats" }
|
||||||
rodio = { version = "0.21", features = ["symphonia-all"] }
|
rodio = { version = "0.21", features = ["symphonia-all"] }
|
||||||
symphonia = { version = "0.5", features = ["all"] }
|
symphonia = { version = "0.5", features = ["all"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
mod loudness;
|
mod loudness;
|
||||||
mod source;
|
mod source;
|
||||||
|
pub mod vocals;
|
||||||
mod waveform;
|
mod waveform;
|
||||||
|
|
||||||
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
|
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
|
||||||
@@ -494,6 +495,19 @@ fn start_decoder(
|
|||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
.spawn(move || {
|
.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() {
|
let (reader, byte_len): (Box<dyn source::MediaRead>, u64) = match track.stream.clone() {
|
||||||
Some(progress) => {
|
Some(progress) => {
|
||||||
match GrowingFile::open(&track.path, track.total_bytes, progress) {
|
match GrowingFile::open(&track.path, track.total_bytes, progress) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
soulseek-proto = { path = "../soulseek-proto" }
|
soulseek-proto = { path = "../soulseek-proto" }
|
||||||
|
audio-formats = { path = "../audio-formats" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
|||||||
@@ -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);
|
responder::respond_to_search(inner.clone(), username, token, query);
|
||||||
}
|
}
|
||||||
Ok(dist::DistributedEvent::BranchLevel(level)) => {
|
Ok(dist::DistributedEvent::BranchLevel(level)) => {
|
||||||
// Report our level (parent + 1) to the server.
|
// Report our level (parent + 1) to the server. Saturating: the
|
||||||
let _ = inner.send_server(server::branch_level(level + 1));
|
// 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)) => {
|
Ok(dist::DistributedEvent::BranchRoot(root)) => {
|
||||||
let _ = inner.send_server(server::branch_root(&root));
|
let _ = inner.send_server(server::branch_root(&root));
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ use std::sync::RwLock;
|
|||||||
|
|
||||||
use soulseek_proto::types::{Directory, File};
|
use soulseek_proto::types::{Directory, File};
|
||||||
|
|
||||||
const AUDIO_EXTS: [&str; 10] = [
|
// Shared with the library scanner and the row pruner (`audio-formats`). The
|
||||||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
// 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.
|
/// One shared file: its remote (virtual) path and where it lives locally.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -161,8 +162,14 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
// The download staging pen is not part of anyone's library —
|
// The download staging pen is not part of anyone's library —
|
||||||
// half-finished transfers are not shareable records.
|
// half-finished transfers are not shareable records. `.soulstream`
|
||||||
if path.file_name().is_some_and(|n| n == "(Cache)") {
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
walk(&path, base, root, index);
|
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 {
|
fn is_audio(path: &Path) -> bool {
|
||||||
path.extension()
|
audio_formats::is_audio_path(path)
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub struct TagSummary {
|
|||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub track_no: Option<u32>,
|
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 year: Option<u32>,
|
||||||
pub duration_secs: Option<u32>,
|
pub duration_secs: Option<u32>,
|
||||||
pub bitrate_kbps: 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.album = non_empty(tag.album().as_deref());
|
||||||
summary.title = non_empty(tag.title().as_deref());
|
summary.title = non_empty(tag.title().as_deref());
|
||||||
summary.track_no = tag.track();
|
summary.track_no = tag.track();
|
||||||
|
summary.disc_no = tag.disk();
|
||||||
summary.year = tag.year();
|
summary.year = tag.year();
|
||||||
summary.album_artist = non_empty(
|
summary.album_artist = non_empty(
|
||||||
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
|
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
|
||||||
@@ -69,6 +74,10 @@ pub struct TagWrite {
|
|||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub track_no: Option<u32>,
|
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 year: Option<u32>,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
@@ -109,6 +118,7 @@ pub fn write_tags(
|
|||||||
|| write.album.is_some()
|
|| write.album.is_some()
|
||||||
|| write.title.is_some()
|
|| write.title.is_some()
|
||||||
|| write.track_no.is_some()
|
|| write.track_no.is_some()
|
||||||
|
|| write.disc_no.is_some()
|
||||||
|| write.year.is_some()
|
|| write.year.is_some()
|
||||||
|| write.label.is_some()
|
|| write.label.is_some()
|
||||||
|| write.genre.is_some();
|
|| write.genre.is_some();
|
||||||
@@ -147,6 +157,9 @@ pub fn write_tags(
|
|||||||
if let Some(v) = write.track_no {
|
if let Some(v) = write.track_no {
|
||||||
tag.set_track(v);
|
tag.set_track(v);
|
||||||
}
|
}
|
||||||
|
if let Some(v) = write.disc_no {
|
||||||
|
tag.set_disk(v);
|
||||||
|
}
|
||||||
if let Some(v) = write.year {
|
if let Some(v) = write.year {
|
||||||
tag.set_year(v);
|
tag.set_year(v);
|
||||||
}
|
}
|
||||||
@@ -222,6 +235,104 @@ pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
|||||||
Ok(())
|
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> {
|
fn non_empty(s: Option<&str>) -> Option<String> {
|
||||||
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
|
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"test": "node --test tests/preview/*.test.mjs"
|
"test": "node --test tests/preview/"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ image.workspace = true
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
base64 = "0.22"
|
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"] }
|
keyring = { version = "3", features = ["windows-native"] }
|
||||||
sysinfo = "0.33"
|
sysinfo = "0.33"
|
||||||
soulseek-proto = { path = "../crates/soulseek-proto" }
|
soulseek-proto = { path = "../crates/soulseek-proto" }
|
||||||
@@ -37,6 +42,7 @@ player = { path = "../crates/player" }
|
|||||||
tagger = { path = "../crates/tagger" }
|
tagger = { path = "../crates/tagger" }
|
||||||
metadata = { path = "../crates/metadata" }
|
metadata = { path = "../crates/metadata" }
|
||||||
library = { path = "../crates/library" }
|
library = { path = "../crates/library" }
|
||||||
|
audio-formats = { path = "../crates/audio-formats" }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.61.3", features = [
|
windows = { version = "0.61.3", features = [
|
||||||
|
|||||||
+29
-109
@@ -143,6 +143,9 @@ pub async fn set_settings(
|
|||||||
volume_normalization: Option<bool>,
|
volume_normalization: Option<bool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cfg = config::load(&app);
|
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 {
|
if let Some(d) = download_dir {
|
||||||
cfg.download_dir = Some(d);
|
cfg.download_dir = Some(d);
|
||||||
}
|
}
|
||||||
@@ -208,6 +211,25 @@ pub async fn set_settings(
|
|||||||
}
|
}
|
||||||
config::save(&app, &cfg);
|
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.
|
// Tighter limits take effect right away, not at the next stream/startup.
|
||||||
if retention_changed {
|
if retention_changed {
|
||||||
let app2 = app.clone();
|
let app2 = app.clone();
|
||||||
@@ -1263,10 +1285,13 @@ pub struct CatalogHeroPreviewResult {
|
|||||||
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
|
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
|
||||||
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
|
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> {
|
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
let dir = app
|
let cfg = config::load(app);
|
||||||
.path()
|
let dir = config::ensure_library_data_dir(app, &cfg)
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.join("artist-heroes");
|
.join("artist-heroes");
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- playlists ----
|
// ---- playlists live in src-tauri/src/playlists.rs ----
|
||||||
|
|
||||||
fn now_secs() -> i64 {
|
fn now_secs() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -4375,111 +4400,6 @@ fn now_secs() -> i64 {
|
|||||||
.unwrap_or(0)
|
.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.
|
/// Compute (or fetch cached) waveform peaks for a local audio file.
|
||||||
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
|
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -258,6 +258,148 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
|
|||||||
music.join("SoulStream")
|
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 const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}";
|
||||||
|
|
||||||
pub fn library_layout(cfg: &AppConfig) -> &str {
|
pub fn library_layout(cfg: &AppConfig) -> &str {
|
||||||
|
|||||||
+499
-33
@@ -20,7 +20,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, Manager, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::pipeline::{self, LibraryState};
|
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> {
|
fn session_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let cfg = config::load(app);
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
let dir = config::ensure_library_data_dir(app, &cfg).map_err(|e| e.to_string())?;
|
||||||
Ok(dir.join("import-session.json"))
|
Ok(dir.join("import-session.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,20 +467,19 @@ pub struct ImportDest {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_get_dest(app: tauri::AppHandle) -> Result<ImportDest, String> {
|
pub fn import_get_dest(app: tauri::AppHandle) -> Result<ImportDest, String> {
|
||||||
let cfg = config::load(&app);
|
let cfg = config::load(&app);
|
||||||
let suggested = if cfg.library_dir.is_none() {
|
// `dir` is the destination that WILL be written to, never null. It used to
|
||||||
Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned())
|
// report only an explicitly-chosen folder, so a null read as "no
|
||||||
} else {
|
// destination" and every import path disabled itself — the review's Import
|
||||||
None
|
// button greyed out and the picker's save-and-bank returned in silence.
|
||||||
};
|
let dir = config::import_dest_dir(&app, &cfg);
|
||||||
let free_bytes = cfg
|
let suggested = cfg
|
||||||
.library_dir
|
.library_dir
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(suggested.as_deref())
|
.map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None);
|
||||||
.and_then(|d| free_space_of(Path::new(d)));
|
|
||||||
Ok(ImportDest {
|
Ok(ImportDest {
|
||||||
dir: cfg.library_dir.clone(),
|
free_bytes: free_space_of(&dir),
|
||||||
|
dir: Some(dir.to_string_lossy().into_owned()),
|
||||||
suggested,
|
suggested,
|
||||||
free_bytes,
|
|
||||||
layout: config::library_layout(&cfg).to_string(),
|
layout: config::library_layout(&cfg).to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -484,13 +556,62 @@ pub async fn import_local_art(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
source_root: String,
|
source_root: String,
|
||||||
rel: String,
|
rel: String,
|
||||||
|
parts: Option<Vec<String>>,
|
||||||
) -> Result<Option<LocalArt>, 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
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.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);
|
let dir = Path::new(source_root).join(rel);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return None;
|
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
|
// 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
|
// into app data so the webview can read it (the audio file itself can't
|
||||||
// be handed to an <img>).
|
// be handed to an <img>).
|
||||||
let mut files: Vec<PathBuf> = Vec::new();
|
let files = own_audio_files(&dir, parts);
|
||||||
pipeline::collect_audio_files(&dir, &mut files, 0);
|
|
||||||
files.sort();
|
|
||||||
for f in files.iter().take(5) {
|
for f in files.iter().take(5) {
|
||||||
let Ok(Some(bytes)) = tagger::extract_art(f) else { continue };
|
let Ok(Some(bytes)) = tagger::extract_art(f) else { continue };
|
||||||
let Ok(img) = image::load_from_memory(&bytes) else { continue };
|
let Ok(img) = image::load_from_memory(&bytes) else { continue };
|
||||||
let (w, h) = (img.width(), img.height());
|
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()?;
|
std::fs::create_dir_all(&cache_dir).ok()?;
|
||||||
// Stable name per rel so reopening a record reuses the extraction.
|
// Stable name per rel so reopening a record reuses the extraction.
|
||||||
let mut hash: u64 = 0xcbf29ce484222325;
|
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))
|
.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
|
/// Download a found album copy into `(Cache)\_audition\<rel>\` for the
|
||||||
/// version comparison — the WHOLE album (his call: keeping it later is a
|
/// version comparison — the WHOLE album (his call: keeping it later is a
|
||||||
/// move, not a re-download, and one search's worth of requests).
|
/// 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())?;
|
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
|
||||||
let mut ids = Vec::with_capacity(files.len());
|
let mut ids = Vec::with_capacity(files.len());
|
||||||
for (i, f) in files.iter().enumerate() {
|
for (i, f) in files.iter().enumerate() {
|
||||||
let leaf = f.path.rsplit('\\').next().unwrap_or(&f.path);
|
let target = pen_target(&base, &f.path);
|
||||||
let target = base.join(pipeline::safe_component(leaf));
|
|
||||||
// Claimed BEFORE the transfer task is spawned: it emits Connecting
|
// Claimed BEFORE the transfer task is spawned: it emits Connecting
|
||||||
// immediately, and an event whose id is not yet registered is one the
|
// 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
|
// 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,
|
/// 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
|
/// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the
|
||||||
/// whole flow (metadata::identify_album). Read-only.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn import_identify(
|
pub async fn import_identify(
|
||||||
|
app: tauri::AppHandle,
|
||||||
state: State<'_, LibraryState>,
|
state: State<'_, LibraryState>,
|
||||||
artist: String,
|
artist: String,
|
||||||
title: 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> {
|
) -> 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
|
/// 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
|
/// fewer than this means the transfer never finished, and importing it
|
||||||
/// would file a truncated album — apply refuses instead.
|
/// would file a truncated album — apply refuses instead.
|
||||||
pub audio_tracks: Option<usize>,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -880,9 +1157,9 @@ pub async fn import_apply(
|
|||||||
albums: Vec<ApplyAlbum>,
|
albums: Vec<ApplyAlbum>,
|
||||||
) -> Result<ApplyReport, String> {
|
) -> Result<ApplyReport, String> {
|
||||||
let cfg = config::load(&app);
|
let cfg = config::load(&app);
|
||||||
let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else {
|
// An unset destination means the library folder, not a refusal — see
|
||||||
return Err("no library folder chosen yet".into());
|
// `config::import_dest_dir`.
|
||||||
};
|
let dest_root = config::import_dest_dir(&app, &cfg);
|
||||||
let layout = config::library_layout(&cfg).to_string();
|
let layout = config::library_layout(&cfg).to_string();
|
||||||
let embed_max_edge = config::embed_art_max_edge(&cfg);
|
let embed_max_edge = config::embed_art_max_edge(&cfg);
|
||||||
let replace_embedded = config::replace_embedded_art(&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
|
// match the one the review displayed, because `track_titles` is
|
||||||
// positional: disagree here and every title past the first part lands on
|
// positional: disagree here and every title past the first part lands on
|
||||||
// the wrong file. A found copy overrides this — the pen is flat.
|
// 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 files: Vec<PathBuf> = if take_theirs || a.parts.is_empty() {
|
||||||
let mut f = audio_files_in(&src_dir);
|
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.sort();
|
||||||
f
|
f
|
||||||
} else {
|
} else {
|
||||||
let mut out = Vec::new();
|
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 dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) };
|
||||||
let mut f = audio_files_in(&dir);
|
let mut f = audio_files_in(&dir);
|
||||||
f.sort();
|
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.append(&mut f);
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -1217,16 +1521,24 @@ fn apply_one(
|
|||||||
// chosen cover embeds at the same bounded size downloads use, and
|
// chosen cover embeds at the same bounded size downloads use, and
|
||||||
// replaces an existing picture when config says so — an explicit
|
// replaces an existing picture when config says so — an explicit
|
||||||
// pick in the review is the strongest consent that config models.
|
// 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 {
|
let write = tagger::TagWrite {
|
||||||
artist: ch.artist.clone(),
|
artist: ch.and_then(|c| c.artist.clone()),
|
||||||
album_artist: None,
|
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()),
|
title: track_titles.and_then(|t| t.get(i).cloned().flatten()),
|
||||||
track_no: None,
|
track_no: None,
|
||||||
year: ch.year,
|
disc_no: disc,
|
||||||
label: ch.label.clone(),
|
year: ch.and_then(|c| c.year),
|
||||||
genre: ch.genre.clone(),
|
label: ch.and_then(|c| c.label.clone()),
|
||||||
|
genre: ch.and_then(|c| c.genre.clone()),
|
||||||
};
|
};
|
||||||
let embed = embed_art.as_deref();
|
let embed = embed_art.as_deref();
|
||||||
if let Err(e) = tagger::write_tags(
|
if let Err(e) = tagger::write_tags(
|
||||||
@@ -1249,6 +1561,9 @@ fn apply_one(
|
|||||||
library
|
library
|
||||||
.upsert_track(
|
.upsert_track(
|
||||||
album_id,
|
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,
|
s.track_no,
|
||||||
&title,
|
&title,
|
||||||
&artist,
|
&artist,
|
||||||
@@ -1328,6 +1643,39 @@ fn apply_one(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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
|
/// audition pen beside the download root, and a library root to file
|
||||||
/// into. Files carry distinct bytes so "which version landed" is a
|
/// into. Files carry distinct bytes so "which version landed" is a
|
||||||
@@ -1378,6 +1726,7 @@ mod tests {
|
|||||||
track_titles: None,
|
track_titles: None,
|
||||||
audio: Some(audio.into()),
|
audio: Some(audio.into()),
|
||||||
audio_tracks: want,
|
audio_tracks: want,
|
||||||
|
audio_files: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,6 +1771,123 @@ mod tests {
|
|||||||
out
|
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
|
/// Re-importing the same record must not copy it again — even though the
|
||||||
/// imported files no longer match their sources byte for byte.
|
/// 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)
|
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
b.library
|
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)
|
&stray.to_string_lossy(), 0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
+38
-6
@@ -6,8 +6,11 @@ mod hero_crop;
|
|||||||
mod import;
|
mod import;
|
||||||
mod hero_detector;
|
mod hero_detector;
|
||||||
mod loudness;
|
mod loudness;
|
||||||
|
mod lyrics;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod playback;
|
mod playback;
|
||||||
|
mod playlist_formats;
|
||||||
|
mod playlists;
|
||||||
mod preview;
|
mod preview;
|
||||||
mod smtc;
|
mod smtc;
|
||||||
mod state;
|
mod state;
|
||||||
@@ -93,14 +96,41 @@ pub fn run() {
|
|||||||
commands::unfollow_artist,
|
commands::unfollow_artist,
|
||||||
commands::home_artist_news,
|
commands::home_artist_news,
|
||||||
commands::home_warm,
|
commands::home_warm,
|
||||||
commands::list_playlists,
|
playlists::list_playlists,
|
||||||
commands::create_playlist,
|
playlists::create_playlist,
|
||||||
commands::delete_playlist,
|
playlists::rename_playlist,
|
||||||
commands::playlist_tracks,
|
playlists::set_playlist_description,
|
||||||
commands::add_to_playlist,
|
playlists::set_playlist_pinned,
|
||||||
commands::remove_from_playlist,
|
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,
|
commands::catalog_search,
|
||||||
import::import_scan,
|
import::import_scan,
|
||||||
|
import::import_portable_root,
|
||||||
|
import::import_resolve_root,
|
||||||
import::import_session_get,
|
import::import_session_get,
|
||||||
import::import_session_set,
|
import::import_session_set,
|
||||||
import::import_session_clear,
|
import::import_session_clear,
|
||||||
@@ -111,6 +141,8 @@ pub fn run() {
|
|||||||
import::import_cover_report,
|
import::import_cover_report,
|
||||||
import::import_local_art,
|
import::import_local_art,
|
||||||
import::import_audition,
|
import::import_audition,
|
||||||
|
import::import_audition_missing,
|
||||||
|
import::import_audition_clear,
|
||||||
import::import_probe_file,
|
import::import_probe_file,
|
||||||
pipeline::rescan_library,
|
pipeline::rescan_library,
|
||||||
commands::catalog_artist,
|
commands::catalog_artist,
|
||||||
|
|||||||
@@ -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> {
|
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) {
|
pub fn init(app: &tauri::AppHandle) {
|
||||||
@@ -947,8 +953,10 @@ mod tests {
|
|||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.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);
|
files.push(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1092,8 +1100,7 @@ mod tests {
|
|||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
|
if !audio_formats::is_playable_ext(&ext) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let artist = path
|
let artist = path
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+90
-20
@@ -11,11 +11,9 @@ use library::Library;
|
|||||||
use metadata::{AlbumMeta, MetadataClient};
|
use metadata::{AlbumMeta, MetadataClient};
|
||||||
use tagger::{TagSummary, TagWrite};
|
use tagger::{TagSummary, TagWrite};
|
||||||
|
|
||||||
// aiff/aif joined for the import tool: the reference library carries 13 AIFF
|
// The one table, in `audio-formats`. This used to be a local list that
|
||||||
// tracks that a scan would otherwise silently drop, and "silently" is the
|
// disagreed with the share index and the row pruner — the scanner indexed
|
||||||
// problem — the album imports incomplete with no warning.
|
// `.aiff` and the pruner deleted it on the next startup.
|
||||||
pub(crate) const AUDIO_EXTS: [&str; 10] =
|
|
||||||
["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"];
|
|
||||||
|
|
||||||
pub struct LibraryState {
|
pub struct LibraryState {
|
||||||
pub library: Arc<Library>,
|
pub library: Arc<Library>,
|
||||||
@@ -140,12 +138,53 @@ fn spawn_identity_warmer(app: tauri::AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
|
pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
|
||||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
// AppData keeps only what is about the APP on THIS machine — the cache of
|
||||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
// 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");
|
let art_dir = data_dir.join("art");
|
||||||
std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?;
|
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())?);
|
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_library = library.clone();
|
||||||
let repair_art_dir = art_dir.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.
|
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
|
||||||
metadata: Arc::new({
|
metadata: Arc::new({
|
||||||
let client = MetadataClient::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
|
// Its own directory, NOT inside the cache above: that one is
|
||||||
// pruned by mtime, and these are verdicts rather than cached
|
// 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"));
|
.with_identity_store(data_dir.join("artist-identity-v1"));
|
||||||
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
||||||
// resolution can be compared against `hero_source_probe` — the two
|
// 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>();
|
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 lib = state.library.clone();
|
||||||
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
|
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();
|
let lib = state.library.clone();
|
||||||
if let Ok(Ok(removed)) =
|
if let Ok(Ok(removed)) =
|
||||||
tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await
|
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
|
// "(Cache)" is the download staging pen — a completed file
|
||||||
// sitting there is seconds from being organized out, and
|
// sitting there is seconds from being organized out, and
|
||||||
// indexing it would pin a library row to a staging path.
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
collect_audio_files(&path, out, depth + 1);
|
collect_audio_files(&path, out, depth + 1);
|
||||||
} else if path
|
} else if audio_formats::is_audio_path(&path) {
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
out.push(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()),
|
album: summary.album.is_none().then_some(final_album.clone()),
|
||||||
title: summary.title.is_none().then_some(title.clone()),
|
title: summary.title.is_none().then_some(title.clone()),
|
||||||
track_no: summary.track_no.or(parsed.track_no),
|
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(),
|
year: summary.year.is_none().then(|| final_year).flatten(),
|
||||||
label: None,
|
label: None,
|
||||||
genre: 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 p = library_path.to_string_lossy().into_owned();
|
||||||
let art = art_path.as_ref().map(|a| a.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);
|
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 (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
|
||||||
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
|
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
|
||||||
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
|
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(
|
lib.upsert_track(
|
||||||
album_id,
|
album_id,
|
||||||
|
disc_no,
|
||||||
track_no,
|
track_no,
|
||||||
&title2,
|
&title2,
|
||||||
&artist,
|
&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 {
|
pub fn is_audio_path(path: &Path) -> bool {
|
||||||
path.extension()
|
audio_formats::is_audio_path(path)
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a newly completed download into one album directory under the managed
|
/// 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,6 +4,8 @@
|
|||||||
import PlayerBar from "./components/PlayerBar.svelte";
|
import PlayerBar from "./components/PlayerBar.svelte";
|
||||||
import NowPlaying from "./components/NowPlaying.svelte";
|
import NowPlaying from "./components/NowPlaying.svelte";
|
||||||
import ArtworkViewer from "./components/ArtworkViewer.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 ImportWindow from "./components/ImportWindow.svelte";
|
||||||
import LoginModal from "./components/LoginModal.svelte";
|
import LoginModal from "./components/LoginModal.svelte";
|
||||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||||
@@ -126,6 +128,8 @@
|
|||||||
<FollowToast />
|
<FollowToast />
|
||||||
<ImportToast />
|
<ImportToast />
|
||||||
<PreviewOverlay />
|
<PreviewOverlay />
|
||||||
|
<LyricsOverlay />
|
||||||
|
<LyricsSyncEditor />
|
||||||
<ArtworkViewer />
|
<ArtworkViewer />
|
||||||
<ImportWindow />
|
<ImportWindow />
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
|
|||||||
@@ -76,6 +76,13 @@
|
|||||||
"brand-bandcamp": '<path d="M22 7h-9.5L2 17h9.5L22 7z"/>',
|
"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"/>',
|
"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"/>',
|
"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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
|
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
|
||||||
import { evidence as matchEvidence, normx } from "../lib/import-evidence.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 {
|
let {
|
||||||
album: a, // derived album from ImportWindow (rel, title, …)
|
album: a, // derived album from ImportWindow (rel, title, …)
|
||||||
@@ -101,14 +101,79 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const ask = albumName ?? a.title;
|
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
|
// Stamped with the engine that produced it: a cached "nothing
|
||||||
// found" is only trustworthy while the thing that looked has
|
// found" is only trustworthy while the thing that looked has
|
||||||
// not learned anything new. See IDENTIFY_ENGINE.
|
// 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) {
|
if (r.candidate?.cover) {
|
||||||
const rep = await api.importCoverReport(
|
const rep = await api.importCoverReport(
|
||||||
artist, r.used ?? ask, r.candidate.cover);
|
askedArtist, r.used ?? ask, r.candidate.cover);
|
||||||
reports[rel] = rep;
|
reports[rel] = rep;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -133,7 +198,10 @@
|
|||||||
const rel = a.rel;
|
const rel = a.rel;
|
||||||
if (localArts[rel] !== undefined) return;
|
if (localArts[rel] !== undefined) return;
|
||||||
localArts[rel] = null;
|
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; },
|
(v) => { localArts[rel] = v; },
|
||||||
() => {});
|
() => {});
|
||||||
});
|
});
|
||||||
@@ -226,7 +294,10 @@
|
|||||||
// an exact hit look doubtful: "Mirrored" against
|
// an exact hit look doubtful: "Mirrored" against
|
||||||
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
|
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
|
||||||
// match wore a "!" on its title row.
|
// 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 verdictLine = $derived.by(() => {
|
||||||
const c = candidate;
|
const c = candidate;
|
||||||
@@ -285,15 +356,57 @@
|
|||||||
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
|
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
|
||||||
const resolvedTrack = (n) => choice?.tracks?.[n] ?? "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 trackPairs = $derived.by(() => {
|
||||||
const theirs = candidate?.tracklist ?? [];
|
const theirs = candidate?.tracklist ?? [];
|
||||||
return a.tracks.map((t, n) => {
|
return trackRows.map(({ t, n }, row) => {
|
||||||
const mine = t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
|
const mine = titleOfTrack(t);
|
||||||
const their = theirs[n];
|
const their = theirs[row];
|
||||||
const dY = t.seconds ?? null;
|
const dY = t.seconds ?? null;
|
||||||
const dT = their?.secs ?? null;
|
const dT = their?.secs ?? null;
|
||||||
const drift = dY && dT ? Math.abs(dY - dT) : 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 };
|
aligned: drift !== null && drift <= 2 };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -542,8 +655,15 @@
|
|||||||
const tags = {};
|
const tags = {};
|
||||||
for (const [k, , theirs] of tagPairs)
|
for (const [k, , theirs] of tagPairs)
|
||||||
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
|
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
|
||||||
const trackTitles = trackPairs.map((p) =>
|
// Built by SCAN index, never by row: apply matches `trackTitles`
|
||||||
p.their && resolvedTrack(p.n) === "theirs" ? p.their : null);
|
// 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 });
|
onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -718,7 +838,9 @@
|
|||||||
<div class="im-ab-find">
|
<div class="im-ab-find">
|
||||||
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
|
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
|
||||||
<small>{audition?.status === "none"
|
<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"
|
: audition?.status === "failed"
|
||||||
? "The peer stopped answering — try the search again."
|
? "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>
|
: "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">{candidate.source}</div>
|
||||||
<div class="im-tl-h"></div>
|
<div class="im-tl-h"></div>
|
||||||
{#each trackPairs as p (p.n)}
|
{#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"}
|
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "yours"}
|
||||||
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
|
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
|
||||||
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}
|
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
|
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
|
||||||
? ` · ${importStatus.label}` : ""}</span>
|
? ` · ${importStatus.label}` : ""}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if importStatus.error}
|
||||||
|
<span>{importStatus.error} <b>Click to fix</b></span>
|
||||||
{:else}
|
{:else}
|
||||||
<span>The import hit a problem — <b>click to see</b></span>
|
<span>The import hit a problem — <b>click to see</b></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
|
|
||||||
let step = $state("source");
|
let step = $state("source");
|
||||||
let root = $state(null); // the folder being imported
|
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 dest = $state(null); // { dir, freeBytes, layout } from the backend
|
||||||
let scan = $state(null); // ScanResult
|
let scan = $state(null); // ScanResult
|
||||||
let scanProg = $state(null); // latest per-file tick; running totals ride along
|
let scanProg = $state(null); // latest per-file tick; running totals ride along
|
||||||
@@ -93,7 +98,7 @@
|
|||||||
return [rel, rest];
|
return [rel, rest];
|
||||||
}));
|
}));
|
||||||
await api.importSessionSet(JSON.stringify({
|
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,
|
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -108,8 +113,29 @@
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const v = JSON.parse(raw);
|
const v = JSON.parse(raw);
|
||||||
if (v?.root) {
|
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;
|
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;
|
layoutChoice = v.layout ?? null;
|
||||||
choices = v.choices ?? {};
|
choices = v.choices ?? {};
|
||||||
found = v.found ?? {};
|
found = v.found ?? {};
|
||||||
@@ -123,6 +149,7 @@
|
|||||||
auditions = v.auditions ?? {};
|
auditions = v.auditions ?? {};
|
||||||
keepSeparate = v.keepSeparate ?? [];
|
keepSeparate = v.keepSeparate ?? [];
|
||||||
step = v.step === "scanning" ? "source" : (v.step ?? "source");
|
step = v.step === "scanning" ? "source" : (v.step ?? "source");
|
||||||
|
if (learned) schedulePersist();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("import: session restore failed", e);
|
console.warn("import: session restore failed", e);
|
||||||
@@ -161,8 +188,7 @@
|
|||||||
// The dev seed ({ root } form) still overrides the source on open.
|
// The dev seed ({ root } form) still overrides the source on open.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
|
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
|
||||||
root = ui.importOpen.root;
|
setRoot(ui.importOpen.root);
|
||||||
scan = null;
|
|
||||||
step = "source";
|
step = "source";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -206,9 +232,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- source ----------------------------------------------------------
|
// ---- 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() {
|
async function pickSource() {
|
||||||
const p = await api.pickFolder().catch(() => null);
|
const p = await api.pickFolder().catch(() => null);
|
||||||
if (p) { root = p; scan = null; }
|
if (p) await setRoot(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickDest() {
|
async function pickDest() {
|
||||||
@@ -473,7 +508,9 @@
|
|||||||
else
|
else
|
||||||
k = matchEvidence(
|
k = matchEvidence(
|
||||||
f.result.candidate,
|
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,
|
albumOf.get(x.rel) ?? x.title,
|
||||||
x.tracks.length,
|
x.tracks.length,
|
||||||
).verdict;
|
).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.
|
/** Stop waiting on this peer and drop to the next ranked copy.
|
||||||
*
|
*
|
||||||
* Cancels the transfers first — without that, a switch left the old peer
|
* Cancels the transfers first — without that, a switch left the old peer
|
||||||
@@ -721,6 +877,7 @@
|
|||||||
for (const id of au.ids ?? []) {
|
for (const id of au.ids ?? []) {
|
||||||
try { await api.cancelDownload(id); } catch {}
|
try { await api.cancelDownload(id); } catch {}
|
||||||
}
|
}
|
||||||
|
await clearPen(rel, reason ?? "peer switched");
|
||||||
const [next, ...rest] = au.alts ?? [];
|
const [next, ...rest] = au.alts ?? [];
|
||||||
if (!next) {
|
if (!next) {
|
||||||
auditions[rel] = { status: "none", dead, exhausted: true, reason };
|
auditions[rel] = { status: "none", dead, exhausted: true, reason };
|
||||||
@@ -751,10 +908,13 @@
|
|||||||
for (const id of prev?.ids ?? []) {
|
for (const id of prev?.ids ?? []) {
|
||||||
try { await api.cancelDownload(id); } catch {}
|
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 };
|
auditions[rel] = { status: "searching", dead };
|
||||||
try {
|
try {
|
||||||
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
|
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
|
||||||
const list = await findAlbumCopies({
|
const { copies, silent } = await findAlbumCopies({
|
||||||
artist: pickerArtist(x),
|
artist: pickerArtist(x),
|
||||||
// The record's own name leads; its folder is the narrower
|
// The record's own name leads; its folder is the narrower
|
||||||
// fallback for when the tags are wrong or absent.
|
// fallback for when the tags are wrong or absent.
|
||||||
@@ -763,15 +923,18 @@
|
|||||||
candidate: trusted ? found[rel]?.result?.candidate : null,
|
candidate: trusted ? found[rel]?.result?.candidate : null,
|
||||||
wantTracks: x.tracks.length,
|
wantTracks: x.tracks.length,
|
||||||
});
|
});
|
||||||
if (!list.length) {
|
if (!copies.length) {
|
||||||
auditions[rel] = { status: "none", dead };
|
// `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();
|
schedulePersist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await beginAudition(
|
await beginAudition(
|
||||||
rel,
|
rel,
|
||||||
slimCandidate(list[0]),
|
slimCandidate(copies[0]),
|
||||||
list.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
|
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
|
||||||
{ dead },
|
{ dead },
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -812,6 +975,7 @@
|
|||||||
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
|
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
|
||||||
if (p.done) {
|
if (p.done) {
|
||||||
au.arrived = (au.arrived ?? 0) + 1;
|
au.arrived = (au.arrived ?? 0) + 1;
|
||||||
|
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
au.track1Path = p.localPath;
|
au.track1Path = p.localPath;
|
||||||
au.status = "ready";
|
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
|
/** The picker's resolved snapshot — what apply writes. Stored beside the
|
||||||
* choices but NOT a "seen": looking is not deciding. */
|
* choices but NOT a "seen": looking is not deciding. */
|
||||||
function snapshot(rel, snap) {
|
function snapshot(rel, snap) {
|
||||||
@@ -1182,6 +1445,12 @@
|
|||||||
if (c && !refused.has(rel)) c.applied = true;
|
if (c && !refused.has(rel)) c.applied = true;
|
||||||
if (refused.size) {
|
if (refused.size) {
|
||||||
await persist();
|
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 {
|
} else {
|
||||||
await api.importSessionClear().catch(() => {});
|
await api.importSessionClear().catch(() => {});
|
||||||
importResume.active = false;
|
importResume.active = false;
|
||||||
@@ -1221,6 +1490,17 @@
|
|||||||
// copy out of the source folder.
|
// copy out of the source folder.
|
||||||
audio: r.audio === "theirs" ? "theirs" : null,
|
audio: r.audio === "theirs" ? "theirs" : null,
|
||||||
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : 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 —
|
* doesn't redo them. The eventual full apply still covers everything —
|
||||||
* idempotent copies make the banked files no-ops. */
|
* idempotent copies make the banked files no-ops. */
|
||||||
async function applyPartial() {
|
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)
|
const seen = new Set(Object.entries(choices)
|
||||||
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
|
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
|
||||||
if (!seen.size) return;
|
if (!seen.size) return;
|
||||||
@@ -1255,6 +1545,7 @@
|
|||||||
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
|
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
|
||||||
for (const r of seen)
|
for (const r of seen)
|
||||||
if (choices[r] && !refused.has(r)) choices[r].applied = true;
|
if (choices[r] && !refused.has(r)) choices[r].applied = true;
|
||||||
|
resumeRefused(rep.skipped);
|
||||||
importStatus.done = rep.albums;
|
importStatus.done = rep.albums;
|
||||||
importStatus.label = fmtGB(rep.bytes);
|
importStatus.label = fmtGB(rep.bytes);
|
||||||
importStatus.skipped = refused.size;
|
importStatus.skipped = refused.size;
|
||||||
@@ -1297,6 +1588,8 @@
|
|||||||
}
|
}
|
||||||
step = "source";
|
step = "source";
|
||||||
root = null;
|
root = null;
|
||||||
|
rootRel = null;
|
||||||
|
rootMissing = false;
|
||||||
scan = null;
|
scan = null;
|
||||||
scanProg = null;
|
scanProg = null;
|
||||||
scanEta = null;
|
scanEta = null;
|
||||||
@@ -1364,14 +1657,14 @@
|
|||||||
show it on, so without this the only trace of a refusal was
|
show it on, so without this the only trace of a refusal was
|
||||||
a toast reading "Imported 0 records". Not dismissible while
|
a toast reading "Imported 0 records". Not dismissible while
|
||||||
it is still true — it clears itself when they import. -->
|
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">
|
<div class="im-left">
|
||||||
<b>{nfmt(report.skipped.length)} record{report.skipped.length === 1 ? "" : "s"}
|
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
|
||||||
{report.skipped.length === 1 ? "was" : "were"} not imported.</b>
|
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
|
||||||
Nothing was written for {report.skipped.length === 1 ? "it" : "them"} and
|
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
|
||||||
{report.skipped.length === 1 ? "it is" : "they are"} still on the list —
|
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
|
||||||
importing again once the reason below is gone will pick
|
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">
|
<div class="im-left-rows">
|
||||||
<!-- The FOLDER, not the tag album name: this record's
|
<!-- The FOLDER, not the tag album name: this record's
|
||||||
tags call it "user48736353001", which names
|
tags call it "user48736353001", which names
|
||||||
@@ -1397,7 +1690,13 @@
|
|||||||
<div class="im-drop-t">
|
<div class="im-drop-t">
|
||||||
<div class="im-drop-h">The music — read from</div>
|
<div class="im-drop-h">The music — read from</div>
|
||||||
<div class="im-path">{root ?? "Choose a folder…"}</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 ·
|
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
|
||||||
{fmtGB(totals.bytes)} · scanned</small>
|
{fmtGB(totals.bytes)} · scanned</small>
|
||||||
{:else if root}
|
{:else if root}
|
||||||
@@ -1638,7 +1937,83 @@
|
|||||||
{label}<i>{nfmt(n)}</i>
|
{label}<i>{nfmt(n)}</i>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
</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}
|
{#if curPick}
|
||||||
{#key curPick.rel}
|
{#key curPick.rel}
|
||||||
<ImportPicker
|
<ImportPicker
|
||||||
@@ -2117,6 +2492,72 @@
|
|||||||
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
|
.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; }
|
.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. */
|
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
|
||||||
.im-left {
|
.im-left {
|
||||||
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
|
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
|
||||||
@@ -2137,6 +2578,9 @@
|
|||||||
}
|
}
|
||||||
.im-reset b { color: #f0a0a0; }
|
.im-reset b { color: #f0a0a0; }
|
||||||
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
|
.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 { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
|
||||||
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
|
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
|
||||||
|
|
||||||
@@ -2177,5 +2621,6 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.im-bar.live i { animation: none; }
|
.im-bar.live i { animation: none; }
|
||||||
.led-pulse { animation: none; }
|
.led-pulse { animation: none; }
|
||||||
|
.ar-dot { animation: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import SaveMenu from "./SaveMenu.svelte";
|
import SaveMenu from "./SaveMenu.svelte";
|
||||||
|
import LyricsPane from "./LyricsPane.svelte";
|
||||||
|
import PlaylistMenu from "./PlaylistMenu.svelte";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { floatScroll } from "../lib/scrollbar.js";
|
import { floatScroll } from "../lib/scrollbar.js";
|
||||||
import { splitDescription } from "../lib/editions.js";
|
import { splitDescription } from "../lib/editions.js";
|
||||||
@@ -1087,6 +1089,27 @@
|
|||||||
{metaParts[1] || player.track?.title || "Nothing playing"}
|
{metaParts[1] || player.track?.title || "Nothing playing"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<!-- Saving what you are streaming lived in the popover. It is the
|
||||||
only ACTION that did, so it comes up here rather than being
|
only ACTION that did, so it comes up here rather than being
|
||||||
lost with it. -->
|
lost with it. -->
|
||||||
@@ -1272,9 +1295,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if pane === "lyrics"}
|
{:else if pane === "lyrics"}
|
||||||
<p class="pane-empty">
|
<LyricsPane />
|
||||||
Lyrics aren't wired up yet — this pane is a placeholder.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{:else if pane === "artist"}
|
{:else if pane === "artist"}
|
||||||
{@render artistChip()}
|
{@render artistChip()}
|
||||||
@@ -1825,6 +1846,17 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
|
.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 {
|
.np-scroll {
|
||||||
/* Fills the absolutely-positioned .full-view rather than sizing to its
|
/* Fills the absolutely-positioned .full-view rather than sizing to its
|
||||||
|
|||||||
@@ -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}
|
||||||
|
 · <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>
|
||||||
@@ -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>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
goToView,
|
goToView,
|
||||||
openRecentAlbum,
|
openRecentAlbum,
|
||||||
getArt,
|
getArt,
|
||||||
|
playlistCoverFor,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
@@ -145,6 +146,15 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!recentsStore.loaded) refreshRecents();
|
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>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
||||||
@@ -226,6 +236,28 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
<div class="rule"></div>
|
||||||
|
|
||||||
{#each recentRows as row (row.key)}
|
{#each recentRows as row (row.key)}
|
||||||
@@ -698,6 +730,14 @@
|
|||||||
opacity: 0;
|
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 {
|
.eq {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
+111
-8
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
|
|||||||
export const setSettings = (patch) => invoke("set_settings", patch);
|
export const setSettings = (patch) => invoke("set_settings", patch);
|
||||||
export const forgetCredentials = () => invoke("forget_credentials");
|
export const forgetCredentials = () => invoke("forget_credentials");
|
||||||
export const pickFolder = () => invoke("pick_folder");
|
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 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 deletePlaylist = (id) => invoke("delete_playlist", { id });
|
||||||
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
|
export const playlistItems = (id) => invoke("playlist_items", { id });
|
||||||
export const addToPlaylist = (playlistId, tracks) =>
|
export const playlistAddItems = (playlistId, items, at = null) =>
|
||||||
invoke("add_to_playlist", { playlistId, tracks });
|
invoke("playlist_add_items", { playlistId, items, at });
|
||||||
export const removeFromPlaylist = (playlistId, position) =>
|
export const playlistRemoveItems = (playlistId, itemIds) =>
|
||||||
invoke("remove_from_playlist", { playlistId, position });
|
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 catalogSearch = (query) => invoke("catalog_search", { query });
|
||||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||||
invoke("remember_fused_artist_portrait", { 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.
|
// source folder is never written to.
|
||||||
export const importScan = (root) => invoke("import_scan", { root });
|
export const importScan = (root) => invoke("import_scan", { root });
|
||||||
export const importSessionGet = () => invoke("import_session_get");
|
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 importSessionSet = (state) => invoke("import_session_set", { state });
|
||||||
export const importSessionClear = () => invoke("import_session_clear");
|
export const importSessionClear = () => invoke("import_session_clear");
|
||||||
export const importGetDest = () => invoke("import_get_dest");
|
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 });
|
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
|
||||||
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
||||||
// the artwork panel's cover report. Both read-only.
|
// 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) =>
|
export const importCoverReport = (artist, title, referenceUrl) =>
|
||||||
invoke("import_cover_report", { artist, title, referenceUrl });
|
invoke("import_cover_report", { artist, title, referenceUrl });
|
||||||
// The record's own artwork, measured (embedded first, else folder images).
|
// 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.
|
// Prune rows whose files are gone, then index anything new — the real rescan.
|
||||||
export const rescanLibrary = () => invoke("rescan_library");
|
export const rescanLibrary = () => invoke("rescan_library");
|
||||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
// 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) =>
|
export const importAudition = (username, rel, files, startIndex = 0) =>
|
||||||
invoke("import_audition", { username, rel, files, startIndex });
|
invoke("import_audition", { username, rel, files, startIndex });
|
||||||
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
|
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).
|
// Measured facts about one audio file (the version card's quality chips).
|
||||||
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
||||||
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
||||||
|
|||||||
Binary file not shown.
@@ -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
@@ -27,8 +27,36 @@
|
|||||||
*
|
*
|
||||||
* 1. Deezer try-list, MusicBrainz fallback.
|
* 1. Deezer try-list, MusicBrainz fallback.
|
||||||
* 2. + the artist's own catalogue, ranked by title similarity — finds records
|
* 2. + the artist's own catalogue, ranked by title similarity — finds records
|
||||||
* whose folder name no release carries ("1989 Pauls Boutique"). */
|
* whose folder name no release carries ("1989 Pauls Boutique").
|
||||||
export const IDENTIFY_ENGINE = 2;
|
* 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
|
/** 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. */
|
* value seen, which keeps the result stable for a re-render. */
|
||||||
|
|||||||
@@ -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
@@ -227,6 +227,8 @@ export const importStatus = $state({
|
|||||||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||||||
skipped: 0, // applied: records nothing was written for — the toast must
|
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
|
// 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
|
/** 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
|
// window is minimized. This is the authoritative shared activity gate for
|
||||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||||
windowActive: true,
|
windowActive: true,
|
||||||
|
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
|
||||||
|
lyricsFull: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function toggleNowPlaying() {
|
export function toggleNowPlaying() {
|
||||||
@@ -311,16 +315,49 @@ export const libraryStore = $state({
|
|||||||
|
|
||||||
export const playlistStore = $state({
|
export const playlistStore = $state({
|
||||||
playlists: [],
|
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() {
|
export async function refreshPlaylists() {
|
||||||
try {
|
try {
|
||||||
playlistStore.playlists = await api.listPlaylists();
|
playlistStore.playlists = await api.listPlaylists();
|
||||||
|
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||||||
} catch {
|
} catch {
|
||||||
// backend not available
|
// 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) ----
|
// ---- recently played (the sidebar's list) ----
|
||||||
// A play row is opened after ~1s of audio, so without a floor this list is a
|
// 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.
|
// 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.
|
// 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
|
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||||||
// complete, so they're save-only.
|
// 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
|
// User source-selection preferences, independent per mode. Loaded from
|
||||||
// settings at startup and refreshed by the Settings view after a commit;
|
// 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 ?? [];
|
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([
|
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({
|
export const downloads = $state({
|
||||||
// id -> download payload (latest update wins)
|
// id -> download payload (latest update wins)
|
||||||
items: {},
|
items: {},
|
||||||
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
sub: subLabel,
|
sub: subLabel,
|
||||||
artist: t.artist ?? null,
|
artist: t.artist ?? null,
|
||||||
|
album: t.album ?? null,
|
||||||
duration: t.duration_secs ?? null,
|
duration: t.duration_secs ?? null,
|
||||||
art: t.art_path ?? artPath,
|
art: t.art_path ?? artPath,
|
||||||
quality: localQuality(t),
|
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 };
|
player.catalogRef = { albumId: null, trackId: null };
|
||||||
playQueueIndex(start);
|
playQueueIndex(start);
|
||||||
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
|
|||||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||||
catalogGo, refreshLibrary, refreshRecents,
|
catalogGo, refreshLibrary, refreshRecents,
|
||||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||||
|
playlistStore, refreshPlaylists, goToView,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+162
-29
@@ -22,6 +22,7 @@
|
|||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
|
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import {
|
import {
|
||||||
libraryStore,
|
libraryStore,
|
||||||
@@ -94,14 +95,32 @@
|
|||||||
// the same join the artist page's own "in your library" ticks already
|
// the same join the artist page's own "in your library" ticks already
|
||||||
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
|
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
|
||||||
let followsByName = $state({}); // normKey(name) -> { artistId, name }
|
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";
|
const GAPS_KEY = "library.gaps.v1";
|
||||||
let gapTotals = $state((() => {
|
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 gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
|
||||||
let gapOpen = $state({}); // artist name -> line expanded
|
let gapOpen = $state({}); // foldKey(artist) -> line expanded
|
||||||
let gapLoading = $state({});
|
let gapLoading = $state({});
|
||||||
|
|
||||||
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
@@ -138,14 +157,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleGaps(artistName) {
|
async function toggleGaps(artistName) {
|
||||||
if (gapOpen[artistName]) {
|
// Keyed by the FOLD, not by the shelf's title: the total was the one
|
||||||
gapOpen = { ...gapOpen, [artistName]: false };
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
const follow = followFor(artistName);
|
const follow = followFor(artistName);
|
||||||
if (!follow) return;
|
if (!follow) return;
|
||||||
if (!gapRows[artistName] && !gapLoading[artistName]) {
|
if (!gapRows[key] && !gapLoading[key]) {
|
||||||
gapLoading = { ...gapLoading, [artistName]: true };
|
gapLoading = { ...gapLoading, [key]: true };
|
||||||
try {
|
try {
|
||||||
const page = await getCatalogArtistPage(follow.artistId);
|
const page = await getCatalogArtistPage(follow.artistId);
|
||||||
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
|
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
|
||||||
@@ -156,15 +179,15 @@
|
|||||||
);
|
);
|
||||||
gapRows = {
|
gapRows = {
|
||||||
...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 */ }
|
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
|
||||||
} catch { /* the line stays collapsed; nothing is invented */ }
|
} catch { /* the line stays collapsed; nothing is invented */ }
|
||||||
gapLoading = { ...gapLoading, [artistName]: false };
|
gapLoading = { ...gapLoading, [key]: false };
|
||||||
}
|
}
|
||||||
if (gapRows[artistName]?.length || gapLoading[artistName] === false) {
|
if (gapRows[key]?.length || gapLoading[key] === false) {
|
||||||
gapOpen = { ...gapOpen, [artistName]: true };
|
gapOpen = { ...gapOpen, [key]: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +332,10 @@
|
|||||||
|
|
||||||
// ---- groupings --------------------------------------------------------
|
// ---- groupings --------------------------------------------------------
|
||||||
const GROUPS = {
|
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") },
|
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
|
||||||
format: {
|
format: {
|
||||||
label: "Format",
|
label: "Format",
|
||||||
@@ -360,12 +386,37 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function completeness(artist) {
|
function completeness(artist) {
|
||||||
const total = gapTotals[artist]?.total || 0;
|
const total = gapTotals[foldKey(artist)]?.total || 0;
|
||||||
if (!total) return 1;
|
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);
|
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) {
|
function sortList(list) {
|
||||||
const out = [...list].sort(SORTS[sortKey].cmp);
|
const out = [...list].sort(SORTS[sortKey].cmp);
|
||||||
return desc ? out : out.reverse();
|
return desc ? out : out.reverse();
|
||||||
@@ -376,13 +427,25 @@
|
|||||||
if (group === "none") return [["", sortList(corpus)]];
|
if (group === "none") return [["", sortList(corpus)]];
|
||||||
const by = new Map();
|
const by = new Map();
|
||||||
for (const a of corpus) {
|
for (const a of corpus) {
|
||||||
const k = g.of(a);
|
const k = (g.keyOf ?? g.of)(a);
|
||||||
if (!by.has(k)) by.set(k, []);
|
if (!by.has(k)) by.set(k, []);
|
||||||
by.get(k).push(a);
|
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
|
// Added is a DATE grouping: newest month first, by the month the key
|
||||||
// names — a string sort put April 2025 above August 2024.
|
// 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)
|
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
|
||||||
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
|
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
|
||||||
: group === "artist"
|
: group === "artist"
|
||||||
@@ -394,7 +457,7 @@
|
|||||||
if (group === "artist" && sortKey === "complete") {
|
if (group === "artist" && sortKey === "complete") {
|
||||||
keys.sort((x, y) => completeness(x) - completeness(y));
|
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
|
// Albums, EPs and singles under their own headings INSIDE an artist — the
|
||||||
@@ -414,7 +477,7 @@
|
|||||||
// ---- the derived stat row --------------------------------------------
|
// ---- the derived stat row --------------------------------------------
|
||||||
const totals = $derived.by(() => ({
|
const totals = $derived.by(() => ({
|
||||||
albums: corpus.length,
|
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),
|
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
|
||||||
secs: corpus.reduce((n, a) => n + a.secs, 0),
|
secs: corpus.reduce((n, a) => n + a.secs, 0),
|
||||||
bytes: corpus.reduce((n, a) => n + a.bytes, 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);
|
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
|
||||||
} catch { /* backend unavailable */ }
|
} 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) =>
|
const isPlayingTrack = (t) =>
|
||||||
player.track?.source === "local" && player.track?.path === t.path;
|
player.track?.source === "local" && player.track?.path === t.path;
|
||||||
function folderOf(tracks) {
|
function folderOf(tracks) {
|
||||||
@@ -1571,6 +1653,22 @@
|
|||||||
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
|
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
|
||||||
<Icon name="play" size={12} /> Play
|
<Icon name="play" size={12} /> Play
|
||||||
</button>
|
</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
|
<button
|
||||||
class="pill ghost"
|
class="pill ghost"
|
||||||
title="Show in folder"
|
title="Show in folder"
|
||||||
@@ -1622,7 +1720,11 @@
|
|||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="trow"
|
class="trow"
|
||||||
class:now={isPlayingTrack(t)}
|
class:now={isPlayingTrack(t)}
|
||||||
@@ -1653,6 +1755,7 @@
|
|||||||
><Icon name="folder-open" size={13} /></button>
|
><Icon name="folder-open" size={13} /></button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="tracks-loading" role="status">
|
<div class="tracks-loading" role="status">
|
||||||
<div class="tl-head">No tracks found for this record</div>
|
<div class="tl-head">No tracks found for this record</div>
|
||||||
@@ -1680,12 +1783,13 @@
|
|||||||
{#snippet gapSection(name, list)}
|
{#snippet gapSection(name, list)}
|
||||||
{@const follow = followFor(name)}
|
{@const follow = followFor(name)}
|
||||||
{#if follow}
|
{#if follow}
|
||||||
{@const missing = gapRows[name]}
|
{@const key = foldKey(name)}
|
||||||
{@const total = gapTotals[name]?.total}
|
{@const missing = gapRows[key]}
|
||||||
|
{@const total = gapTotals[key]?.total}
|
||||||
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
|
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
|
||||||
{#if count !== 0}
|
{#if count !== 0}
|
||||||
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
|
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
|
||||||
{#if gapLoading[name]}
|
{#if gapLoading[key]}
|
||||||
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
|
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
|
||||||
{:else if count != null}
|
{:else if count != null}
|
||||||
<b>{count}</b> more released
|
<b>{count}</b> more released
|
||||||
@@ -1693,12 +1797,12 @@
|
|||||||
See what's missing
|
See what's missing
|
||||||
{/if}
|
{/if}
|
||||||
<span class="lib-gapline-cta">
|
<span class="lib-gapline-cta">
|
||||||
{gapOpen[name] ? "hide" : "show"}
|
{gapOpen[key] ? "hide" : "show"}
|
||||||
<Icon name={gapOpen[name] ? "chevron-down" : "chevron-right"} size={12} />
|
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if gapOpen[name] && missing?.length}
|
{#if gapOpen[key] && missing?.length}
|
||||||
<!-- Full-size cards, dimmed — the same card as everything else on
|
<!-- Full-size cards, dimmed — the same card as everything else on
|
||||||
the page, readable, full strength on hover (handoff §4d). -->
|
the page, readable, full strength on hover (handoff §4d). -->
|
||||||
<div class="lib-gaps" role="list">
|
<div class="lib-gaps" role="list">
|
||||||
@@ -1772,7 +1876,7 @@
|
|||||||
|
|
||||||
{#snippet shelfHead(name, list)}
|
{#snippet shelfHead(name, list)}
|
||||||
{@const follow = followFor(name)}
|
{@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)}
|
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
|
||||||
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
|
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
|
||||||
click resolves one first (openShelfArtist), so an unfollowed shelf is
|
click resolves one first (openShelfArtist), so an unfollowed shelf is
|
||||||
@@ -3236,6 +3340,15 @@
|
|||||||
.pill:hover { background: var(--accent-hover); }
|
.pill:hover { background: var(--accent-hover); }
|
||||||
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
|
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
|
||||||
.pill.ghost:hover { background: var(--bg-press, #333); }
|
.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.grow { flex: 1; }
|
||||||
.pill:disabled {
|
.pill:disabled {
|
||||||
background: var(--bg-highlight);
|
background: var(--bg-highlight);
|
||||||
@@ -3261,6 +3374,26 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.tdur-head { display: flex; justify-content: flex-end; }
|
.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 {
|
.trow {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+1254
-94
File diff suppressed because it is too large.
Load diff
+584
-55
@@ -1,132 +1,661 @@
|
|||||||
<script>
|
<script>
|
||||||
// The sidebar's Playlists row needs a destination. Playlists used to be
|
// The playlist shelf: every playlist as a cover card — custom art or the
|
||||||
// listed in the sidebar itself; with the sidebar showing listening history
|
// composed four-cover mosaic — with pinning, duplication, export, and the
|
||||||
// instead, this is where they live.
|
// two import doors (a playlist file, a pasted tracklist).
|
||||||
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
|
import {
|
||||||
|
playlistStore,
|
||||||
|
refreshPlaylists,
|
||||||
|
playlistCoverFor,
|
||||||
|
goToView,
|
||||||
|
playAlbum,
|
||||||
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import Icon from "./../components/Icon.svelte";
|
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() {
|
async function newPlaylist() {
|
||||||
|
if (creating) return;
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||||
const id = await api.createPlaylist(name);
|
const id = await api.createPlaylist(name);
|
||||||
await refreshPlaylists();
|
await refreshPlaylists();
|
||||||
goToView("playlist", { playlistId: id, label: name });
|
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) {
|
function open(pl) {
|
||||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={pageClick} />
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header>
|
<header>
|
||||||
|
<div class="head-text">
|
||||||
<h1>Playlists</h1>
|
<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
|
<Icon name="plus" size={15} /> New playlist
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if playlistStore.playlists.length === 0}
|
{#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}
|
{:else}
|
||||||
<ul>
|
{#if pinned.length}
|
||||||
{#each playlistStore.playlists as pl (pl.id)}
|
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
|
||||||
<li>
|
<div class="grid">
|
||||||
<button class="row" onclick={() => open(pl)}>
|
{#each pinned as pl (pl.id)}
|
||||||
<span class="ic"><Icon name="playlist" size={18} /></span>
|
{@render card(pl)}
|
||||||
<span class="name">{pl.name}</span>
|
|
||||||
<span class="count">
|
|
||||||
{pl.track_count}
|
|
||||||
{pl.track_count === 1 ? "track" : "tracks"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/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}
|
{/if}
|
||||||
</div>
|
</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} · {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>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: 8px 4px 24px;
|
padding: 8px 4px 24px;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.new {
|
.sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 8px 14px;
|
padding: 9px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-highlight);
|
|
||||||
transition:
|
transition:
|
||||||
background 0.14s,
|
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);
|
background: var(--bg-press);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
ul {
|
.btn:disabled {
|
||||||
list-style: none;
|
opacity: 0.55;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 13px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 12px;
|
padding: 9px 10px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
text-align: left;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
transition: background 0.14s;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.row:hover {
|
.mi:hover:not(:disabled) {
|
||||||
background: var(--bg-highlight);
|
background: var(--bg-highlight);
|
||||||
color: var(--text-primary);
|
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;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 40px;
|
width: 34px;
|
||||||
height: 40px;
|
height: 34px;
|
||||||
border-radius: 5px;
|
border-radius: 50%;
|
||||||
background: var(--bg-highlight);
|
background: var(--accent);
|
||||||
color: var(--text-muted);
|
color: #000;
|
||||||
flex: none;
|
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 {
|
.hoverplay:hover {
|
||||||
flex: 1;
|
background: var(--accent-hover);
|
||||||
min-width: 0;
|
}
|
||||||
font-size: 14px;
|
.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-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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.count {
|
.badges {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: var(--text-muted);
|
gap: 5px;
|
||||||
flex: none;
|
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 {
|
.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);
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty-sub {
|
||||||
|
margin-top: 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 380px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user