dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9068
-427
No files matched your search
Generated
+60
@@ -165,6 +165,8 @@ version = "0.1.0-alpha.4"
|
||||
dependencies = [
|
||||
"audio-formats",
|
||||
"base64 0.22.1",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"keyring",
|
||||
"library",
|
||||
@@ -172,6 +174,7 @@ dependencies = [
|
||||
"metadata",
|
||||
"player",
|
||||
"quick-xml 0.37.5",
|
||||
"realfft",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2579,6 +2582,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -3181,6 +3193,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -3376,6 +3397,15 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "realfft"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -3653,6 +3683,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -4150,6 +4194,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -5209,6 +5259,16 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.1"
|
||||
|
||||
+116
-20
@@ -93,11 +93,12 @@ pub struct PlaylistItem {
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration_secs: Option<u32>,
|
||||
/// The playable local file, when resolution found one. An unresolved
|
||||
/// import row keeps its metadata and order with no path at all.
|
||||
/// The playable local file, when resolution found one. A stream or
|
||||
/// unresolved row keeps its metadata and order with no path at all.
|
||||
pub path: Option<String>,
|
||||
pub art_path: Option<String>,
|
||||
/// "local" (has a playable path) | "unresolved" (import placeholder).
|
||||
/// "local" (a file on disk) | "stream" (full identity, plays through the
|
||||
/// resolver) | "unresolved" (import placeholder awaiting repair).
|
||||
pub kind: String,
|
||||
/// Sanitized provenance: the raw locator or pasted line this row came
|
||||
/// from. Repair tools read it; nothing ever executes it.
|
||||
@@ -106,6 +107,11 @@ pub struct PlaylistItem {
|
||||
/// begins, milliseconds.
|
||||
pub start_ms: Option<i64>,
|
||||
pub note: Option<String>,
|
||||
/// A stream row's identity anchor: the catalog track id (exact id, never
|
||||
/// a name match — the same idiom as plays.catalog_album_id).
|
||||
pub track_id: Option<String>,
|
||||
/// A stream row's cover, which has no local file to be read from.
|
||||
pub art_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`).
|
||||
@@ -117,11 +123,34 @@ pub struct NewPlaylistItem {
|
||||
pub duration_secs: Option<u32>,
|
||||
pub path: Option<String>,
|
||||
pub art_path: Option<String>,
|
||||
/// "local" | "unresolved"; empty defaults to "local" when a path exists.
|
||||
/// "local" | "stream" | "unresolved"; empty lets the insert classify:
|
||||
/// a path is local, a full identity streams, anything weaker is a
|
||||
/// placeholder (see `pathless_kind`).
|
||||
pub kind: String,
|
||||
pub source_line: Option<String>,
|
||||
pub start_ms: Option<i64>,
|
||||
pub note: Option<String>,
|
||||
pub track_id: Option<String>,
|
||||
pub art_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Classify a row that names no local file. A full identity — an artist and
|
||||
/// a real title, never the DJ unknown-token "ID" — plays through the normal
|
||||
/// resolver exactly like a catalog track, so it is a first-class 'stream'
|
||||
/// row. Anything weaker stays an 'unresolved' placeholder for the repair
|
||||
/// tools. (The same gate the playlist page always used to decide whether a
|
||||
/// click streams or opens repair.)
|
||||
fn pathless_kind(artist: &str, title: &str) -> &'static str {
|
||||
let t = title.trim();
|
||||
if !artist.trim().is_empty()
|
||||
&& !t.is_empty()
|
||||
&& !t.eq_ignore_ascii_case("id")
|
||||
&& !t.eq_ignore_ascii_case("i.d.")
|
||||
{
|
||||
"stream"
|
||||
} else {
|
||||
"unresolved"
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the whole-library matching index (playlist import resolution:
|
||||
@@ -292,7 +321,9 @@ impl Library {
|
||||
source_line TEXT,
|
||||
start_ms INTEGER,
|
||||
note TEXT,
|
||||
added_at INTEGER NOT NULL DEFAULT 0
|
||||
added_at INTEGER NOT NULL DEFAULT 0,
|
||||
track_id TEXT,
|
||||
art_url TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_items
|
||||
ON playlist_items(playlist_id, position);
|
||||
@@ -392,6 +423,22 @@ impl Library {
|
||||
"ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
// Playlist rows split into local vs stream (2026-08-18): a row with a
|
||||
// full identity and no file is a STREAMING track, not an error state.
|
||||
// Its anchor is the catalog track id (exact id, never a name match —
|
||||
// the plays.catalog_album_id idiom) with the cover URL riding along,
|
||||
// since a stream row has no local file to read artwork from.
|
||||
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN track_id TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN art_url TEXT", []);
|
||||
// Reclassify rows stored before the split: anything 'unresolved' that
|
||||
// carries an artist and a real title already streamed on click — the
|
||||
// kind now says so. Idempotent, so it simply runs on every open.
|
||||
let _ = conn.execute(
|
||||
"UPDATE playlist_items SET kind = 'stream'
|
||||
WHERE kind = 'unresolved' AND TRIM(artist) <> '' AND TRIM(title) <> ''
|
||||
AND LOWER(TRIM(title)) NOT IN ('id', 'i.d.')",
|
||||
[],
|
||||
);
|
||||
migrate_playlist_rows(&mut conn)?;
|
||||
Ok(Library {
|
||||
conn: Mutex::new(conn),
|
||||
@@ -1126,7 +1173,7 @@ impl Library {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, position, title, artist, album, duration_secs, path, art_path,
|
||||
kind, source_line, start_ms, note
|
||||
kind, source_line, start_ms, note, track_id, art_url
|
||||
FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
@@ -1143,6 +1190,8 @@ impl Library {
|
||||
source_line: r.get(9)?,
|
||||
start_ms: r.get(10)?,
|
||||
note: r.get(11)?,
|
||||
track_id: r.get(12)?,
|
||||
art_url: r.get(13)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
|
||||
@@ -1198,12 +1247,17 @@ impl Library {
|
||||
let mut ins = tx.prepare(
|
||||
"INSERT INTO playlist_items
|
||||
(playlist_id, position, title, artist, album, duration_secs,
|
||||
path, art_path, kind, source_line, start_ms, note, added_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
path, art_path, kind, source_line, start_ms, note, added_at,
|
||||
track_id, art_url)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
)?;
|
||||
for (offset, item) in items.iter().enumerate() {
|
||||
let kind = if item.kind.is_empty() {
|
||||
if item.path.is_some() { "local" } else { "unresolved" }
|
||||
if item.path.is_some() {
|
||||
"local"
|
||||
} else {
|
||||
pathless_kind(&item.artist, &item.title)
|
||||
}
|
||||
} else {
|
||||
&item.kind
|
||||
};
|
||||
@@ -1221,6 +1275,8 @@ impl Library {
|
||||
item.start_ms,
|
||||
item.note,
|
||||
now,
|
||||
item.track_id,
|
||||
item.art_url,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -1286,7 +1342,8 @@ impl Library {
|
||||
}
|
||||
|
||||
/// Repair one row: attach a playable local file (or correct its display
|
||||
/// metadata). Passing `path` Some flips the row to "local".
|
||||
/// metadata). Passing `path` Some flips the row to "local". `track_id`
|
||||
/// and `art_url` enrich a stream row's catalog anchor and cover.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn relink_playlist_item(
|
||||
&self,
|
||||
@@ -1298,6 +1355,8 @@ impl Library {
|
||||
duration_secs: Option<u32>,
|
||||
path: Option<&str>,
|
||||
art_path: Option<&str>,
|
||||
track_id: Option<&str>,
|
||||
art_url: Option<&str>,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
@@ -1310,9 +1369,11 @@ impl Library {
|
||||
duration_secs = COALESCE(?6, duration_secs),
|
||||
path = COALESCE(?7, path),
|
||||
art_path = COALESCE(?8, art_path),
|
||||
track_id = COALESCE(?9, track_id),
|
||||
art_url = COALESCE(?10, art_url),
|
||||
kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END
|
||||
WHERE playlist_id = ?1 AND id = ?2",
|
||||
rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path],
|
||||
rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path, track_id, art_url],
|
||||
)?;
|
||||
Self::touch_playlist(&tx, playlist_id, now)?;
|
||||
tx.commit()?;
|
||||
@@ -1337,9 +1398,11 @@ impl Library {
|
||||
tx.execute(
|
||||
"INSERT INTO playlist_items
|
||||
(playlist_id, position, title, artist, album, duration_secs,
|
||||
path, art_path, kind, source_line, start_ms, note, added_at)
|
||||
path, art_path, kind, source_line, start_ms, note, added_at,
|
||||
track_id, art_url)
|
||||
SELECT ?2, position, title, artist, album, duration_secs,
|
||||
path, art_path, kind, source_line, start_ms, note, ?3
|
||||
path, art_path, kind, source_line, start_ms, note, ?3,
|
||||
track_id, art_url
|
||||
FROM playlist_items WHERE playlist_id = ?1",
|
||||
rusqlite::params![id, new_id, now],
|
||||
)?;
|
||||
@@ -1369,12 +1432,17 @@ impl Library {
|
||||
let mut ins = tx.prepare(
|
||||
"INSERT INTO playlist_items
|
||||
(playlist_id, position, title, artist, album, duration_secs,
|
||||
path, art_path, kind, source_line, start_ms, note, added_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
path, art_path, kind, source_line, start_ms, note, added_at,
|
||||
track_id, art_url)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
)?;
|
||||
for (position, item) in items.iter().enumerate() {
|
||||
let kind = if item.kind.is_empty() {
|
||||
if item.path.is_some() { "local" } else { "unresolved" }
|
||||
if item.path.is_some() {
|
||||
"local"
|
||||
} else {
|
||||
pathless_kind(&item.artist, &item.title)
|
||||
}
|
||||
} else {
|
||||
&item.kind
|
||||
};
|
||||
@@ -1392,6 +1460,8 @@ impl Library {
|
||||
item.start_ms,
|
||||
item.note,
|
||||
now,
|
||||
item.track_id,
|
||||
item.art_url,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -2261,7 +2331,9 @@ mod tests {
|
||||
art_path: art.map(String::from),
|
||||
..Default::default()
|
||||
};
|
||||
// Two copies of the same file, on purpose, plus an unresolved import row.
|
||||
// Two copies of the same file, on purpose, plus a pathless row with a
|
||||
// full identity (a streaming track, first-class) and a genuinely
|
||||
// unknown one (the DJ token "ID" stays a placeholder for repair).
|
||||
lib.add_playlist_items(
|
||||
id,
|
||||
&[
|
||||
@@ -2274,10 +2346,31 @@ mod tests {
|
||||
110,
|
||||
)
|
||||
.unwrap();
|
||||
lib.add_playlist_items(
|
||||
id,
|
||||
&[NewPlaylistItem {
|
||||
title: "ID".into(),
|
||||
artist: "Artist".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
None,
|
||||
111,
|
||||
)
|
||||
.unwrap();
|
||||
let items = lib.playlist_items(id).unwrap();
|
||||
assert_eq!(items.len(), 5);
|
||||
assert_eq!(
|
||||
items[3].kind, "stream",
|
||||
"a pathless row with full identity streams — never dropped, never an error"
|
||||
);
|
||||
assert_eq!(
|
||||
items[4].kind, "unresolved",
|
||||
"the DJ unknown-token stays a placeholder for repair"
|
||||
);
|
||||
lib.remove_playlist_items(id, &[items[4].id], 112).unwrap();
|
||||
|
||||
let items = lib.playlist_items(id).unwrap();
|
||||
assert_eq!(items.len(), 4);
|
||||
assert_eq!(items[3].kind, "unresolved", "no path means placeholder, never dropped");
|
||||
|
||||
// Reorder: move the SECOND "One" first; the two copies stay distinct.
|
||||
let ids: Vec<i64> = items.iter().map(|i| i.id).collect();
|
||||
@@ -2308,9 +2401,10 @@ mod tests {
|
||||
assert_eq!(items.len(), 4);
|
||||
|
||||
// Aggregates: unresolved count, duration sum, distinct art seeds.
|
||||
// "Ghost" is a stream row (full identity), so nothing is unresolved.
|
||||
let meta = lib.playlist_meta(id).unwrap().unwrap();
|
||||
assert_eq!(meta.track_count, 4);
|
||||
assert_eq!(meta.unresolved_count, 1);
|
||||
assert_eq!(meta.unresolved_count, 0);
|
||||
assert_eq!(meta.duration_secs, 400, "all four rows carry 100s");
|
||||
assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]);
|
||||
assert!(meta.revision > 0);
|
||||
@@ -2349,7 +2443,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let rows = lib.playlist_items(imported).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].kind, "unresolved");
|
||||
assert_eq!(rows[0].kind, "stream", "full identity, no file: streams");
|
||||
assert_eq!(rows[1].kind, "local");
|
||||
assert_eq!(rows[1].start_ms, Some(3_600_000));
|
||||
let meta = lib.playlist_meta(imported).unwrap().unwrap();
|
||||
@@ -2365,6 +2459,8 @@ mod tests {
|
||||
Some(240),
|
||||
Some("C:\\m\\opener.mp3"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
170,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -4500,6 +4500,27 @@ impl MetadataClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// One catalog track by ISRC — the anchor-exact lookup for rows whose
|
||||
/// identity came from AUDIO RECOGNITION (the recognizer reports the
|
||||
/// matched release's ISRC). An id-grade identity, never a name guess;
|
||||
/// `None` is an honest "that release isn't in the catalog".
|
||||
pub async fn catalog_track_by_isrc(&self, isrc: &str) -> Option<CatalogTrack> {
|
||||
let clean: String = isrc
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect();
|
||||
// ISRCs are exactly 12 characters (CC-XXX-YY-NNNNN without the
|
||||
// dashes); anything else would only ever be a malformed URL.
|
||||
if clean.len() != 12 {
|
||||
return None;
|
||||
}
|
||||
let body = self
|
||||
.deezer(&format!("https://api.deezer.com/track/isrc:{clean}"))
|
||||
.await?;
|
||||
parse_track(&body)
|
||||
}
|
||||
|
||||
/// The two things the search hero can say about an artist that Deezer
|
||||
/// cannot: what they sound like, and how big they are.
|
||||
///
|
||||
|
||||
@@ -13,7 +13,7 @@ mod waveform;
|
||||
|
||||
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
|
||||
pub use source::{GrowingFile, StreamProgress, Terminal};
|
||||
pub use waveform::compute_waveform;
|
||||
pub use waveform::{compute_waveform, compute_waveform_range};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -115,6 +115,138 @@ fn decode(path: &Path, limit_secs: Option<f64>) -> Result<Decoded, String> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- streaming long-form decode ---------------------------------------------
|
||||
|
||||
/// Streaming 16 kHz mono decode for LONG sources (DJ sets, hour-plus mixes):
|
||||
/// packets are downmixed and resampled as they arrive, so memory holds only
|
||||
/// the 16 kHz mono result (~230 MB per hour as f32) and never the source-rate
|
||||
/// PCM. The whole-file `decode` above deliberately caps at 20 minutes; this
|
||||
/// is the path past that cap, bounded by `max_secs` of OUTPUT.
|
||||
pub fn mono_pcm_16k_streaming(path: &Path, max_secs: f64) -> Result<Vec<f32>, 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 out: Vec<f32> = Vec::new();
|
||||
let cap = (max_secs * 16_000.0) as usize;
|
||||
let mut resampler: Option<StreamResampler> = None;
|
||||
let mut buffer: Option<SampleBuffer<f32>> = None;
|
||||
loop {
|
||||
if out.len() >= cap {
|
||||
break;
|
||||
}
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
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();
|
||||
let channels = spec.channels.count().max(1);
|
||||
let buf = buffer.get_or_insert_with(|| {
|
||||
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec)
|
||||
});
|
||||
if buf.capacity() < decoded.capacity() * channels {
|
||||
*buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
}
|
||||
buf.copy_interleaved_ref(decoded);
|
||||
let rs = resampler.get_or_insert_with(|| StreamResampler::new(spec.rate, 16_000));
|
||||
rs.push_interleaved(buf.samples(), channels, &mut out);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("nothing decodable".into());
|
||||
}
|
||||
out.truncate(cap);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Linear resampler with carry across pushes, so packet boundaries do not
|
||||
/// click or drift: keeps the previous source sample and a fractional read
|
||||
/// position between calls.
|
||||
struct StreamResampler {
|
||||
ratio: f64,
|
||||
/// Fractional position into the CURRENT push, may start negative into
|
||||
/// the carried sample.
|
||||
pos: f64,
|
||||
carry: f32,
|
||||
has_carry: bool,
|
||||
passthrough: bool,
|
||||
}
|
||||
|
||||
impl StreamResampler {
|
||||
fn new(src_rate: u32, dst_rate: u32) -> Self {
|
||||
StreamResampler {
|
||||
ratio: src_rate as f64 / dst_rate as f64,
|
||||
pos: 0.0,
|
||||
carry: 0.0,
|
||||
has_carry: false,
|
||||
passthrough: src_rate == dst_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmix `interleaved` to mono and append the resampled result to
|
||||
/// `out`.
|
||||
fn push_interleaved(&mut self, interleaved: &[f32], channels: usize, out: &mut Vec<f32>) {
|
||||
let frames = interleaved.len() / channels.max(1);
|
||||
if frames == 0 {
|
||||
return;
|
||||
}
|
||||
let mono = |i: usize| -> f32 {
|
||||
let row = &interleaved[i * channels..(i + 1) * channels];
|
||||
row.iter().sum::<f32>() / channels as f32
|
||||
};
|
||||
if self.passthrough {
|
||||
out.extend((0..frames).map(mono));
|
||||
return;
|
||||
}
|
||||
// Source timeline for this push: sample -1 is the carried one.
|
||||
loop {
|
||||
let i0 = self.pos.floor();
|
||||
let frac = (self.pos - i0) as f32;
|
||||
let i0 = i0 as isize;
|
||||
if i0 + 1 >= frames as isize {
|
||||
break;
|
||||
}
|
||||
let a = if i0 < 0 {
|
||||
if self.has_carry { self.carry } else { mono(0) }
|
||||
} else {
|
||||
mono(i0 as usize)
|
||||
};
|
||||
let b = mono((i0 + 1) as usize);
|
||||
out.push(a + (b - a) * frac);
|
||||
self.pos += self.ratio;
|
||||
}
|
||||
self.carry = mono(frames - 1);
|
||||
self.has_carry = true;
|
||||
self.pos -= frames as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- leading silence -------------------------------------------------------
|
||||
|
||||
/// Milliseconds of leading silence in `path`, or None when the file cannot be
|
||||
@@ -216,6 +348,216 @@ pub fn vocal_activity(path: &Path, frame_ms: u32) -> Result<VocalActivity, Strin
|
||||
Ok(VocalActivity { frame_ms, frames })
|
||||
}
|
||||
|
||||
// ---- mono PCM for the CTC aligner ------------------------------------------
|
||||
|
||||
/// Whole-file VOCAL-FORWARD mono PCM at 16 kHz for the CTC aligner: the mid
|
||||
/// (center) channel through the same 180 Hz – 7.5 kHz band the reducer and
|
||||
/// the activity envelope use, linearly resampled. Cutting bass/kick
|
||||
/// fundamentals and cymbal air before the model's per-window normalization
|
||||
/// leaves the voice owning more of the input's dynamic range — cheap
|
||||
/// pre-emphasis, NOT separation (center-mixed instruments remain). The
|
||||
/// low-pass doubles as a crude anti-alias guard for the 16 kHz resample.
|
||||
pub fn vocal_pcm_16k(path: &Path) -> Result<Vec<f32>, String> {
|
||||
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
|
||||
let ch = decoded.channels.max(1);
|
||||
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 mono: Vec<f32> = decoded
|
||||
.samples
|
||||
.chunks(ch)
|
||||
.map(|f| high.process(low.process(f.iter().sum::<f32>() / ch as f32)))
|
||||
.collect();
|
||||
let src = decoded.sample_rate as f64;
|
||||
if (src - 16_000.0).abs() < 1.0 {
|
||||
return Ok(mono);
|
||||
}
|
||||
let ratio = 16_000.0 / src;
|
||||
let out_len = (mono.len() as f64 * ratio) as usize;
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for i in 0..out_len {
|
||||
let pos = i as f64 / ratio;
|
||||
let i0 = pos as usize;
|
||||
let frac = (pos - i0 as f64) as f32;
|
||||
let a = mono.get(i0).copied().unwrap_or(0.0);
|
||||
let b = mono.get(i0 + 1).copied().unwrap_or(a);
|
||||
out.push(a + (b - a) * frac);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stereo 44.1 kHz planar PCM for the learned vocal separator: any layout
|
||||
/// folds to two channels (mono duplicates its one, extra channels beyond the
|
||||
/// front pair are ignored), any rate linearly resampled. Linear interpolation
|
||||
/// is deliberate — the stem feeds ALIGNMENT, not ears, the same tradeoff
|
||||
/// `vocal_pcm_16k` already makes for its own input.
|
||||
pub fn separation_input_44k1(path: &Path) -> Result<[Vec<f32>; 2], String> {
|
||||
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
|
||||
let ch = decoded.channels.max(1);
|
||||
let frames = decoded.samples.len() / ch;
|
||||
let mut left = Vec::with_capacity(frames);
|
||||
let mut right = Vec::with_capacity(frames);
|
||||
for f in decoded.samples.chunks(ch) {
|
||||
let l = f[0];
|
||||
left.push(l);
|
||||
right.push(if f.len() > 1 { f[1] } else { l });
|
||||
}
|
||||
Ok([
|
||||
resample_linear(left, decoded.sample_rate as f64, 44_100.0),
|
||||
resample_linear(right, decoded.sample_rate as f64, 44_100.0),
|
||||
])
|
||||
}
|
||||
|
||||
/// Aligner input from a separated vocal stem (44.1 kHz planar stereo): mid
|
||||
/// fold, a 60 Hz rumble high-pass, the same 7.5 kHz low-pass `vocal_pcm_16k`
|
||||
/// uses as its 16 kHz anti-alias guard, then the resample. No 180 Hz floor
|
||||
/// here — that floor exists to fight INSTRUMENTS sharing the band, and a
|
||||
/// separated stem has (mostly) none left to fight.
|
||||
pub fn stem_pcm_16k(left: &[f32], right: &[f32]) -> Vec<f32> {
|
||||
let mut rumble = Biquad::highpass(44_100.0, 60.0);
|
||||
let mut guard = Biquad::lowpass(44_100.0, 7_500.0);
|
||||
let n = left.len().min(right.len());
|
||||
let mono: Vec<f32> = (0..n)
|
||||
.map(|i| guard.process(rumble.process((left[i] + right[i]) * 0.5)))
|
||||
.collect();
|
||||
resample_linear(mono, 44_100.0, 16_000.0)
|
||||
}
|
||||
|
||||
/// Linear resampler shared by the separator inputs (see the tradeoff note on
|
||||
/// `separation_input_44k1`). Within 1 Hz of the target it hands the buffer
|
||||
/// back untouched — whole-track copies are not free at 44.1 kHz.
|
||||
fn resample_linear(src_pcm: Vec<f32>, src: f64, dst: f64) -> Vec<f32> {
|
||||
if (src - dst).abs() < 1.0 {
|
||||
return src_pcm;
|
||||
}
|
||||
let ratio = dst / src;
|
||||
let out_len = (src_pcm.len() as f64 * ratio) as usize;
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for i in 0..out_len {
|
||||
let pos = i as f64 / ratio;
|
||||
let i0 = pos as usize;
|
||||
let frac = (pos - i0 as f64) as f32;
|
||||
let a = src_pcm.get(i0).copied().unwrap_or(0.0);
|
||||
let b = src_pcm.get(i0 + 1).copied().unwrap_or(a);
|
||||
out.push(a + (b - a) * frac);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- vocal features (word-timing worker) -----------------------------------
|
||||
|
||||
/// Per-frame vocal evidence for the lyric word-timing worker: the band
|
||||
/// energy `vocal_activity` measures PLUS a voicing verdict — vocals are
|
||||
/// PITCHED, so an autocorrelation clarity check on a decimated mid channel
|
||||
/// separates sung frames from drums and noise that light the band up.
|
||||
pub struct VocalFrames {
|
||||
pub frame_ms: u32,
|
||||
/// 0..1 per frame, normalized like `vocal_activity`.
|
||||
pub energy: Vec<f32>,
|
||||
/// Frame carries periodic (pitched) content in the vocal F0 range.
|
||||
pub voiced: Vec<bool>,
|
||||
}
|
||||
|
||||
pub fn vocal_frames(path: &Path, frame_ms: u32) -> Result<VocalFrames, 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);
|
||||
|
||||
// Decimate the mid channel to ~8 kHz for the pitch check — full-rate
|
||||
// autocorrelation across a song is debug-build poison, and F0 up to
|
||||
// 500 Hz needs nothing like 44.1 kHz.
|
||||
let decim = (decoded.sample_rate as usize / 8_000).max(1);
|
||||
let target_rate = decoded.sample_rate as usize / decim;
|
||||
let mut energy = Vec::new();
|
||||
let mut voiced = Vec::new();
|
||||
let mut acc = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
let mut pitch_buf: Vec<f32> = Vec::with_capacity(frame_len / decim + 2);
|
||||
let mut decim_acc = 0.0f32;
|
||||
let mut decim_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;
|
||||
decim_acc += banded;
|
||||
decim_n += 1;
|
||||
if decim_n >= decim {
|
||||
pitch_buf.push(decim_acc / decim as f32);
|
||||
decim_acc = 0.0;
|
||||
decim_n = 0;
|
||||
}
|
||||
if n >= frame_len {
|
||||
energy.push((acc / n as f64).sqrt() as f32);
|
||||
voiced.push(frame_is_voiced(&pitch_buf, target_rate));
|
||||
pitch_buf.clear();
|
||||
acc = 0.0;
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
if n > frame_len / 4 {
|
||||
energy.push((acc / n.max(1) as f64).sqrt() as f32);
|
||||
voiced.push(frame_is_voiced(&pitch_buf, target_rate));
|
||||
}
|
||||
if energy.is_empty() {
|
||||
return Err("nothing decodable".into());
|
||||
}
|
||||
let mut sorted: Vec<f32> = energy.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 energy {
|
||||
*value = (*value / p95).min(1.0);
|
||||
}
|
||||
Ok(VocalFrames {
|
||||
frame_ms,
|
||||
energy,
|
||||
voiced,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalized-autocorrelation clarity over the sung F0 range (80–500 Hz).
|
||||
/// A frame is voiced when some lag in that range correlates strongly —
|
||||
/// periodic content — rather than merely being loud.
|
||||
fn frame_is_voiced(buf: &[f32], rate: usize) -> bool {
|
||||
if buf.len() < 64 || rate == 0 {
|
||||
return false;
|
||||
}
|
||||
let energy: f32 = buf.iter().map(|s| s * s).sum();
|
||||
if energy < 1e-7 {
|
||||
return false;
|
||||
}
|
||||
let lag_min = (rate / 500).max(2);
|
||||
let lag_max = (rate / 80).min(buf.len() / 2);
|
||||
if lag_min >= lag_max {
|
||||
return false;
|
||||
}
|
||||
let mut best = 0.0f32;
|
||||
let mut lag = lag_min;
|
||||
// Stride lags by 2: clarity peaks are broad; halving the work loses
|
||||
// nothing a boolean cares about.
|
||||
while lag < lag_max {
|
||||
let n = buf.len() - lag;
|
||||
let mut num = 0.0f32;
|
||||
let mut den_a = 0.0f32;
|
||||
let mut den_b = 0.0f32;
|
||||
for i in 0..n {
|
||||
num += buf[i] * buf[i + lag];
|
||||
den_a += buf[i] * buf[i];
|
||||
den_b += buf[i + lag] * buf[i + lag];
|
||||
}
|
||||
let den = (den_a * den_b).sqrt().max(1e-9);
|
||||
best = best.max(num / den);
|
||||
lag += 2;
|
||||
}
|
||||
best >= 0.55
|
||||
}
|
||||
|
||||
// ---- vocal reduction -------------------------------------------------------
|
||||
|
||||
/// What the renderer produced (for the caller's labels and sanity checks).
|
||||
@@ -407,6 +749,70 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// The streaming resampler must produce the same stream regardless of
|
||||
/// how the source is chopped into pushes — no clicks, no drift at
|
||||
/// packet boundaries.
|
||||
#[test]
|
||||
fn stream_resampler_is_chunking_invariant() {
|
||||
let src_rate = 44_100u32;
|
||||
let n = 44_100usize;
|
||||
let stereo: Vec<f32> = (0..n)
|
||||
.flat_map(|i| {
|
||||
let s = (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin();
|
||||
[s, s]
|
||||
})
|
||||
.collect();
|
||||
let mut one_shot = Vec::new();
|
||||
let mut a = StreamResampler::new(src_rate, 16_000);
|
||||
a.push_interleaved(&stereo, 2, &mut one_shot);
|
||||
let mut chunked = Vec::new();
|
||||
let mut b = StreamResampler::new(src_rate, 16_000);
|
||||
let mut at = 0usize;
|
||||
for (k, step) in [431usize, 977, 1153, 64, 2048].iter().cycle().enumerate() {
|
||||
if at >= stereo.len() {
|
||||
break;
|
||||
}
|
||||
let end = (at + step * 2).min(stereo.len());
|
||||
b.push_interleaved(&stereo[at..end], 2, &mut chunked);
|
||||
at = end;
|
||||
assert!(k < 200_000, "runaway");
|
||||
}
|
||||
assert!(
|
||||
(one_shot.len() as i64 - chunked.len() as i64).abs() <= 2,
|
||||
"length drift: {} vs {}",
|
||||
one_shot.len(),
|
||||
chunked.len()
|
||||
);
|
||||
let worst = one_shot
|
||||
.iter()
|
||||
.zip(&chunked)
|
||||
.map(|(x, y)| (x - y).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-4, "boundary artifacts, worst {worst}");
|
||||
}
|
||||
|
||||
/// The stem→aligner path: a vocal-band tone passes the 60 Hz / 7.5 kHz
|
||||
/// hygiene filters and lands at 16 kHz with its energy intact.
|
||||
#[test]
|
||||
fn stem_pcm_16k_resamples_and_keeps_vocal_band() {
|
||||
let frames = 44_100usize;
|
||||
let mono: Vec<f32> = (0..frames)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * 1_000.0 * i as f32 / 44_100.0).sin() * 0.5)
|
||||
.collect();
|
||||
let out = stem_pcm_16k(&mono, &mono);
|
||||
assert!(
|
||||
(15_998..=16_000).contains(&out.len()),
|
||||
"one second must land at ~16000 samples, got {}",
|
||||
out.len()
|
||||
);
|
||||
let level = rms(&out);
|
||||
assert!(
|
||||
level > 0.3 && level < 0.4,
|
||||
"1kHz mid tone must pass the stem filters near unity, rms {level}"
|
||||
);
|
||||
assert!(out.iter().all(|s| s.is_finite()));
|
||||
}
|
||||
|
||||
/// The whole point: a center-panned vocal-band tone dies, a center bass
|
||||
/// tone survives, side content survives.
|
||||
#[test]
|
||||
|
||||
@@ -10,10 +10,11 @@ use std::path::Path;
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::core::units::Time;
|
||||
|
||||
/// Peaks folded over this many frames before resampling to the final bucket
|
||||
/// count. Keeps memory bounded regardless of track length.
|
||||
@@ -31,6 +32,25 @@ pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`compute_waveform`], but for one SLICE of the file — the setlist
|
||||
/// segment rows' lane. `end_secs: None` runs to the file's end. The decode
|
||||
/// seeks coarsely and then trims by packet timestamps, so a 4-minute slice
|
||||
/// of a 90-minute set costs a 4-minute decode, not a 90-minute one.
|
||||
pub fn compute_waveform_range(
|
||||
path: &Path,
|
||||
buckets: usize,
|
||||
start_secs: f64,
|
||||
end_secs: Option<f64>,
|
||||
) -> Vec<f32> {
|
||||
match coarse_rms_range(path, start_secs, end_secs).map(|c| resample(&c, buckets)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::debug!("range waveform for {} failed: {e}", path.display());
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
|
||||
Ok(resample(&coarse_rms(path)?, buckets))
|
||||
}
|
||||
@@ -41,6 +61,14 @@ fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
|
||||
/// against a real decode without re-reading the file per variant — see
|
||||
/// `bucketing_strategies_on_a_real_track`.
|
||||
fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
|
||||
coarse_rms_range(path, 0.0, None)
|
||||
}
|
||||
|
||||
fn coarse_rms_range(
|
||||
path: &Path,
|
||||
start_secs: f64,
|
||||
end_secs: Option<f64>,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
@@ -73,14 +101,34 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
|
||||
.default_track()
|
||||
.ok_or_else(|| "no default track".to_string())?;
|
||||
let track_id = track.id;
|
||||
let time_base = track.codec_params.time_base;
|
||||
let sample_rate = track.codec_params.sample_rate;
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if start_secs > 0.0 {
|
||||
// A coarse seek lands at a sync point near (usually before) the
|
||||
// target; the timestamp gate in the loop trims the difference. A
|
||||
// format that cannot seek just decodes from the top — slower, same
|
||||
// result.
|
||||
let _ = format.seek(
|
||||
SeekMode::Coarse,
|
||||
SeekTo::Time {
|
||||
time: Time::from(start_secs),
|
||||
track_id: Some(track_id),
|
||||
},
|
||||
);
|
||||
decoder.reset();
|
||||
}
|
||||
|
||||
let mut sample_buf: Option<SampleBuffer<f32>> = None;
|
||||
let mut coarse: Vec<f32> = Vec::new();
|
||||
let mut win_energy = 0f64;
|
||||
let mut win_count = 0usize;
|
||||
// When the container carries no timestamps, the slice is bounded by
|
||||
// counted frames from wherever the seek landed instead.
|
||||
let mut frames_seen: u64 = 0;
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
@@ -90,6 +138,22 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
// Where this packet sits in the file, when the format can say.
|
||||
let packet_secs = time_base.map(|tb| {
|
||||
let t = tb.calc_time(packet.ts());
|
||||
t.seconds as f64 + t.frac
|
||||
});
|
||||
if let Some(t) = packet_secs {
|
||||
if t < start_secs {
|
||||
// Before the slice: decode and discard, keeping codec state
|
||||
// warm across the boundary.
|
||||
let _ = decoder.decode(&packet);
|
||||
continue;
|
||||
}
|
||||
if end_secs.is_some_and(|end| t >= end) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
|
||||
@@ -115,6 +179,14 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
|
||||
win_count = 0;
|
||||
}
|
||||
}
|
||||
if packet_secs.is_none() {
|
||||
frames_seen += (sb.samples().len() / channels) as u64;
|
||||
if let (Some(end), Some(rate)) = (end_secs, sample_rate) {
|
||||
if frames_seen as f64 / rate as f64 >= end - start_secs {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if win_count > 0 {
|
||||
coarse.push((win_energy / win_count as f64).sqrt() as f32);
|
||||
|
||||
@@ -29,6 +29,14 @@ image.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
base64 = "0.22"
|
||||
# Shazam signature packets (shazam.rs) carry a CRC-32 of their body.
|
||||
crc32fast = "1"
|
||||
# Inflate for the deno release zip (ytdlp.rs) — already in the lock via the
|
||||
# image stack, so this adds no download.
|
||||
flate2 = "1"
|
||||
# STFT/iSTFT for the vocal separator (vocal_sep.rs) — pure Rust, so the
|
||||
# no-child-processes charter holds; wraps rustfft with real transforms.
|
||||
realfft = "3"
|
||||
# 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"
|
||||
|
||||
@@ -76,6 +76,8 @@ pub struct Settings {
|
||||
pub stream_source_prefs: config::SourcePrefs,
|
||||
pub download_source_prefs: config::SourcePrefs,
|
||||
pub volume_normalization: bool,
|
||||
/// Word-by-word lyric fill — experimental, default off.
|
||||
pub word_timing: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -114,6 +116,7 @@ pub async fn get_settings(
|
||||
stream_source_prefs: cfg.stream_source_prefs,
|
||||
download_source_prefs: cfg.download_source_prefs,
|
||||
volume_normalization: config::volume_normalization(&cfg),
|
||||
word_timing: config::word_timing(&cfg),
|
||||
username: cfg.username,
|
||||
})
|
||||
}
|
||||
@@ -141,6 +144,7 @@ pub async fn set_settings(
|
||||
stream_source_prefs: Option<config::SourcePrefs>,
|
||||
download_source_prefs: Option<config::SourcePrefs>,
|
||||
volume_normalization: Option<bool>,
|
||||
word_timing: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = config::load(&app);
|
||||
let library_folder_moved = download_dir
|
||||
@@ -209,6 +213,9 @@ pub async fn set_settings(
|
||||
// takes effect from the next track.
|
||||
crate::loudness::set_enabled(&app, enabled);
|
||||
}
|
||||
if word_timing.is_some() {
|
||||
cfg.word_timing = word_timing;
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
|
||||
// A new library folder is a new library. Re-base immediately rather than
|
||||
@@ -1550,6 +1557,15 @@ pub async fn catalog_search(
|
||||
Ok(state.metadata.catalog_search(&query).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn catalog_track_by_isrc(
|
||||
state: State<'_, LibraryState>,
|
||||
isrc: String,
|
||||
) -> Result<Option<metadata::CatalogTrack>, String> {
|
||||
state.touch_catalog_activity();
|
||||
Ok(state.metadata.catalog_track_by_isrc(&isrc).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn catalog_artist(
|
||||
app: tauri::AppHandle,
|
||||
@@ -4051,6 +4067,7 @@ pub fn play_local(
|
||||
app: tauri::AppHandle,
|
||||
path: String,
|
||||
duration: Option<f64>,
|
||||
start_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
if std::fs::metadata(&path).is_err() {
|
||||
return Err("file not found".into());
|
||||
@@ -4064,7 +4081,11 @@ pub fn play_local(
|
||||
0,
|
||||
None,
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>().player.play_local(&path2, duration);
|
||||
// `start_secs` = play a SLICE of the file from there (the
|
||||
// setlist segment rows); plain tracks pass None.
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_local_from(&path2, duration, start_secs);
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
@@ -4407,6 +4428,8 @@ pub async fn waveform(
|
||||
players: State<'_, PlayerState>,
|
||||
path: String,
|
||||
buckets: usize,
|
||||
start_secs: Option<f64>,
|
||||
end_secs: Option<f64>,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
const MAX_WAVEFORM_BUCKETS: usize = 4096;
|
||||
if buckets > MAX_WAVEFORM_BUCKETS {
|
||||
@@ -4417,7 +4440,20 @@ pub async fn waveform(
|
||||
if buckets == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let key = format!("{path}#{buckets}");
|
||||
// A range = a setlist SLICE's lane; each slice of one file caches
|
||||
// separately under its own key.
|
||||
let ranged = start_secs.is_some() || end_secs.is_some();
|
||||
let key = if ranged {
|
||||
format!(
|
||||
"{path}#{buckets}@{:.3}-{}",
|
||||
start_secs.unwrap_or(0.0),
|
||||
end_secs
|
||||
.map(|e| format!("{e:.3}"))
|
||||
.unwrap_or_else(|| "end".into()),
|
||||
)
|
||||
} else {
|
||||
format!("{path}#{buckets}")
|
||||
};
|
||||
let job = players.waveform_job(&key);
|
||||
let result = async {
|
||||
let job_guard = job.clone().lock_owned().await;
|
||||
@@ -4426,7 +4462,11 @@ pub async fn waveform(
|
||||
}
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let peaks = player::compute_waveform(&p, buckets);
|
||||
let peaks = if ranged {
|
||||
player::compute_waveform_range(&p, buckets, start_secs.unwrap_or(0.0), end_secs)
|
||||
} else {
|
||||
player::compute_waveform(&p, buckets)
|
||||
};
|
||||
(job_guard, peaks)
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -139,6 +139,7 @@ pub struct AppConfig {
|
||||
/// every track plays at roughly the same perceived level. None → true.
|
||||
#[serde(default)]
|
||||
pub volume_normalization: Option<bool>,
|
||||
pub word_timing: Option<bool>,
|
||||
}
|
||||
|
||||
/// Default longest edge for embedded covers when the user hasn't chosen.
|
||||
@@ -180,6 +181,14 @@ pub fn volume_normalization(cfg: &AppConfig) -> bool {
|
||||
cfg.volume_normalization.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Word-by-word lyric timing (the karaoke fill) — EXPERIMENTAL, off by
|
||||
/// default: generated word spans still drift on dense or echo-heavy mixes,
|
||||
/// and the solid line-level sync must be what users live with until the
|
||||
/// feature earns its keep (Kyle, 2026-08-19).
|
||||
pub fn word_timing(cfg: &AppConfig) -> bool {
|
||||
cfg.word_timing.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// One-time migration after the identifier rename: the app-data dir moved
|
||||
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
|
||||
/// the old dir into place so the library DB, config and stream cache
|
||||
|
||||
@@ -6,15 +6,22 @@ mod hero_crop;
|
||||
mod import;
|
||||
mod hero_detector;
|
||||
mod loudness;
|
||||
mod lyric_align;
|
||||
mod lyric_prep;
|
||||
mod lyrics;
|
||||
mod pipeline;
|
||||
mod playback;
|
||||
mod playlist_formats;
|
||||
mod playlists;
|
||||
mod preview;
|
||||
mod setlist_scan;
|
||||
mod shazam;
|
||||
mod smtc;
|
||||
mod state;
|
||||
mod thumbbar;
|
||||
mod vocal_sep;
|
||||
mod youtube_setlist;
|
||||
mod ytdlp;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -114,7 +121,15 @@ pub fn run() {
|
||||
playlists::playlist_import_probe,
|
||||
playlists::playlist_import_folder,
|
||||
playlists::playlist_import_text,
|
||||
playlists::playlist_import_youtube,
|
||||
playlists::playlist_import_commit,
|
||||
setlist_scan::playlist_scan_youtube,
|
||||
setlist_scan::playlist_pick_set_file,
|
||||
setlist_scan::playlist_scan_file,
|
||||
setlist_scan::playlist_scan_cancel,
|
||||
ytdlp::ytdlp_status,
|
||||
ytdlp::ytdlp_download,
|
||||
ytdlp::deno_download,
|
||||
playlists::playlist_export,
|
||||
playlists::library_track_search,
|
||||
lyrics::lyrics_get,
|
||||
@@ -128,6 +143,7 @@ pub fn run() {
|
||||
lyrics::lyrics_save_text,
|
||||
lyrics::lyrics_clear,
|
||||
commands::catalog_search,
|
||||
commands::catalog_track_by_isrc,
|
||||
import::import_scan,
|
||||
import::import_portable_root,
|
||||
import::import_resolve_root,
|
||||
@@ -187,6 +203,11 @@ pub fn run() {
|
||||
preview::player_fade_resume,
|
||||
diagnostics::debug_stats,
|
||||
smtc::smtc_metadata,
|
||||
lyric_prep::lyric_prep_request,
|
||||
lyric_align::lyric_model_status,
|
||||
lyric_align::lyric_model_download,
|
||||
vocal_sep::vocal_sep_status,
|
||||
vocal_sep::vocal_sep_download,
|
||||
])
|
||||
.run({
|
||||
#[allow(unused_mut)]
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Tier B word alignment: CTC forced alignment with a small acoustic model
|
||||
//! through WinML — the same built-in runtime the hero detector uses, so no
|
||||
//! new dependency and no child processes (the preparation-worker charter).
|
||||
//!
|
||||
//! The model (MMS-300m forced-aligner, ONNX f32 export — Meta's multilingual
|
||||
//! wav2vec2 fine-tuned specifically FOR forced alignment, the checkpoint
|
||||
//! torchaudio's alignment API standardized on) emits per-20ms CHARACTER
|
||||
//! probabilities; a Viterbi pass aligns the KNOWN lyric text against them —
|
||||
//! no transcription, no guessing, just "when does each known word occur".
|
||||
//! Its vocab is lowercase romanized characters with NO word separator and
|
||||
//! blank = `<blank>`; the loader reads all of that from the repo's own
|
||||
//! vocab.json rather than hardcoding (the vocab-never-hardcoded rule).
|
||||
//! It is downloaded ONLY from the user's explicit click in Settings
|
||||
//! (`lyric_model_download`) — the charter's consent rule — verified by
|
||||
//! length, and loaded lazily. When it is absent or fails, the worker's
|
||||
//! envelope estimate stands; nothing ever depends on this module working.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use windows::{
|
||||
core::{Interface, HSTRING},
|
||||
AI::MachineLearning::{
|
||||
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
|
||||
LearningModelSession, TensorFloat,
|
||||
},
|
||||
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
|
||||
};
|
||||
|
||||
const MODEL_URL: &str =
|
||||
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/onnx/model.onnx";
|
||||
const VOCAB_URL: &str =
|
||||
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/vocab.json";
|
||||
/// Exact f32 model size, shown in Settings and sanity-checked after download.
|
||||
const MODEL_BYTES: u64 = 1_262_529_881;
|
||||
/// wav2vec2-family conv stride: 320 samples at 16 kHz (MMS keeps it).
|
||||
const FRAME_MS: i64 = 20;
|
||||
|
||||
struct Engine {
|
||||
session: LearningModelSession,
|
||||
/// char → token id, from the repo's own vocab.json (never hardcoded).
|
||||
chars: HashMap<char, usize>,
|
||||
vocab_len: usize,
|
||||
blank: usize,
|
||||
/// Word-separator token — wav2vec2-960h had `|`, MMS has none.
|
||||
sep: Option<usize>,
|
||||
}
|
||||
|
||||
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("lyric-models");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(models_dir(app)?.join("mms-300m-fa.onnx"))
|
||||
}
|
||||
|
||||
fn vocab_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(models_dir(app)?.join("mms-300m-fa.vocab.json"))
|
||||
}
|
||||
|
||||
pub fn model_present(app: &tauri::AppHandle) -> bool {
|
||||
matches!((model_file(app), vocab_file(app)), (Ok(m), Ok(v)) if m.is_file() && v.is_file())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn lyric_model_status(app: tauri::AppHandle) -> serde_json::Value {
|
||||
let size = model_file(&app)
|
||||
.ok()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"present": model_present(&app),
|
||||
"bytes": size,
|
||||
// The tier the machine would actually prepare under right now —
|
||||
// ctc-v5 once the separator model is ALSO on disk.
|
||||
"aligner": crate::lyric_prep::active_aligner(&app),
|
||||
"downloadBytes": MODEL_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Download the model + vocab. Runs ONLY from the user's explicit Settings
|
||||
/// click — the consent-before-download charter rule. Streams to a temp file,
|
||||
/// renames atomically, emits `lyrics:model-progress` along the way.
|
||||
#[tauri::command]
|
||||
pub async fn lyric_model_download(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1 (lyric alignment)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Vocab first — tiny, and the engine cannot load without it.
|
||||
let vocab_dest = vocab_file(&app)?;
|
||||
let vocab = client
|
||||
.get(VOCAB_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::from_slice::<HashMap<String, usize>>(&vocab)
|
||||
.map_err(|_| "vocab.json did not parse".to_string())?;
|
||||
std::fs::write(&vocab_dest, &vocab).map_err(|e| e.to_string())?;
|
||||
|
||||
let dest = model_file(&app)?;
|
||||
let tmp = dest.with_extension("onnx.downloading");
|
||||
let mut response = client
|
||||
.get(MODEL_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
let mut received: u64 = 0;
|
||||
let mut last_emit: u64 = 0;
|
||||
use std::io::Write;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
received += chunk.len() as u64;
|
||||
if received - last_emit > 4 * 1024 * 1024 {
|
||||
last_emit = received;
|
||||
let _ = app.emit(
|
||||
"lyrics:model-progress",
|
||||
serde_json::json!({ "received": received, "total": total }),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
if total > 0 && received != total {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("download truncated: {received} of {total} bytes"));
|
||||
}
|
||||
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
|
||||
// The superseded wav2vec2 tier's files are dead weight once this lands.
|
||||
if let Ok(dir) = models_dir(&app) {
|
||||
for legacy in ["wav2vec2-base-960h.onnx", "wav2vec2-base-960h.vocab.json"] {
|
||||
let _ = std::fs::remove_file(dir.join(legacy));
|
||||
}
|
||||
}
|
||||
let _ = app.emit(
|
||||
"lyrics:model-progress",
|
||||
serde_json::json!({ "received": received, "total": total, "done": true }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let mut guard = ENGINE.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let model_path = model_file(app)?;
|
||||
if !model_path.is_file() {
|
||||
return Err("model not downloaded".into());
|
||||
}
|
||||
let raw = std::fs::read(vocab_file(app)?).map_err(|e| e.to_string())?;
|
||||
let vocab: HashMap<String, usize> =
|
||||
serde_json::from_slice(&raw).map_err(|_| "vocab.json did not parse".to_string())?;
|
||||
let vocab_len = vocab.values().copied().max().unwrap_or(0) + 1;
|
||||
// CTC blank: MMS names it `<blank>` (its pad_token_id points there);
|
||||
// the wav2vec2-960h family used `<pad>` itself. Prefer the explicit one.
|
||||
let blank = *vocab
|
||||
.get("<blank>")
|
||||
.or_else(|| vocab.get("<pad>"))
|
||||
.ok_or("vocab has no blank token")?;
|
||||
let mut chars = HashMap::new();
|
||||
for (token, id) in &vocab {
|
||||
let mut it = token.chars();
|
||||
if let (Some(c), None) = (it.next(), it.next()) {
|
||||
chars.insert(c, *id);
|
||||
}
|
||||
}
|
||||
let sep = chars.get(&'|').copied();
|
||||
// Match the hero detector's WinRT posture: initialize, tolerate an
|
||||
// existing apartment, never uninitialize someone else's.
|
||||
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
|
||||
Err(e) => return Err(format!("WinRT init failed: {e}")),
|
||||
}
|
||||
let model =
|
||||
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
|
||||
.map_err(|e| format!("could not load alignment model: {e}"))?;
|
||||
let device = LearningModelDevice::Create(LearningModelDeviceKind::Cpu)
|
||||
.map_err(|e| format!("could not create WinML device: {e}"))?;
|
||||
let session = LearningModelSession::CreateFromModelOnDevice(&model, &device)
|
||||
.map_err(|e| format!("could not create alignment session: {e}"))?;
|
||||
*guard = Some(Engine {
|
||||
session,
|
||||
chars,
|
||||
vocab_len,
|
||||
blank,
|
||||
sep,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bare-ASCII fold for the Latin letters MMS's romanized vocab expects:
|
||||
/// Western lyrics keep their diacritics, the model's chars do not. Anything
|
||||
/// outside this stays unmapped and the line falls back to the estimate.
|
||||
fn fold_latin(c: char) -> char {
|
||||
match c {
|
||||
'à'..='å' | 'À'..='Å' => 'a',
|
||||
'è'..='ë' | 'È'..='Ë' => 'e',
|
||||
'ì'..='ï' | 'Ì'..='Ï' => 'i',
|
||||
'ò'..='ö' | 'Ò'..='Ö' | 'ø' | 'Ø' => 'o',
|
||||
'ù'..='ü' | 'Ù'..='Ü' => 'u',
|
||||
'ý' | 'ÿ' | 'Ý' => 'y',
|
||||
'ñ' | 'Ñ' => 'n',
|
||||
'ç' | 'Ç' => 'c',
|
||||
// Typographic apostrophes down to the vocab's plain one.
|
||||
'’' | 'ʼ' | '`' => '\'',
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AlignedWord {
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
}
|
||||
|
||||
pub struct AlignedLine {
|
||||
pub words: Vec<AlignedWord>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Force-align a PASSAGE — one or more consecutive lines' words — against a
|
||||
/// 16 kHz mono window. Returns per-line word times RELATIVE to the window
|
||||
/// plus a per-line confidence, or Err when the model cannot carry the
|
||||
/// passage (the caller retries per line, then falls back to the estimate).
|
||||
///
|
||||
/// Aligning lines TOGETHER is what lets word boundaries pull free of a
|
||||
/// slightly-late source cue: inside a passage the line cues are only hints,
|
||||
/// not walls — the research bundle's "whole-song alignment using line
|
||||
/// starts as hints", bounded to passage windows. `floors_ms[l]` is line
|
||||
/// l's earliest permitted emission (window-relative): without that floor,
|
||||
/// a repeating chorus lets the path lock whole lines onto the PREVIOUS
|
||||
/// acoustic instance of the same words (the charter's "competing repeated
|
||||
/// sections") — hint means "at most this much early", never "anywhere".
|
||||
/// `ceils_ms[l]` is the floor's mirror, the latest permitted emission:
|
||||
/// without it a line may smear arbitrarily LATE across its successors'
|
||||
/// audio when the space between lines still sounds vocal-ish (a separated
|
||||
/// stem keeps reverb/delay tails), sustaining one token for seconds at
|
||||
/// HIGH margin confidence — free decoding hears the same tail, so the
|
||||
/// score cannot object. Hint means "at most this much late", too.
|
||||
pub fn align_passage(
|
||||
app: &tauri::AppHandle,
|
||||
pcm: &[f32],
|
||||
lines: &[Vec<String>],
|
||||
floors_ms: &[i64],
|
||||
ceils_ms: &[i64],
|
||||
) -> Result<Vec<AlignedLine>, String> {
|
||||
let word_total: usize = lines.iter().map(|l| l.len()).sum();
|
||||
if pcm.len() < 1600
|
||||
|| word_total == 0
|
||||
|| floors_ms.len() != lines.len()
|
||||
|| ceils_ms.len() != lines.len()
|
||||
{
|
||||
return Err("window too short".into());
|
||||
}
|
||||
ensure_engine(app)?;
|
||||
let guard = ENGINE.lock().unwrap();
|
||||
let engine = guard.as_ref().ok_or("engine unavailable")?;
|
||||
|
||||
// Zero-mean / unit-variance, the model's expected normalization.
|
||||
let mean = pcm.iter().sum::<f32>() / pcm.len() as f32;
|
||||
let var = pcm.iter().map(|s| (s - mean) * (s - mean)).sum::<f32>() / pcm.len() as f32;
|
||||
let scale = 1.0 / (var.sqrt() + 1e-7);
|
||||
let normed: Vec<f32> = pcm.iter().map(|s| (s - mean) * scale).collect();
|
||||
|
||||
let input = TensorFloat::CreateFromShapeArrayAndDataArray(&[1, normed.len() as i64], &normed)
|
||||
.map_err(|e| format!("could not create input tensor: {e}"))?;
|
||||
let binding = LearningModelBinding::CreateFromSession(&engine.session)
|
||||
.map_err(|e| format!("could not create binding: {e}"))?;
|
||||
binding
|
||||
.Bind(&HSTRING::from("input_values"), &input)
|
||||
.map_err(|e| format!("could not bind input: {e}"))?;
|
||||
let result = engine
|
||||
.session
|
||||
.Evaluate(&binding, &HSTRING::from("lyric-align"))
|
||||
.map_err(|e| format!("inference failed: {e}"))?;
|
||||
if !result.Succeeded().unwrap_or(false) {
|
||||
return Err("inference did not succeed".into());
|
||||
}
|
||||
let logits: TensorFloat = result
|
||||
.Outputs()
|
||||
.and_then(|o| o.Lookup(&HSTRING::from("logits")))
|
||||
.map_err(|e| format!("logits missing: {e}"))?
|
||||
.cast()
|
||||
.map_err(|e| format!("logits wrong type: {e}"))?;
|
||||
let values: Vec<f32> = logits
|
||||
.GetAsVectorView()
|
||||
.map_err(|e| format!("could not read logits: {e}"))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let v = engine.vocab_len;
|
||||
if v == 0 || values.len() % v != 0 {
|
||||
return Err(format!("unexpected logits length {}", values.len()));
|
||||
}
|
||||
let t_frames = values.len() / v;
|
||||
|
||||
// log-softmax per frame.
|
||||
let mut logp = vec![0.0f32; values.len()];
|
||||
for t in 0..t_frames {
|
||||
let row = &values[t * v..(t + 1) * v];
|
||||
let max = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let sum: f32 = row.iter().map(|x| (x - max).exp()).sum();
|
||||
let log_z = max + sum.ln();
|
||||
for (i, x) in row.iter().enumerate() {
|
||||
logp[t * v + i] = x - log_z;
|
||||
}
|
||||
}
|
||||
|
||||
// Token sequence: each token knows its WORD (passage-global index), each
|
||||
// word knows its line; a separator joins words only when the vocab has
|
||||
// one (wav2vec2-960h did) — MMS aligns bare chars. Mapping is
|
||||
// vocab-driven and case-agnostic, with a Latin fold so accented lyrics
|
||||
// reach the romanized vocab. A word that maps to nothing (digits,
|
||||
// symbols, other scripts) sinks the whole passage to the per-line
|
||||
// ladder rather than being silently skipped.
|
||||
let map_char = |c: char| -> Option<usize> {
|
||||
engine
|
||||
.chars
|
||||
.get(&c)
|
||||
.or_else(|| c.to_lowercase().next().and_then(|l| engine.chars.get(&l)))
|
||||
.or_else(|| c.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
|
||||
.or_else(|| {
|
||||
let f = fold_latin(c);
|
||||
engine
|
||||
.chars
|
||||
.get(&f)
|
||||
.or_else(|| f.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
let mut tokens: Vec<(usize, usize)> = Vec::new();
|
||||
// Per-token emission bounds, frames — the line's cue-derived floor and
|
||||
// its successor-derived ceiling.
|
||||
let mut floor_frame: Vec<usize> = Vec::new();
|
||||
let mut ceil_frame: Vec<usize> = Vec::new();
|
||||
let mut next_wi = 0usize;
|
||||
for (li, line) in lines.iter().enumerate() {
|
||||
let floor = (floors_ms[li].max(0) / FRAME_MS) as usize;
|
||||
let ceil = (ceils_ms[li].max(0) / FRAME_MS) as usize;
|
||||
for word in line {
|
||||
let wi = next_wi;
|
||||
next_wi += 1;
|
||||
if wi > 0 {
|
||||
if let Some(sep) = engine.sep {
|
||||
tokens.push((sep, wi));
|
||||
floor_frame.push(floor);
|
||||
ceil_frame.push(ceil);
|
||||
}
|
||||
}
|
||||
let before = tokens.len();
|
||||
for c in word.chars() {
|
||||
if let Some(id) = map_char(c) {
|
||||
tokens.push((id, wi));
|
||||
floor_frame.push(floor);
|
||||
ceil_frame.push(ceil);
|
||||
}
|
||||
}
|
||||
if tokens.len() == before {
|
||||
return Err("word has no mappable characters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = tokens.len();
|
||||
if t_frames < n {
|
||||
return Err("window shorter than token sequence".into());
|
||||
}
|
||||
|
||||
// Real CTC trellis: at each frame stay on blank, SUSTAIN the current
|
||||
// token by re-emitting it (the self-loop), or advance by emitting the
|
||||
// next. The self-loop turns single emission frames into token SPANS —
|
||||
// without it every word "ended" one frame after it started and sustained
|
||||
// notes were invisible. Known approximation, kept deliberately: identical
|
||||
// CONSECUTIVE tokens (double letters, "LL") properly require a mandatory
|
||||
// blank between them; collapsing that costs ~one frame (20ms).
|
||||
const NEG: f32 = f32::NEG_INFINITY;
|
||||
let mut trellis = vec![NEG; (t_frames + 1) * (n + 1)];
|
||||
// Backpointers (0 = blank, 1 = self-loop, 2 = advance) make the
|
||||
// backtrack exact instead of re-deriving float comparisons.
|
||||
let mut back = vec![0u8; (t_frames + 1) * (n + 1)];
|
||||
let idx = |t: usize, j: usize| t * (n + 1) + j;
|
||||
trellis[idx(0, 0)] = 0.0;
|
||||
for t in 0..t_frames {
|
||||
let blank_p = logp[t * v + engine.blank];
|
||||
for j in 0..=n.min(t + 1) {
|
||||
let mut best = trellis[idx(t, j)] + blank_p;
|
||||
let mut way = 0u8;
|
||||
// A token may not be emitted before its line's floor (repeated
|
||||
// choruses would lock onto an earlier acoustic instance) nor at
|
||||
// or past its ceiling (stem reverb tails would smear a sustain
|
||||
// across the successors' audio). A line that cannot fit between
|
||||
// its bounds leaves no path — the caller's ladder handles it.
|
||||
if j > 0 && t >= floor_frame[j - 1] && t < ceil_frame[j - 1] {
|
||||
let tok_p = logp[t * v + tokens[j - 1].0];
|
||||
let sustain = trellis[idx(t, j)] + tok_p;
|
||||
if sustain > best {
|
||||
best = sustain;
|
||||
way = 1;
|
||||
}
|
||||
let advance = trellis[idx(t, j - 1)] + tok_p;
|
||||
if advance > best {
|
||||
best = advance;
|
||||
way = 2;
|
||||
}
|
||||
}
|
||||
trellis[idx(t + 1, j)] = best;
|
||||
back[idx(t + 1, j)] = way;
|
||||
}
|
||||
}
|
||||
if trellis[idx(t_frames, n)] == NEG {
|
||||
return Err("no alignment path".into());
|
||||
}
|
||||
// Backtrack the pointers into per-token spans: an advance opens the
|
||||
// token's span, self-loops extend it. Walking backwards, the first
|
||||
// emission met is the token's LAST frame. Every backtrack step consumes
|
||||
// exactly one frame emitting blank or a token — recording that symbol's
|
||||
// logp gives the path's per-frame score, which per-LINE confidence
|
||||
// needs below.
|
||||
let mut first_emit = vec![usize::MAX; n];
|
||||
let mut last_emit = vec![usize::MAX; n];
|
||||
let mut path_logp = vec![0.0f32; t_frames];
|
||||
let mut j = n;
|
||||
let mut t = t_frames;
|
||||
while t > 0 {
|
||||
match back[idx(t, j)] {
|
||||
2 => {
|
||||
first_emit[j - 1] = t - 1;
|
||||
if last_emit[j - 1] == usize::MAX {
|
||||
last_emit[j - 1] = t - 1;
|
||||
}
|
||||
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
|
||||
j -= 1;
|
||||
}
|
||||
1 => {
|
||||
if last_emit[j - 1] == usize::MAX {
|
||||
last_emit[j - 1] = t - 1;
|
||||
}
|
||||
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
|
||||
}
|
||||
_ => {
|
||||
path_logp[t - 1] = logp[(t - 1) * v + engine.blank];
|
||||
}
|
||||
}
|
||||
t -= 1;
|
||||
}
|
||||
if j != 0 {
|
||||
return Err("backtrack failed".into());
|
||||
}
|
||||
|
||||
// Per-word spans, then grouped into lines.
|
||||
let mut word_span: Vec<Option<(usize, usize)>> = vec![None; word_total];
|
||||
for (k, (id, w)) in tokens.iter().enumerate() {
|
||||
if Some(*id) == engine.sep || first_emit[k] == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
word_span[*w] = Some(match word_span[*w] {
|
||||
None => (first_emit[k], last_emit[k]),
|
||||
Some((f, _)) => (f, last_emit[k]),
|
||||
});
|
||||
}
|
||||
|
||||
// Confidence = margin to FREE decoding, per LINE: exp of the mean
|
||||
// per-frame gap between the forced path and the best unconstrained path
|
||||
// over the line's own sung span — "how close is the known lyric to what
|
||||
// the model actually hears", the calibration CTC-segmentation systems
|
||||
// use. Near 1.0 when text matches audio, sinking as it diverges. (v1
|
||||
// sampled exp(logprob) at each token's single emission frame, where CTC
|
||||
// piles mass on blank — every line pinned to the clamp floor.)
|
||||
let free: Vec<f32> = (0..t_frames)
|
||||
.map(|t| logp[t * v..(t + 1) * v].iter().cloned().fold(NEG, f32::max))
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
let mut wi = 0usize;
|
||||
for line in lines {
|
||||
let mut words_out = Vec::with_capacity(line.len());
|
||||
let mut span_first = usize::MAX;
|
||||
let mut span_last = 0usize;
|
||||
for _ in line {
|
||||
match word_span[wi] {
|
||||
Some((f, l)) => {
|
||||
span_first = span_first.min(f);
|
||||
span_last = span_last.max(l);
|
||||
words_out.push(AlignedWord {
|
||||
start_ms: f as i64 * FRAME_MS,
|
||||
end_ms: (l as i64 + 1) * FRAME_MS,
|
||||
});
|
||||
}
|
||||
None => return Err("word lost in backtrack".into()),
|
||||
}
|
||||
wi += 1;
|
||||
}
|
||||
let confidence = if span_first == usize::MAX {
|
||||
0.05
|
||||
} else {
|
||||
let gap: f32 = (span_first..=span_last.min(t_frames - 1))
|
||||
.map(|t| path_logp[t] - free[t])
|
||||
.sum();
|
||||
let frames = (span_last.min(t_frames - 1) - span_first + 1) as f32;
|
||||
(gap / frames).exp().clamp(0.05, 0.99)
|
||||
};
|
||||
out.push(AlignedLine {
|
||||
words: words_out,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1410,6 +1410,7 @@ pub async fn lyrics_get(
|
||||
let payload = payload_from(user, offset, duration_secs);
|
||||
if payload.status != "none" {
|
||||
return Ok(finish_synced(
|
||||
&app,
|
||||
payload,
|
||||
entry.auto_sync_off,
|
||||
path.as_deref(),
|
||||
@@ -1514,6 +1515,7 @@ pub async fn lyrics_get(
|
||||
let payload = payload_from(doc, offset, duration_secs);
|
||||
if payload.status == "synced" {
|
||||
return Ok(finish_synced(
|
||||
&app,
|
||||
payload,
|
||||
entry.auto_sync_off,
|
||||
path.as_deref(),
|
||||
@@ -1603,6 +1605,7 @@ pub async fn lyrics_get(
|
||||
// local copy may be the corrected one).
|
||||
if payload.status == "synced" || payload.status == "instrumental" {
|
||||
return Ok(finish_synced(
|
||||
&app,
|
||||
payload,
|
||||
entry.auto_sync_off,
|
||||
path.as_deref(),
|
||||
@@ -1638,6 +1641,7 @@ pub async fn lyrics_get(
|
||||
|
||||
/// Stamp the auto-sync fields onto a winning synced payload.
|
||||
async fn finish_synced(
|
||||
app: &tauri::AppHandle,
|
||||
mut payload: LyricsPayload,
|
||||
auto_sync_off: bool,
|
||||
path: Option<&str>,
|
||||
@@ -1649,6 +1653,35 @@ async fn finish_synced(
|
||||
let first = payload.synced.first().map(|l| l.time_ms);
|
||||
payload.auto_offset_ms =
|
||||
compute_auto_offset(path, duration_secs, source_duration_secs, first).await;
|
||||
// Second evidence source, consulted only when the corroborated
|
||||
// duration/silence probe found nothing: the word-timing artifact.
|
||||
// When forced alignment has already measured this file against this
|
||||
// doc and the sung words consistently start N ms from their cues,
|
||||
// that median is the doc's shift. Cache read only — never computed
|
||||
// here. (Aligned WORD times themselves are absolute and exempt from
|
||||
// this offset; only line cues move. See ArtifactLine::ctc.)
|
||||
if payload.auto_offset_ms == 0 {
|
||||
if let Some(p) = path {
|
||||
// The exact shape the word-timing request sends: original
|
||||
// indexes, empties dropped — the artifact key depends on it.
|
||||
let lines: Vec<crate::lyric_prep::PrepLine> = payload
|
||||
.synced
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| crate::lyric_prep::PrepLine {
|
||||
index: i as u32,
|
||||
time_ms: l.time_ms as i64,
|
||||
text: l.text.clone(),
|
||||
})
|
||||
.filter(|l| !l.text.trim().is_empty())
|
||||
.collect();
|
||||
if let Some(shift) =
|
||||
crate::lyric_prep::cue_shift_evidence(app, Path::new(p), &lines)
|
||||
{
|
||||
payload.auto_offset_ms = shift;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -775,7 +775,7 @@ pub fn parse_text(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
||||
}
|
||||
|
||||
/// "1:23" → 83_000 ms; "1:23:45" → 5_025_000; rejects anything else.
|
||||
fn parse_timestamp(s: &str) -> Option<i64> {
|
||||
pub(crate) fn parse_timestamp(s: &str) -> Option<i64> {
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if !(2..=3).contains(&parts.len()) {
|
||||
return None;
|
||||
|
||||
+180
-9
@@ -1,4 +1,4 @@
|
||||
//! Playlist commands: CRUD over the occurrence model, covers (custom +
|
||||
//! Playlist commands: CRUD over the occurrence model, covers (custom +
|
||||
//! composed mosaic), file import with staged preview, and export profiles.
|
||||
//!
|
||||
//! Boundaries, per the architecture audit: parsing happens in
|
||||
@@ -246,6 +246,13 @@ pub struct AddItemInput {
|
||||
pub source_line: Option<String>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// Catalog track id for a stream row — the identity anchor (exact id,
|
||||
/// never a name match).
|
||||
#[serde(default)]
|
||||
pub track_id: Option<String>,
|
||||
/// Cover URL for a stream row, which has no local file to read art from.
|
||||
#[serde(default)]
|
||||
pub art_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -269,6 +276,8 @@ pub async fn playlist_add_items(
|
||||
start_ms: t.start_ms,
|
||||
source_line: t.source_line,
|
||||
note: t.note,
|
||||
track_id: t.track_id,
|
||||
art_url: t.art_url,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
@@ -318,7 +327,8 @@ pub async fn playlist_set_order(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repair one row — attach a found local file or corrected metadata.
|
||||
/// Repair one row — attach a found local file, corrected metadata, or a
|
||||
/// stream row's catalog anchor (track id + cover URL).
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn playlist_relink_item(
|
||||
@@ -332,6 +342,8 @@ pub async fn playlist_relink_item(
|
||||
duration_secs: Option<u32>,
|
||||
path: Option<String>,
|
||||
art_path: Option<String>,
|
||||
track_id: Option<String>,
|
||||
art_url: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let l = lib.library.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -344,6 +356,8 @@ pub async fn playlist_relink_item(
|
||||
duration_secs,
|
||||
path.as_deref(),
|
||||
art_path.as_deref(),
|
||||
track_id.as_deref(),
|
||||
art_url.as_deref(),
|
||||
now_secs(),
|
||||
)
|
||||
})
|
||||
@@ -583,7 +597,9 @@ pub struct DraftEntry {
|
||||
pub duration_secs: Option<u32>,
|
||||
pub start_ms: Option<i64>,
|
||||
pub raw: String,
|
||||
/// "local" (matched a library file) | "unresolved".
|
||||
/// "local" (matched a library file) | "stream" (full identity, no local
|
||||
/// file — becomes a Soulseek stream on commit) | "unresolved" (identity
|
||||
/// too thin to play: ID rows, title-only lines).
|
||||
pub resolution: String,
|
||||
/// How the local match was made: "path" | "relinked" | "tags" — evidence
|
||||
/// the preview shows so an exact path match reads differently from a
|
||||
@@ -591,6 +607,18 @@ pub struct DraftEntry {
|
||||
pub matched_by: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub art_path: Option<String>,
|
||||
/// Catalog identity, attached by the review's verification pass (the
|
||||
/// modal matches stream rows against the Deezer-backed catalog — Kyle:
|
||||
/// verify against "the streaming library part, not Soulseek"). Flows
|
||||
/// into the committed row so it plays as a catalog track with artwork.
|
||||
#[serde(default)]
|
||||
pub track_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub art_url: Option<String>,
|
||||
/// The heard release's ISRC, when this row came from the listener door
|
||||
/// — lets the review resolve the catalog by exact recording id.
|
||||
#[serde(default)]
|
||||
pub isrc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -604,7 +632,13 @@ pub struct ImportDraft {
|
||||
pub entries: Vec<DraftEntry>,
|
||||
pub warnings: Vec<String>,
|
||||
pub resolved_count: u32,
|
||||
/// Rows that will play as Soulseek streams (identified, not owned).
|
||||
pub stream_count: u32,
|
||||
pub unresolved_count: u32,
|
||||
/// A suggested cover image URL (the YouTube doors set the video's own
|
||||
/// thumbnail). Commit installs it as the playlist's custom cover —
|
||||
/// failure-soft, a cover is never worth failing an import over.
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Open a playlist file and stage it for review. Parsing and resolution only —
|
||||
@@ -769,6 +803,9 @@ pub async fn playlist_import_folder(
|
||||
matched_by: Some(if indexed.is_some() { "path" } else { "file" }.into()),
|
||||
path: Some(path_string),
|
||||
art_path: indexed.and_then(|r| r.art_path.clone()),
|
||||
track_id: None,
|
||||
art_url: None,
|
||||
isrc: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -782,7 +819,9 @@ pub async fn playlist_import_folder(
|
||||
entries,
|
||||
warnings: Vec::new(),
|
||||
resolved_count: resolved,
|
||||
stream_count: 0,
|
||||
unresolved_count: 0,
|
||||
cover_url: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -875,6 +914,38 @@ pub async fn playlist_import_text(
|
||||
resolve_draft(&lib, parsed, "Pasted tracklist".into(), None).await
|
||||
}
|
||||
|
||||
/// Stage a YouTube DJ set for review: fetch the video's own tracklist
|
||||
/// evidence (chapters → description → top comments, the research bundle's
|
||||
/// declared-data rungs), then run it through the exact pasted-tracklist
|
||||
/// pipeline — same parser, same resolution, same review modal, same commit.
|
||||
/// No audio is fetched and nothing is guessed: a set with no posted
|
||||
/// tracklist says so instead of inventing one.
|
||||
#[tauri::command]
|
||||
pub async fn playlist_import_youtube(
|
||||
lib: State<'_, LibraryState>,
|
||||
url: String,
|
||||
) -> Result<ImportDraft, String> {
|
||||
let fetched = crate::youtube_setlist::fetch(&url).await?;
|
||||
let parsed = playlist_formats::parse_text(fetched.tracklist.as_bytes())?;
|
||||
let source = if fetched.author.is_empty() {
|
||||
fetched.title.clone()
|
||||
} else {
|
||||
format!("{} — {}", fetched.author, fetched.title)
|
||||
};
|
||||
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
|
||||
draft.format = "youtube".into();
|
||||
draft.name = Some(fetched.title);
|
||||
// The video's own thumbnail is the playlist's default face.
|
||||
if let Some(id) = crate::youtube_setlist::video_id_from_url(&url) {
|
||||
draft.cover_url = Some(format!("https://i.ytimg.com/vi/{id}/maxresdefault.jpg"));
|
||||
}
|
||||
draft
|
||||
.warnings
|
||||
.insert(0, format!("Tracklist from {}.", fetched.provenance));
|
||||
draft.warnings.extend(fetched.warnings);
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
/// Commit a reviewed draft as a new playlist, atomically.
|
||||
#[tauri::command]
|
||||
pub async fn playlist_import_commit(
|
||||
@@ -884,6 +955,7 @@ pub async fn playlist_import_commit(
|
||||
description: Option<String>,
|
||||
kind: String,
|
||||
entries: Vec<DraftEntry>,
|
||||
cover_url: Option<String>,
|
||||
) -> Result<i64, String> {
|
||||
let trimmed = name.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
@@ -897,16 +969,25 @@ pub async fn playlist_import_commit(
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
duration_secs: e.duration_secs,
|
||||
kind: if e.resolution == "local" && e.path.is_some() {
|
||||
"local".into()
|
||||
// Empty lets the insert classify: a matched path is local, an
|
||||
// unmatched row with full identity is a stream, the rest stay
|
||||
// unresolved placeholders. The one kind the command DOES assert
|
||||
// is "segment" — a listener row that plays a slice of the set's
|
||||
// own recording; the classifier would read its path as "local".
|
||||
kind: if e.resolution == "segment" {
|
||||
"segment".into()
|
||||
} else {
|
||||
"unresolved".into()
|
||||
String::new()
|
||||
},
|
||||
path: e.path.filter(|p| !p.is_empty()),
|
||||
art_path: e.art_path,
|
||||
source_line: (!e.raw.is_empty()).then_some(e.raw),
|
||||
start_ms: e.start_ms,
|
||||
note: None,
|
||||
// Catalog identity from the review's verification pass rides
|
||||
// into the row: the playlist plays it as a catalog track.
|
||||
track_id: e.track_id.map(|id| id.to_string()),
|
||||
art_url: e.art_url,
|
||||
})
|
||||
.collect();
|
||||
let l = lib.library.clone();
|
||||
@@ -919,9 +1000,74 @@ pub async fn playlist_import_commit(
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
changed(&app);
|
||||
if let Some(url) = cover_url {
|
||||
// Failure-soft: the playlist is already real; a cover is never
|
||||
// worth failing it over.
|
||||
match set_cover_from_url(&app, &lib, id, &url).await {
|
||||
Ok(()) => changed(&app),
|
||||
Err(e) => log::debug!("import cover fetch failed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Download an image URL and install it as the playlist's custom cover —
|
||||
/// the same square master `playlist_cover_pick` writes. YouTube's
|
||||
/// maxresdefault 404s on some videos, so that URL falls back to hqdefault.
|
||||
async fn set_cover_from_url(
|
||||
app: &tauri::AppHandle,
|
||||
lib: &State<'_, LibraryState>,
|
||||
id: i64,
|
||||
url: &str,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut candidates = vec![url.to_string()];
|
||||
if let Some(rest) = url.strip_suffix("maxresdefault.jpg") {
|
||||
candidates.push(format!("{rest}hqdefault.jpg"));
|
||||
}
|
||||
let mut bytes: Option<Vec<u8>> = None;
|
||||
for candidate in candidates {
|
||||
if let Ok(resp) = client.get(&candidate).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(body) = resp.bytes().await {
|
||||
// YouTube's CDN can answer a missing size with a tiny
|
||||
// gray placeholder instead of a 404 — refuse those.
|
||||
if body.len() > 4096 {
|
||||
bytes = Some(body.to_vec());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let bytes = bytes.ok_or("no usable image answered")?;
|
||||
let dir = covers_dir(app)?;
|
||||
let target = dir.join(format!("{id}.jpg"));
|
||||
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let img =
|
||||
image::load_from_memory(&bytes).map_err(|e| format!("not a usable image: {e}"))?;
|
||||
Ok(square_jpeg(&img, COVER_MASTER_EDGE))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let temp = dir.join(format!("{id}.importing"));
|
||||
std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&temp, &target).map_err(|e| e.to_string())?;
|
||||
let l = lib.library.clone();
|
||||
let stored = target.to_string_lossy().into_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
l.set_playlist_cover(id, Some(&stored), now_secs())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve parsed entries against the local library, in memory.
|
||||
///
|
||||
/// The ladder, strongest evidence first (identity rules: an exact locator
|
||||
@@ -931,7 +1077,7 @@ pub async fn playlist_import_commit(
|
||||
/// 3. a unique basename match in the library index;
|
||||
/// 4. a unique normalized (artist, title) tag match;
|
||||
/// 5. a unique normalized title match, when the entry names no artist.
|
||||
async fn resolve_draft(
|
||||
pub(crate) async fn resolve_draft(
|
||||
lib: &State<'_, LibraryState>,
|
||||
parsed: playlist_formats::ParsedPlaylist,
|
||||
source_name: String,
|
||||
@@ -980,7 +1126,7 @@ async fn resolve_draft(
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let (mut resolved, mut unresolved) = (0u32, 0u32);
|
||||
let (mut resolved, mut streams, mut unresolved) = (0u32, 0u32, 0u32);
|
||||
let mut any_start = false;
|
||||
for entry in &parsed.entries {
|
||||
let ParsedEntry {
|
||||
@@ -1022,6 +1168,9 @@ async fn resolve_draft(
|
||||
matched_by: Some("path".into()),
|
||||
path: Some(abs),
|
||||
art_path: None,
|
||||
track_id: None,
|
||||
art_url: None,
|
||||
isrc: None,
|
||||
});
|
||||
resolved += 1;
|
||||
continue;
|
||||
@@ -1090,10 +1239,26 @@ async fn resolve_draft(
|
||||
matched_by: Some(how.into()),
|
||||
path: Some(row.path.clone()),
|
||||
art_path: row.art_path.clone(),
|
||||
track_id: None,
|
||||
art_url: None,
|
||||
isrc: None,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
// No library file — but a row with FULL identity becomes a
|
||||
// STREAM on commit (the insert classifier's rule), so the
|
||||
// preview says so instead of "unresolved": Kyle read that
|
||||
// label as "not identified" on a fully-recognized set.
|
||||
// "unresolved" now means what it says: identity too thin
|
||||
// to play (ID rows, title-only lines).
|
||||
let full_identity = title.is_some()
|
||||
&& artist.as_deref().map(|a| !a.trim().is_empty()).unwrap_or(false)
|
||||
&& title.as_deref() != Some("ID");
|
||||
if full_identity {
|
||||
streams += 1;
|
||||
} else {
|
||||
unresolved += 1;
|
||||
}
|
||||
entries.push(DraftEntry {
|
||||
ordinal: *ordinal,
|
||||
title: title.clone().unwrap_or_else(|| "Unknown".into()),
|
||||
@@ -1102,10 +1267,13 @@ async fn resolve_draft(
|
||||
duration_secs: *duration_secs,
|
||||
start_ms: *start_ms,
|
||||
raw: raw.clone(),
|
||||
resolution: "unresolved".into(),
|
||||
resolution: if full_identity { "stream" } else { "unresolved" }.into(),
|
||||
matched_by: None,
|
||||
path: None,
|
||||
art_path: None,
|
||||
track_id: None,
|
||||
art_url: None,
|
||||
isrc: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1119,7 +1287,9 @@ async fn resolve_draft(
|
||||
entries,
|
||||
warnings: parsed.warnings,
|
||||
resolved_count: resolved,
|
||||
stream_count: streams,
|
||||
unresolved_count: unresolved,
|
||||
cover_url: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1277,3 +1447,4 @@ pub async fn playlist_export(
|
||||
.map_err(|e| e.to_string())??;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,478 @@
|
||||
//! Shazam-style audio recognition for the DJ-set setlist assistant — the
|
||||
//! research bundle's "broader recognition adapter" (feasibility §3 rung 4,
|
||||
//! plan phase 7), Kyle's pick 2026-08-19: identify a mix's tracks by
|
||||
//! LISTENING, not just by reading its posted tracklist.
|
||||
//!
|
||||
//! The signature algorithm and packet format are reimplemented from the
|
||||
//! publicly documented Shazam fingerprint (Wang 2003; layout and constants
|
||||
//! cross-checked against the open-source SongRec project's description of
|
||||
//! the format). Nothing here links foreign code: plain Rust over the same
|
||||
//! realfft the vocal separator uses.
|
||||
//!
|
||||
//! Posture: in-process, no child processes; ONE request per analyzed
|
||||
//! window against the free discovery endpoint, paced by the caller
|
||||
//! (research §11 — rate limits, timeout, cache belong to the job layer,
|
||||
//! built next). Failure is always soft: a window that cannot be
|
||||
//! recognized is an honest Unknown, never a guess.
|
||||
|
||||
use realfft::num_complex::Complex;
|
||||
use realfft::RealFftPlanner;
|
||||
|
||||
/// One recognized window: what Shazam heard and where it claims the window
|
||||
/// sits inside the identified track.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Recognition {
|
||||
pub artist: String,
|
||||
pub title: String,
|
||||
/// Shazam track key — stable id for consensus grouping.
|
||||
pub key: String,
|
||||
/// Offset of the analyzed window within the identified track, seconds,
|
||||
/// when the response carries one (it usually does).
|
||||
pub offset_secs: Option<f64>,
|
||||
pub album: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
/// The matched RELEASE's ISRC when the response carries one — an
|
||||
/// id-grade identity the review can resolve against the catalog
|
||||
/// exactly, instead of by name.
|
||||
pub isrc: Option<String>,
|
||||
}
|
||||
|
||||
// ---- signature generation (pure DSP) ----------------------------------------
|
||||
|
||||
const FFT_SIZE: usize = 2048;
|
||||
const BINS: usize = FFT_SIZE / 2 + 1; // 1025
|
||||
const HOP: usize = 128;
|
||||
/// The generator's working window: 12 s at 16 kHz mono, the length the
|
||||
/// service expects a tag to be.
|
||||
pub const WINDOW_SECS: usize = 12;
|
||||
pub const SAMPLE_RATE: usize = 16_000;
|
||||
|
||||
struct Rings {
|
||||
samples: [i16; FFT_SIZE],
|
||||
samples_at: usize,
|
||||
ffts: Vec<[f32; BINS]>,
|
||||
ffts_at: u8,
|
||||
spread: Vec<[f32; BINS]>,
|
||||
spread_at: u8,
|
||||
passes: u32,
|
||||
peaks: [Vec<Peak>; 4],
|
||||
}
|
||||
|
||||
struct Peak {
|
||||
pass: u32,
|
||||
magnitude: u16,
|
||||
corrected_bin: u16,
|
||||
}
|
||||
|
||||
/// Symmetric Hann of length 2048 (numpy-style, N−1 denominator) — the
|
||||
/// window the reference tables were generated from.
|
||||
fn hann() -> Vec<f32> {
|
||||
(0..FFT_SIZE)
|
||||
.map(|i| {
|
||||
0.5 - 0.5
|
||||
* (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the binary signature for ≤ WINDOW_SECS of 16 kHz mono PCM.
|
||||
pub fn signature(pcm: &[f32]) -> Vec<u8> {
|
||||
let mut samples: Vec<i16> = pcm
|
||||
.iter()
|
||||
.map(|&x| (x * 32768.0).clamp(-32768.0, 32767.0) as i16)
|
||||
.collect();
|
||||
let want = WINDOW_SECS * SAMPLE_RATE;
|
||||
if samples.len() < want {
|
||||
samples.resize(want, 0);
|
||||
} else {
|
||||
samples.truncate(want);
|
||||
}
|
||||
|
||||
let window = hann();
|
||||
let mut planner = RealFftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(FFT_SIZE);
|
||||
let mut rings = Rings {
|
||||
samples: [0i16; FFT_SIZE],
|
||||
samples_at: 0,
|
||||
ffts: vec![[0.0; BINS]; 256],
|
||||
ffts_at: 0,
|
||||
spread: vec![[0.0; BINS]; 256],
|
||||
spread_at: 0,
|
||||
passes: 0,
|
||||
peaks: Default::default(),
|
||||
};
|
||||
let mut re_in = vec![0.0f32; FFT_SIZE];
|
||||
let mut cx_out = vec![Complex::default(); BINS];
|
||||
|
||||
for chunk in samples.chunks_exact(HOP) {
|
||||
// Ring in the new hop, then window the reordered buffer.
|
||||
for (i, &s) in chunk.iter().enumerate() {
|
||||
rings.samples[(rings.samples_at + i) & (FFT_SIZE - 1)] = s;
|
||||
}
|
||||
rings.samples_at = (rings.samples_at + HOP) & (FFT_SIZE - 1);
|
||||
for i in 0..FFT_SIZE {
|
||||
re_in[i] =
|
||||
rings.samples[(i + rings.samples_at) & (FFT_SIZE - 1)] as f32 * window[i];
|
||||
}
|
||||
let _ = fft.process(&mut re_in, &mut cx_out);
|
||||
{
|
||||
let out = &mut rings.ffts[rings.ffts_at as usize];
|
||||
for (o, c) in out.iter_mut().zip(cx_out.iter()) {
|
||||
*o = ((c.re * c.re + c.im * c.im) / (1 << 17) as f32).max(1e-10);
|
||||
}
|
||||
}
|
||||
rings.ffts_at = rings.ffts_at.wrapping_add(1);
|
||||
|
||||
// Frequency- then time-domain peak spreading.
|
||||
{
|
||||
let src = rings.ffts[rings.ffts_at.wrapping_sub(1) as usize];
|
||||
let dst = &mut rings.spread[rings.spread_at as usize];
|
||||
dst.copy_from_slice(&src);
|
||||
for i in 0..=BINS - 3 {
|
||||
dst[i] = dst[i].max(dst[i + 1]).max(dst[i + 2]);
|
||||
}
|
||||
let snapshot = *dst;
|
||||
for former in [1u8, 3, 6] {
|
||||
let f = &mut rings.spread[rings.spread_at.wrapping_sub(former) as usize];
|
||||
for i in 0..BINS {
|
||||
f[i] = f[i].max(snapshot[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rings.spread_at = rings.spread_at.wrapping_add(1);
|
||||
rings.passes += 1;
|
||||
|
||||
if rings.passes >= 46 {
|
||||
recognize_peaks(&mut rings);
|
||||
}
|
||||
}
|
||||
|
||||
encode(&rings.peaks, want as u32)
|
||||
}
|
||||
|
||||
fn recognize_peaks(r: &mut Rings) {
|
||||
let f46 = r.ffts[r.ffts_at.wrapping_sub(46) as usize];
|
||||
let s49 = r.spread[r.spread_at.wrapping_sub(49) as usize];
|
||||
for bin in 10..=BINS - 11 {
|
||||
if f46[bin] < 1.0 / 64.0 || f46[bin] < s49[bin - 1] {
|
||||
continue;
|
||||
}
|
||||
let mut neighbor_max = 0.0f32;
|
||||
for off in [-10i32, -7, -4, -3, 1, 2, 5, 8] {
|
||||
neighbor_max = neighbor_max.max(s49[(bin as i32 + off) as usize]);
|
||||
}
|
||||
if f46[bin] <= neighbor_max {
|
||||
continue;
|
||||
}
|
||||
let mut other_max = neighbor_max;
|
||||
for off in [
|
||||
-53i32, -45, 165, 172, 179, 186, 193, 200, 214, 221, 228, 235, 242, 249,
|
||||
] {
|
||||
let other = &r.spread[((r.spread_at as i32 + off) & 255) as usize];
|
||||
other_max = other_max.max(other[bin - 1]);
|
||||
}
|
||||
if f46[bin] <= other_max {
|
||||
continue;
|
||||
}
|
||||
let pass = r.passes - 46;
|
||||
let mag = f46[bin].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
|
||||
let before = f46[bin - 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
|
||||
let after = f46[bin + 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
|
||||
let variation_1 = mag * 2.0 - before - after;
|
||||
if variation_1 < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let variation_2 = (after - before) * 32.0 / variation_1;
|
||||
let corrected = (bin as i32 * 64 + variation_2 as i32) as u16;
|
||||
let hz = corrected as f32 * (SAMPLE_RATE as f32 / 2.0 / 1024.0 / 64.0);
|
||||
let band = match hz as i32 {
|
||||
250..=519 => 0,
|
||||
520..=1449 => 1,
|
||||
1450..=3499 => 2,
|
||||
3500..=5500 => 3,
|
||||
_ => continue,
|
||||
};
|
||||
r.peaks[band].push(Peak {
|
||||
pass,
|
||||
magnitude: mag as u16,
|
||||
corrected_bin: corrected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire format: 48-byte header (magic, CRC-32 of everything after the
|
||||
/// first 8 bytes, payload size, 16 kHz marker, sample count + 0.24 s), a
|
||||
/// type-length echo, then per-band peak runs with delta-coded pass numbers.
|
||||
fn encode(peaks: &[Vec<Peak>; 4], number_samples: u32) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(4096);
|
||||
let w32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_le_bytes());
|
||||
let w16 = |out: &mut Vec<u8>, v: u16| out.extend_from_slice(&v.to_le_bytes());
|
||||
|
||||
w32(&mut out, 0xcafe2580); // magic1
|
||||
w32(&mut out, 0); // crc32 backfilled
|
||||
w32(&mut out, 0); // size backfilled
|
||||
w32(&mut out, 0x94119c00); // magic2
|
||||
w32(&mut out, 0);
|
||||
w32(&mut out, 0);
|
||||
w32(&mut out, 0);
|
||||
w32(&mut out, 3u32 << 27); // 16 kHz
|
||||
w32(&mut out, 0);
|
||||
w32(&mut out, 0);
|
||||
w32(&mut out, number_samples + (SAMPLE_RATE as f32 * 0.24) as u32);
|
||||
w32(&mut out, (15 << 19) + 0x40000);
|
||||
w32(&mut out, 0x40000000);
|
||||
w32(&mut out, 0); // size echo backfilled
|
||||
|
||||
for (band, list) in peaks.iter().enumerate() {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut body: Vec<u8> = Vec::with_capacity(list.len() * 5);
|
||||
let mut pass = 0u32;
|
||||
for p in list {
|
||||
if p.pass - pass >= 255 {
|
||||
body.push(0xff);
|
||||
body.extend_from_slice(&p.pass.to_le_bytes());
|
||||
pass = p.pass;
|
||||
}
|
||||
body.push((p.pass - pass) as u8);
|
||||
body.extend_from_slice(&p.magnitude.to_le_bytes());
|
||||
body.extend_from_slice(&p.corrected_bin.to_le_bytes());
|
||||
pass = p.pass;
|
||||
}
|
||||
w32(&mut out, 0x60030040 + band as u32);
|
||||
w32(&mut out, body.len() as u32);
|
||||
out.extend_from_slice(&body);
|
||||
while out.len() % 4 != 0 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
let _ = w16; // (kept for symmetry; u16 writes happen inside the body)
|
||||
|
||||
let size = (out.len() - 48) as u32;
|
||||
out[8..12].copy_from_slice(&size.to_le_bytes());
|
||||
out[52..56].copy_from_slice(&size.to_le_bytes());
|
||||
let mut hasher = crc32fast::Hasher::new();
|
||||
hasher.update(&out[8..]);
|
||||
let crc = hasher.finalize();
|
||||
out[4..8].copy_from_slice(&crc.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
// ---- the discovery request ----------------------------------------------------
|
||||
|
||||
/// Pseudo-UUID v4 from OS entropy via the stdlib hasher — unique enough for
|
||||
/// the tag ids the endpoint expects, no uuid/rand dependency.
|
||||
fn pseudo_uuid() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut parts = [0u64; 2];
|
||||
for p in parts.iter_mut() {
|
||||
let mut h = RandomState::new().build_hasher();
|
||||
h.write_u64(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0));
|
||||
*p = h.finish();
|
||||
}
|
||||
let b: Vec<u8> = parts.iter().flat_map(|p| p.to_le_bytes()).collect();
|
||||
format!(
|
||||
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:01x}{:02x}-a{:01x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0xf, b[7], b[8] & 0xf, b[9], b[10], b[11], b[12], b[13], b[14], b[15]
|
||||
)
|
||||
}
|
||||
|
||||
/// Recognize one ≤12 s window of 16 kHz mono PCM. `Ok(None)` = the service
|
||||
/// answered and heard nothing it knows — an honest Unknown. `Err` = the
|
||||
/// request itself failed (network, rate limit) and the caller may retry
|
||||
/// later.
|
||||
pub async fn recognize_window(pcm16k: &[f32]) -> Result<Option<Recognition>, String> {
|
||||
let sig = signature(pcm16k);
|
||||
use base64::Engine as _;
|
||||
let uri = format!(
|
||||
"data:audio/vnd.shazam.sig;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&sig)
|
||||
);
|
||||
let sample_ms = (pcm16k.len().min(WINDOW_SECS * SAMPLE_RATE) * 1000 / SAMPLE_RATE) as u64;
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let body = serde_json::json!({
|
||||
"geolocation": { "altitude": 300, "latitude": 45, "longitude": 2 },
|
||||
"signature": { "samplems": sample_ms, "timestamp": ts, "uri": uri },
|
||||
"timestamp": ts,
|
||||
"timezone": "Europe/Paris",
|
||||
});
|
||||
let url = format!(
|
||||
"https://amp.shazam.com/discovery/v5/en/US/android/-/tag/{}/{}?sync=true&webv3=true&sampling=true&connected=&shazamapiversion=v3&sharehub=true&video=v3",
|
||||
pseudo_uuid().to_uppercase(),
|
||||
pseudo_uuid()
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A.230901.001)")
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Language", "en_US")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Shazam did not answer: {e}"))?;
|
||||
if resp.status().as_u16() == 429 {
|
||||
return Err("rate-limited by the recognition service".into());
|
||||
}
|
||||
let v: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("unreadable recognition response: {e}"))?;
|
||||
let Some(track) = v.get("track") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let title = track
|
||||
.get("title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let artist = track
|
||||
.get("subtitle")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if title.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let key = track
|
||||
.get("key")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let offset_secs = v
|
||||
.get("matches")
|
||||
.and_then(|m| m.get(0))
|
||||
.and_then(|m| m.get("offset"))
|
||||
.and_then(serde_json::Value::as_f64);
|
||||
// Album rides in the metadata sections when present.
|
||||
let mut album = None;
|
||||
if let Some(sections) = track.get("sections").and_then(serde_json::Value::as_array) {
|
||||
for s in sections {
|
||||
if let Some(meta) = s.get("metadata").and_then(serde_json::Value::as_array) {
|
||||
for m in meta {
|
||||
if m.get("title").and_then(serde_json::Value::as_str) == Some("Album") {
|
||||
album = m
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cover_url = track
|
||||
.get("images")
|
||||
.and_then(|i| i.get("coverart"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let isrc = track
|
||||
.get("isrc")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(Some(Recognition {
|
||||
artist,
|
||||
title,
|
||||
key,
|
||||
offset_secs,
|
||||
album,
|
||||
cover_url,
|
||||
isrc,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The packet must round out structurally: magics in place, CRC over
|
||||
/// bytes 8.., size echoed at 8 and 52, bands tagged 0x60030040+n.
|
||||
#[test]
|
||||
fn signature_packet_is_well_formed() {
|
||||
// A tone sweep gives the recognizer real peaks to encode.
|
||||
let pcm: Vec<f32> = (0..WINDOW_SECS * SAMPLE_RATE)
|
||||
.map(|i| {
|
||||
let t = i as f32 / SAMPLE_RATE as f32;
|
||||
(2.0 * std::f32::consts::PI * (440.0 + 80.0 * t) * t).sin() * 0.4
|
||||
})
|
||||
.collect();
|
||||
let sig = signature(&pcm);
|
||||
assert!(sig.len() > 64, "packet too small: {}", sig.len());
|
||||
assert_eq!(&sig[0..4], &0xcafe2580u32.to_le_bytes());
|
||||
assert_eq!(&sig[12..16], &0x94119c00u32.to_le_bytes());
|
||||
let size = u32::from_le_bytes(sig[8..12].try_into().unwrap());
|
||||
assert_eq!(size as usize, sig.len() - 48);
|
||||
assert_eq!(&sig[52..56], &size.to_le_bytes());
|
||||
let mut h = crc32fast::Hasher::new();
|
||||
h.update(&sig[8..]);
|
||||
assert_eq!(u32::from_le_bytes(sig[4..8].try_into().unwrap()), h.finalize());
|
||||
let band_tag = u32::from_le_bytes(sig[56..60].try_into().unwrap());
|
||||
assert!(
|
||||
(0x60030040..=0x60030043).contains(&band_tag),
|
||||
"first band tag {band_tag:#x}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pure tone at 1 kHz must place its peaks in band 1 (520–1450 Hz).
|
||||
#[test]
|
||||
fn peaks_land_in_the_right_band() {
|
||||
let pcm: Vec<f32> = (0..WINDOW_SECS * SAMPLE_RATE)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 16000.0).sin() * 0.4)
|
||||
.collect();
|
||||
let mut samples: Vec<i16> = pcm.iter().map(|&x| (x * 32768.0) as i16).collect();
|
||||
samples.truncate(WINDOW_SECS * SAMPLE_RATE);
|
||||
let sig = signature(&pcm);
|
||||
// Band 1's tag must appear; bands 0/2/3 may be empty or tiny.
|
||||
let mut found_band1 = false;
|
||||
let mut at = 56usize;
|
||||
while at + 8 <= sig.len() {
|
||||
let tag = u32::from_le_bytes(sig[at..at + 4].try_into().unwrap());
|
||||
let len = u32::from_le_bytes(sig[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
if tag == 0x60030041 && len > 50 {
|
||||
found_band1 = true;
|
||||
}
|
||||
at += 8 + len + ((4 - len % 4) % 4);
|
||||
}
|
||||
assert!(found_band1, "1 kHz peaks must dominate band 1");
|
||||
}
|
||||
|
||||
/// LIVE: recognize a 12 s window of a known library track against the
|
||||
/// real service. Ignored by default — network, unofficial endpoint —
|
||||
/// run explicitly: cargo test -p app shazam_live -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn shazam_live_known_track() {
|
||||
let path = std::path::Path::new(
|
||||
"D:\\Music\\SoulSeek Music\\Panda Bear\\2015 - Panda Bear Meets The Grim Reaper\\1_SEQUENTIAL CIRCUITS.flac",
|
||||
);
|
||||
assert!(path.is_file(), "corpus track missing");
|
||||
let [l, r] = player::vocals::separation_input_44k1(path).unwrap();
|
||||
let mono = player::vocals::stem_pcm_16k(&l, &r);
|
||||
// A mid-track window, well into the singing.
|
||||
let start = 60 * SAMPLE_RATE;
|
||||
let window = &mono[start..start + WINDOW_SECS * SAMPLE_RATE];
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let got = rt.block_on(recognize_window(window)).expect("request failed");
|
||||
let rec = got.expect("service heard nothing — signature likely malformed");
|
||||
println!(
|
||||
"LIVE RESULT: {} — {} (key {}, offset {:?}, album {:?}, isrc {:?})",
|
||||
rec.artist, rec.title, rec.key, rec.offset_secs, rec.album, rec.isrc
|
||||
);
|
||||
assert!(
|
||||
rec.title.to_lowercase().contains("sequential"),
|
||||
"wrong track: {} — {}",
|
||||
rec.artist,
|
||||
rec.title
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Learned vocal separation for the lyric aligner — the "honest fix" the
|
||||
//! vocals.rs reducer comment promises: a real separator lifting the voice
|
||||
//! out of the band before CTC alignment, instead of band-pass pre-emphasis.
|
||||
//!
|
||||
//! Model: UVR-MDX-NET Voc_FT (KUIELab MDX-Net / TFC-TDF v2, the Ultimate
|
||||
//! Vocal Remover project's vocal fine-tune; MIT with credit). Chosen
|
||||
//! 2026-08-19 from the verified table in
|
||||
//! research/lyrics-vocal-separation/candidates.md: 63.7 MiB, ONNX opset 13 —
|
||||
//! under the inbox-WinML ceiling this machine PROVED by running the MMS
|
||||
//! aligner (opset 14) — static `input`/`output` [1,4,3072,256] f32, vocals
|
||||
//! SDR 9.64 on the MVSEP multisong benchmark (+1.5 dB over htdemucs at a
|
||||
//! fifth of the size; the RoFormer/Demucs ONNX exports are opset 17–23 and
|
||||
//! cannot load here as published).
|
||||
//!
|
||||
//! Posture is lyric_align.rs's exactly: WinML (no onnxruntime, no child
|
||||
//! processes), download only from the user's explicit Settings click, the
|
||||
//! session cached in a static — and NOTHING depends on this module. When
|
||||
//! the model is absent or anything fails, the aligner keeps its band-passed
|
||||
//! mixture input (the ctc-v4 behavior).
|
||||
//!
|
||||
//! The inference recipe is UVR's own non-overlap framing, kept bit-faithful
|
||||
//! so the model sees what it was trained on: stereo 44.1 kHz → per-chunk
|
||||
//! centered STFT (n_fft 7680, hop 1024, periodic Hann, reflect pad) → crop
|
||||
//! to 3072 bins → model (channels [L_re, L_im, R_re, R_im]) → vocal
|
||||
//! spectrogram → zero-pad the cropped bins back → WOLA iSTFT → trim
|
||||
//! n_fft/2 from each end → butt-join. Each chunk contributes 253,440 fresh
|
||||
//! samples (~5.75 s), so a four-minute track is ~42 model evaluations.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use realfft::num_complex::Complex;
|
||||
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
|
||||
use tauri::Emitter;
|
||||
use windows::{
|
||||
core::{Interface, HSTRING},
|
||||
AI::MachineLearning::{
|
||||
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
|
||||
LearningModelSession, TensorFloat,
|
||||
},
|
||||
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
|
||||
};
|
||||
|
||||
const MODEL_URL: &str =
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/UVR-MDX-NET-Voc_FT.onnx";
|
||||
/// Exact pinned size, shown in Settings and enforced after download — if the
|
||||
/// asset is ever republished with different bytes, refuse rather than trust.
|
||||
/// (Verified against the served bytes AND the file's own protobuf tail:
|
||||
/// opset 13, `input`/`output` [batch,4,3072,256]. Kim_Vocal_2 is 66,759,214
|
||||
/// — a first pin mistakenly used that sibling's size, and this guard caught
|
||||
/// it exactly as designed.)
|
||||
const MODEL_BYTES: u64 = 66_762_490;
|
||||
|
||||
// UVR MDX family constants (their model_data.json for this checkpoint:
|
||||
// dim_f 3072, dim_t 2^8, n_fft 7680; hop is the architecture's fixed 1024).
|
||||
const N_FFT: usize = 7_680;
|
||||
const HOP: usize = 1_024;
|
||||
const BINS: usize = N_FFT / 2 + 1; // 3841
|
||||
const DIM_F: usize = 3_072;
|
||||
const DIM_T: usize = 256;
|
||||
/// Samples one model window covers: 256 centered STFT frames at hop 1024.
|
||||
const CHUNK: usize = HOP * (DIM_T - 1); // 261,120
|
||||
/// STFT edge effects live inside half a window; UVR trims this from each
|
||||
/// chunk end and butt-joins the rest — no cross-fade, nothing to tune, and
|
||||
/// the kept region sits in full window-overlap coverage by construction.
|
||||
const TRIM: usize = N_FFT / 2; // 3840
|
||||
/// Fresh samples each chunk contributes (~5.75 s at 44.1 kHz).
|
||||
const GEN: usize = CHUNK - 2 * TRIM; // 253,440
|
||||
/// Flat f32 count of the model's input and output: [1, 4, 3072, 256].
|
||||
const TENSOR_LEN: usize = 4 * DIM_F * DIM_T;
|
||||
|
||||
struct Engine {
|
||||
session: LearningModelSession,
|
||||
/// Which device kind actually carried the smoke eval — for the log line.
|
||||
device: &'static str,
|
||||
}
|
||||
|
||||
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
|
||||
|
||||
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(crate::lyric_align::models_dir(app)?.join("uvr-mdx-voc-ft.onnx"))
|
||||
}
|
||||
|
||||
pub fn model_present(app: &tauri::AppHandle) -> bool {
|
||||
matches!(model_file(app), Ok(m) if m.is_file())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn vocal_sep_status(app: tauri::AppHandle) -> serde_json::Value {
|
||||
let size = model_file(&app)
|
||||
.ok()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
serde_json::json!({
|
||||
"present": model_present(&app),
|
||||
"bytes": size,
|
||||
"downloadBytes": MODEL_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Download the separation model. Runs ONLY from the user's explicit
|
||||
/// Settings click — the consent-before-download charter rule. Streams to a
|
||||
/// temp file, verifies the exact pinned byte count, renames atomically,
|
||||
/// emits `lyrics:sep-model-progress` along the way.
|
||||
#[tauri::command]
|
||||
pub async fn vocal_sep_download(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1 (vocal separation)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let dest = model_file(&app)?;
|
||||
let tmp = dest.with_extension("onnx.downloading");
|
||||
let mut response = client
|
||||
.get(MODEL_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = response.content_length().unwrap_or(MODEL_BYTES);
|
||||
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
let mut received: u64 = 0;
|
||||
let mut last_emit: u64 = 0;
|
||||
use std::io::Write;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
received += chunk.len() as u64;
|
||||
if received - last_emit > 2 * 1024 * 1024 {
|
||||
last_emit = received;
|
||||
let _ = app.emit(
|
||||
"lyrics:sep-model-progress",
|
||||
serde_json::json!({ "received": received, "total": total }),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
if received != MODEL_BYTES {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!(
|
||||
"download size mismatch: {received} bytes, expected {MODEL_BYTES}"
|
||||
));
|
||||
}
|
||||
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
|
||||
let _ = app.emit(
|
||||
"lyrics:sep-model-progress",
|
||||
serde_json::json!({ "received": received, "total": total, "done": true }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let mut guard = ENGINE.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let model_path = model_file(app)?;
|
||||
if !model_path.is_file() {
|
||||
return Err("separation model not downloaded".into());
|
||||
}
|
||||
// Same WinRT posture as the aligner: initialize, tolerate an existing
|
||||
// apartment, never uninitialize someone else's.
|
||||
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
|
||||
Err(e) => return Err(format!("WinRT init failed: {e}")),
|
||||
}
|
||||
let model =
|
||||
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
|
||||
.map_err(|e| format!("could not load separation model: {e}"))?;
|
||||
// DirectX first (DirectML — the discrete GPU when there is one): a track
|
||||
// is ~42 conv-net evaluations and the GPU turns minutes into seconds.
|
||||
// The zeros smoke eval proves the device can actually RUN the graph —
|
||||
// "loads but cannot execute" is a real DML failure mode — then Cpu is
|
||||
// the quiet fallback, so this works everywhere the aligner does.
|
||||
for (kind, name) in [
|
||||
(LearningModelDeviceKind::DirectX, "DirectX"),
|
||||
(LearningModelDeviceKind::Cpu, "Cpu"),
|
||||
] {
|
||||
let Ok(device) = LearningModelDevice::Create(kind) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(session) = LearningModelSession::CreateFromModelOnDevice(&model, &device) else {
|
||||
continue;
|
||||
};
|
||||
let zeros = vec![0.0f32; TENSOR_LEN];
|
||||
if eval_chunk(&session, &zeros).is_ok() {
|
||||
*guard = Some(Engine {
|
||||
session,
|
||||
device: name,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("no WinML device could run the separation model".into())
|
||||
}
|
||||
|
||||
fn eval_chunk(session: &LearningModelSession, flat: &[f32]) -> Result<Vec<f32>, String> {
|
||||
let input = TensorFloat::CreateFromShapeArrayAndDataArray(
|
||||
&[1, 4, DIM_F as i64, DIM_T as i64],
|
||||
flat,
|
||||
)
|
||||
.map_err(|e| format!("could not create input tensor: {e}"))?;
|
||||
let binding = LearningModelBinding::CreateFromSession(session)
|
||||
.map_err(|e| format!("could not create binding: {e}"))?;
|
||||
binding
|
||||
.Bind(&HSTRING::from("input"), &input)
|
||||
.map_err(|e| format!("could not bind input: {e}"))?;
|
||||
let result = session
|
||||
.Evaluate(&binding, &HSTRING::from("vocal-sep"))
|
||||
.map_err(|e| format!("inference failed: {e}"))?;
|
||||
if !result.Succeeded().unwrap_or(false) {
|
||||
return Err("inference did not succeed".into());
|
||||
}
|
||||
let out: TensorFloat = result
|
||||
.Outputs()
|
||||
.and_then(|o| o.Lookup(&HSTRING::from("output")))
|
||||
.map_err(|e| format!("output missing: {e}"))?
|
||||
.cast()
|
||||
.map_err(|e| format!("output wrong type: {e}"))?;
|
||||
let values: Vec<f32> = out
|
||||
.GetAsVectorView()
|
||||
.map_err(|e| format!("could not read output: {e}"))?
|
||||
.into_iter()
|
||||
.collect();
|
||||
if values.len() != TENSOR_LEN {
|
||||
return Err(format!("unexpected output length {}", values.len()));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
// ---- STFT plumbing (pure DSP — no WinML below this line) -------------------
|
||||
|
||||
struct Stft {
|
||||
r2c: Arc<dyn RealToComplex<f32>>,
|
||||
c2r: Arc<dyn ComplexToReal<f32>>,
|
||||
/// Periodic Hann — torch.hann_window's default, what the model trained on.
|
||||
window: Vec<f32>,
|
||||
/// WOLA denominator Σ w² over the padded chunk layout. The 256 frames
|
||||
/// tile the padded buffer exactly, so it is identical for every chunk.
|
||||
envelope: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Stft {
|
||||
fn new() -> Self {
|
||||
let mut planner = RealFftPlanner::<f32>::new();
|
||||
let r2c = planner.plan_fft_forward(N_FFT);
|
||||
let c2r = planner.plan_fft_inverse(N_FFT);
|
||||
let window: Vec<f32> = (0..N_FFT)
|
||||
.map(|n| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * n as f32 / N_FFT as f32).cos())
|
||||
.collect();
|
||||
let mut envelope = vec![1e-8f32; CHUNK + 2 * TRIM];
|
||||
for t in 0..DIM_T {
|
||||
for (n, w) in window.iter().enumerate() {
|
||||
envelope[t * HOP + n] += w * w;
|
||||
}
|
||||
}
|
||||
Stft {
|
||||
r2c,
|
||||
c2r,
|
||||
window,
|
||||
envelope,
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered STFT of one CHUNK-sized segment: reflect-pad n_fft/2 each
|
||||
/// side (torch.stft center=True), frames at the hop — frame-major
|
||||
/// [t * BINS + f].
|
||||
fn forward(&self, chunk: &[f32]) -> Result<Vec<Complex<f32>>, String> {
|
||||
debug_assert_eq!(chunk.len(), CHUNK);
|
||||
let mut padded = vec![0.0f32; CHUNK + 2 * TRIM];
|
||||
padded[TRIM..TRIM + CHUNK].copy_from_slice(chunk);
|
||||
for i in 1..=TRIM {
|
||||
padded[TRIM - i] = chunk[i];
|
||||
padded[TRIM + CHUNK - 1 + i] = chunk[CHUNK - 1 - i];
|
||||
}
|
||||
let mut spec = vec![Complex::default(); DIM_T * BINS];
|
||||
let mut frame = vec![0.0f32; N_FFT];
|
||||
for t in 0..DIM_T {
|
||||
for (n, w) in self.window.iter().enumerate() {
|
||||
frame[n] = padded[t * HOP + n] * w;
|
||||
}
|
||||
self.r2c
|
||||
.process(&mut frame, &mut spec[t * BINS..(t + 1) * BINS])
|
||||
.map_err(|e| format!("stft failed: {e}"))?;
|
||||
}
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// WOLA inverse of a frame-major spectrogram back to CHUNK samples with
|
||||
/// the centered pad trimmed off — torch.istft's window-envelope
|
||||
/// normalization. Consumes the spectrogram (the FFT scratches it).
|
||||
fn inverse(&self, spec: &mut [Complex<f32>]) -> Result<Vec<f32>, String> {
|
||||
let mut acc = vec![0.0f32; CHUNK + 2 * TRIM];
|
||||
let mut frame = vec![0.0f32; N_FFT];
|
||||
let scale = 1.0 / N_FFT as f32; // realfft's c2r is unnormalized
|
||||
for t in 0..DIM_T {
|
||||
let row = &mut spec[t * BINS..(t + 1) * BINS];
|
||||
// The model emits free re/im everywhere; a real inverse needs
|
||||
// pure-real DC and Nyquist. Nyquist sits in the cropped-zero
|
||||
// region already, DC gets clamped here.
|
||||
row[0].im = 0.0;
|
||||
row[BINS - 1].im = 0.0;
|
||||
self.c2r
|
||||
.process(row, &mut frame)
|
||||
.map_err(|e| format!("istft failed: {e}"))?;
|
||||
for (n, w) in self.window.iter().enumerate() {
|
||||
acc[t * HOP + n] += frame[n] * scale * w;
|
||||
}
|
||||
}
|
||||
Ok((TRIM..TRIM + CHUNK)
|
||||
.map(|i| acc[i] / self.envelope[i])
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack two frame-major spectrograms into the model's flat input — channel
|
||||
/// order [L_re, L_im, R_re, R_im] (UVR's reshape/permute order), frequency
|
||||
/// cropped to DIM_F.
|
||||
fn pack_input(spec_l: &[Complex<f32>], spec_r: &[Complex<f32>]) -> Vec<f32> {
|
||||
let mut flat = vec![0.0f32; TENSOR_LEN];
|
||||
for (c, spec) in [spec_l, spec_r].into_iter().enumerate() {
|
||||
for f in 0..DIM_F {
|
||||
for t in 0..DIM_T {
|
||||
let z = spec[t * BINS + f];
|
||||
flat[((2 * c) * DIM_F + f) * DIM_T + t] = z.re;
|
||||
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t] = z.im;
|
||||
}
|
||||
}
|
||||
}
|
||||
flat
|
||||
}
|
||||
|
||||
/// Unpack the model's flat output back into two frame-major spectrograms.
|
||||
/// Bins at and above DIM_F stay zero — the crop restored as silence, which
|
||||
/// for a vocal stem is honest (nothing sung lives above ~17.6 kHz).
|
||||
fn unpack_output(flat: &[f32]) -> (Vec<Complex<f32>>, Vec<Complex<f32>>) {
|
||||
let mut left = vec![Complex::default(); DIM_T * BINS];
|
||||
let mut right = vec![Complex::default(); DIM_T * BINS];
|
||||
for (c, spec) in [&mut left, &mut right].into_iter().enumerate() {
|
||||
for f in 0..DIM_F {
|
||||
for t in 0..DIM_T {
|
||||
spec[t * BINS + f] = Complex::new(
|
||||
flat[((2 * c) * DIM_F + f) * DIM_T + t],
|
||||
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
(left, right)
|
||||
}
|
||||
|
||||
/// Chunk a stereo 44.1 kHz track through `run` (the [1,4,3072,256] →
|
||||
/// same-shape spectrogram transform) and reassemble the returned stem.
|
||||
/// UVR's non-overlap framing: TRIM zeros lead the track so chunk c's kept
|
||||
/// middle lands exactly at c·GEN, edge effects fall in the trimmed halves,
|
||||
/// and segments butt-join.
|
||||
fn process_chunks(
|
||||
stft: &Stft,
|
||||
left: &[f32],
|
||||
right: &[f32],
|
||||
mut run: impl FnMut(&[f32]) -> Result<Vec<f32>, String>,
|
||||
) -> Result<(Vec<f32>, Vec<f32>), String> {
|
||||
let len = left.len().min(right.len());
|
||||
if len < HOP {
|
||||
return Err("audio too short to separate".into());
|
||||
}
|
||||
let chunks = len.div_ceil(GEN);
|
||||
let padded_len = TRIM + chunks * GEN + TRIM;
|
||||
let mut mix_l = vec![0.0f32; padded_len];
|
||||
let mut mix_r = vec![0.0f32; padded_len];
|
||||
mix_l[TRIM..TRIM + len].copy_from_slice(&left[..len]);
|
||||
mix_r[TRIM..TRIM + len].copy_from_slice(&right[..len]);
|
||||
let mut stem_l = vec![0.0f32; chunks * GEN];
|
||||
let mut stem_r = vec![0.0f32; chunks * GEN];
|
||||
for c in 0..chunks {
|
||||
let s = c * GEN;
|
||||
let spec_l = stft.forward(&mix_l[s..s + CHUNK])?;
|
||||
let spec_r = stft.forward(&mix_r[s..s + CHUNK])?;
|
||||
let out = run(&pack_input(&spec_l, &spec_r))?;
|
||||
if out.len() != TENSOR_LEN {
|
||||
return Err(format!("unexpected chunk output length {}", out.len()));
|
||||
}
|
||||
let (mut vl, mut vr) = unpack_output(&out);
|
||||
let wl = stft.inverse(&mut vl)?;
|
||||
let wr = stft.inverse(&mut vr)?;
|
||||
stem_l[s..s + GEN].copy_from_slice(&wl[TRIM..TRIM + GEN]);
|
||||
stem_r[s..s + GEN].copy_from_slice(&wr[TRIM..TRIM + GEN]);
|
||||
// Politeness between chunks — this thread must never contend with
|
||||
// the audio path for sustained stretches (the passage loop's rule).
|
||||
std::thread::sleep(std::time::Duration::from_millis(3));
|
||||
}
|
||||
stem_l.truncate(len);
|
||||
stem_r.truncate(len);
|
||||
Ok((stem_l, stem_r))
|
||||
}
|
||||
|
||||
/// Whole-file vocal stem as 16 kHz mono for the CTC aligner: decode →
|
||||
/// chunked WinML separation → mid fold + anti-alias resample (player crate).
|
||||
/// Fails soft by design — the caller falls back to the band-passed mixture.
|
||||
pub fn separate_vocals_16k(
|
||||
app: &tauri::AppHandle,
|
||||
path: &std::path::Path,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
if !model_present(app) {
|
||||
return Err("separation model not downloaded".into());
|
||||
}
|
||||
let [left, right] = player::vocals::separation_input_44k1(path)?;
|
||||
ensure_engine(app)?;
|
||||
let guard = ENGINE.lock().unwrap();
|
||||
let engine = guard.as_ref().ok_or("engine unavailable")?;
|
||||
let stft = Stft::new();
|
||||
let started = Instant::now();
|
||||
let (stem_l, stem_r) = process_chunks(&stft, &left, &right, |flat| {
|
||||
eval_chunk(&engine.session, flat)
|
||||
})?;
|
||||
log::info!(
|
||||
"vocal_sep: {:.0}s of audio separated in {:.1}s on {}",
|
||||
stem_l.len() as f32 / 44_100.0,
|
||||
started.elapsed().as_secs_f32(),
|
||||
engine.device
|
||||
);
|
||||
Ok(player::vocals::stem_pcm_16k(&stem_l, &stem_r))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The WOLA plumbing alone — forward STFT straight back through the
|
||||
/// inverse, no crop — must be per-sample exact: the numerator and the
|
||||
/// envelope share the same window algebra, so any real error here is a
|
||||
/// tiling or normalization bug, not numerics.
|
||||
#[test]
|
||||
fn stft_wola_roundtrip_is_exact() {
|
||||
let chunk: Vec<f32> = (0..CHUNK)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
|
||||
.collect();
|
||||
let stft = Stft::new();
|
||||
let mut spec = stft.forward(&chunk).unwrap();
|
||||
let back = stft.inverse(&mut spec).unwrap();
|
||||
let worst = chunk
|
||||
.iter()
|
||||
.zip(&back)
|
||||
.map(|(a, b)| (a - b).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-4, "un-cropped WOLA must be exact, worst {worst}");
|
||||
}
|
||||
|
||||
/// An identity model (output spectrogram = input spectrogram) must hand
|
||||
/// the track back through the whole STFT → crop → pack → unpack →
|
||||
/// zero-pad → iSTFT → trim → butt-join pipeline: proves the frame
|
||||
/// tiling, the chunk joins, and the layout round-trip all at once.
|
||||
/// Tolerance: the reflect pad puts a derivative kink at each chunk
|
||||
/// edge whose broadband energy reaches above bin 3072, and the model
|
||||
/// crop discards it — torch.stft + UVR's own framing lose exactly the
|
||||
/// same (~1% here); the model trained on that world.
|
||||
#[test]
|
||||
fn identity_model_reconstructs_across_chunk_joins() {
|
||||
let len = GEN + GEN / 2; // two chunks, one join, one truncation
|
||||
let track: Vec<f32> = (0..len)
|
||||
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
|
||||
.collect();
|
||||
let stft = Stft::new();
|
||||
let (out_l, out_r) =
|
||||
process_chunks(&stft, &track, &track, |flat| Ok(flat.to_vec())).unwrap();
|
||||
assert_eq!(out_l.len(), len);
|
||||
assert_eq!(out_r.len(), len);
|
||||
let mut worst = 0.0f32;
|
||||
for (a, b) in track.iter().zip(&out_l) {
|
||||
worst = worst.max((a - b).abs());
|
||||
}
|
||||
assert!(
|
||||
worst < 2e-2,
|
||||
"identity round-trip must reconstruct the tone, worst err {worst}"
|
||||
);
|
||||
// The join at GEN must not be a seam beyond that same crop error.
|
||||
let seam = (GEN - 50..GEN + 50)
|
||||
.map(|i| (track[i] - out_l[i]).abs())
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(seam < 2e-2, "butt-join must be seamless, err {seam}");
|
||||
}
|
||||
|
||||
/// The kept region of every chunk sits in full window-overlap coverage:
|
||||
/// the WOLA envelope there is flat and far from zero.
|
||||
#[test]
|
||||
fn envelope_is_stable_where_samples_are_kept() {
|
||||
let stft = Stft::new();
|
||||
let kept = &stft.envelope[2 * TRIM..2 * TRIM + GEN];
|
||||
let min = kept.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||
let max = kept.iter().cloned().fold(0.0f32, f32::max);
|
||||
assert!(min > 2.0, "envelope must be well-conditioned, min {min}");
|
||||
assert!(
|
||||
max / min < 1.5,
|
||||
"envelope must be near-flat in the kept region, {min}..{max}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
//! YouTube DJ-set tracklist extraction — the evidence rungs the feasibility
|
||||
//! research ranks FIRST (creator- and viewer-declared data): the video's
|
||||
//! chapters, its description, and timestamped tracklists in top comments.
|
||||
//! (TODO-Feature-Docs/playlist-management-and-setlist-import, research
|
||||
//! §3/§15: "the high-value, reliable subset is metadata import … broader
|
||||
//! automatic recognition must remain optional".) No audio is fetched and
|
||||
//! nothing is recognized — this is metadata import, honest about it, and a
|
||||
//! set with no posted tracklist simply reports that instead of guessing.
|
||||
//!
|
||||
//! Everything runs in-process over reqwest (the no-child-processes posture):
|
||||
//! one GET of the watch page, plus at most one innertube POST for comments
|
||||
//! when the page itself carried no tracklist. The extracted lines feed the
|
||||
//! EXISTING pasted-tracklist pipeline (playlist_formats::parse_text →
|
||||
//! resolve_draft → review modal → commit), so every downstream behavior —
|
||||
//! setlist stamps, library matching, stream fallbacks, unresolved rows —
|
||||
//! is the one already shipped and tested.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// What one video yielded: identity for the draft header plus the chosen
|
||||
/// tracklist text and where it came from.
|
||||
pub struct SetlistFetch {
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
pub tracklist: String,
|
||||
/// "chapters" | "description" | "a comment by …" — shown in the modal's
|
||||
/// warnings so the evidence rung is visible (research §3: "show which
|
||||
/// rung produced a result").
|
||||
pub provenance: String,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Minimum timestamped lines before a block counts as a tracklist — one or
|
||||
/// two stamps are a lyric quote or a "best drop" comment, not a setlist.
|
||||
const MIN_TRACKLIST_LINES: usize = 3;
|
||||
/// Top-level comments scanned for a tracklist before giving up.
|
||||
const MAX_COMMENTS_SCANNED: usize = 40;
|
||||
|
||||
// ---- URL handling -----------------------------------------------------------
|
||||
|
||||
/// Accepts watch/short/live/embed/youtu.be URLs (and a bare 11-char id) and
|
||||
/// returns the canonical video id.
|
||||
pub fn video_id_from_url(url: &str) -> Option<String> {
|
||||
let s = url.trim();
|
||||
let is_id = |c: &str| {
|
||||
c.len() == 11
|
||||
&& c.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
};
|
||||
if is_id(s) {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
let after_scheme = s.split("://").nth(1).unwrap_or(s);
|
||||
let (host, rest) = after_scheme.split_once('/')?;
|
||||
let host = host.split('@').next_back().unwrap_or(host).to_lowercase();
|
||||
let host = host.split(':').next().unwrap_or(&host);
|
||||
let path_q = rest.split('#').next().unwrap_or(rest);
|
||||
let (path, query) = match path_q.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (path_q, None),
|
||||
};
|
||||
let candidate = if host == "youtu.be" {
|
||||
path.split('/').next().map(str::to_string)
|
||||
} else if host.ends_with("youtube.com") || host.ends_with("youtube-nocookie.com") {
|
||||
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
match segs.first().copied() {
|
||||
Some("watch") => query.and_then(|q| {
|
||||
q.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("v=").map(str::to_string))
|
||||
}),
|
||||
Some("shorts") | Some("live") | Some("embed") | Some("v") => {
|
||||
segs.get(1).map(|s| s.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
candidate.filter(|c| is_id(c))
|
||||
}
|
||||
|
||||
// ---- watch-page JSON slicing --------------------------------------------------
|
||||
|
||||
/// Slice the JSON object that begins at the first `{` after `needle`,
|
||||
/// brace-counting with string/escape awareness — the watch page embeds its
|
||||
/// data as `var ytInitialData = {...};` inside script tags, megabytes long.
|
||||
pub(crate) fn slice_json_after<'a>(html: &'a str, needle: &str) -> Option<&'a str> {
|
||||
let at = html.find(needle)?;
|
||||
let rest = &html[at + needle.len()..];
|
||||
let open = rest.find('{')?;
|
||||
let bytes = rest.as_bytes();
|
||||
let mut depth = 0usize;
|
||||
let mut in_str = false;
|
||||
let mut escaped = false;
|
||||
for (i, &b) in bytes.iter().enumerate().skip(open) {
|
||||
if in_str {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if b == b'\\' {
|
||||
escaped = true;
|
||||
} else if b == b'"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(&rest[open..=i]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Depth-first search for every object under `root` that contains `key`,
|
||||
/// applying `f` to the value. Youtube's trees are deep, volatile, and
|
||||
/// renderer-name-happy; searching for leaf keys survives their reshuffles
|
||||
/// better than hardcoded paths.
|
||||
fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) {
|
||||
match root {
|
||||
Value::Object(map) => {
|
||||
if let Some(v) = map.get(key) {
|
||||
f(v);
|
||||
}
|
||||
for v in map.values() {
|
||||
walk(v, key, f);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for v in items {
|
||||
walk(v, key, f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// First string under any of `keys` in reading order — for label-ish leaves
|
||||
/// ("simpleText", nested runs) where youtube varies the wrapper.
|
||||
fn first_string(v: &Value, keys: &[&str]) -> Option<String> {
|
||||
match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
_ => {
|
||||
for k in keys {
|
||||
let mut found = None;
|
||||
walk(v, k, &mut |x| {
|
||||
if found.is_none() {
|
||||
if let Some(s) = x.as_str() {
|
||||
found = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the three evidence rungs -------------------------------------------------
|
||||
|
||||
/// A line "looks timestamped" when it carries a `m:ss`-or-longer stamp plus
|
||||
/// some letters — the same acceptance the pasted-tracklist parser applies,
|
||||
/// reused for candidate SCORING before anything is parsed for real.
|
||||
pub(crate) fn is_timestamped_line(line: &str) -> bool {
|
||||
let has_letters = line.chars().filter(|c| c.is_alphabetic()).count() >= 2;
|
||||
if !has_letters {
|
||||
return false;
|
||||
}
|
||||
line.split(|c: char| c.is_whitespace() || c == '[' || c == ']' || c == '(' || c == ')')
|
||||
.any(|tok| {
|
||||
let t = tok.trim_matches(|c: char| !c.is_ascii_digit() && c != ':');
|
||||
crate::playlist_formats::parse_timestamp(t).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Chapters out of ytInitialData: every `chapterRenderer` carries a title
|
||||
/// and `timeRangeStartMillis`. Ordered, creator-authored — the top rung.
|
||||
pub(crate) fn chapters_tracklist(initial_data: &Value) -> Option<String> {
|
||||
let mut rows: Vec<(i64, String)> = Vec::new();
|
||||
walk(initial_data, "chapterRenderer", &mut |ch| {
|
||||
let ms = ch
|
||||
.get("timeRangeStartMillis")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(-1);
|
||||
let title = ch
|
||||
.get("title")
|
||||
.and_then(|t| first_string(t, &["simpleText", "text"]));
|
||||
if ms >= 0 {
|
||||
if let Some(t) = title {
|
||||
rows.push((ms, t));
|
||||
}
|
||||
}
|
||||
});
|
||||
if rows.len() < MIN_TRACKLIST_LINES {
|
||||
return None;
|
||||
}
|
||||
rows.sort_by_key(|(ms, _)| *ms);
|
||||
rows.dedup();
|
||||
Some(
|
||||
rows.into_iter()
|
||||
.map(|(ms, t)| format!("{} {}", fmt_ts(ms), t))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The description's best block: the longest run of timestamped lines with
|
||||
/// at most one untimed line between them (headers like "Tracklist:" break
|
||||
/// nothing; prose paragraphs do).
|
||||
pub(crate) fn description_tracklist(description: &str) -> Option<String> {
|
||||
fn flush<'a>(
|
||||
current: &mut Vec<&'a str>,
|
||||
stamped: &mut usize,
|
||||
best: &mut Option<(usize, Vec<&'a str>)>,
|
||||
) {
|
||||
if *stamped >= MIN_TRACKLIST_LINES
|
||||
&& best.as_ref().map(|(n, _)| *stamped > *n).unwrap_or(true)
|
||||
{
|
||||
*best = Some((*stamped, current.clone()));
|
||||
}
|
||||
current.clear();
|
||||
*stamped = 0;
|
||||
}
|
||||
let mut best: Option<(usize, Vec<&str>)> = None;
|
||||
let mut current: Vec<&str> = Vec::new();
|
||||
let mut gap = 0usize;
|
||||
let mut stamped = 0usize;
|
||||
for line in description.lines() {
|
||||
let trimmed = line.trim();
|
||||
if is_timestamped_line(trimmed) {
|
||||
current.push(trimmed);
|
||||
stamped += 1;
|
||||
gap = 0;
|
||||
} else if !current.is_empty() && !trimmed.is_empty() && gap == 0 {
|
||||
// One stray untimed line rides along (a wrapped title, an "ID"),
|
||||
// a second breaks the block.
|
||||
gap = 1;
|
||||
} else if !current.is_empty() {
|
||||
flush(&mut current, &mut stamped, &mut best);
|
||||
gap = 0;
|
||||
}
|
||||
}
|
||||
flush(&mut current, &mut stamped, &mut best);
|
||||
best.map(|(_, lines)| lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Comment texts out of an innertube `next` response. Youtube has shipped
|
||||
/// two shapes in the wild — legacy `contentText` runs and the newer
|
||||
/// `commentEntityPayload` — so both are walked; each yields (author, text).
|
||||
pub(crate) fn comment_texts(next_response: &Value) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
walk(next_response, "commentEntityPayload", &mut |p| {
|
||||
let text = p
|
||||
.get("properties")
|
||||
.and_then(|pr| pr.get("content"))
|
||||
.and_then(|c| c.get("content"))
|
||||
.and_then(Value::as_str);
|
||||
let author = p
|
||||
.get("author")
|
||||
.and_then(|a| a.get("displayName"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("a commenter");
|
||||
if let Some(t) = text {
|
||||
out.push((author.to_string(), t.to_string()));
|
||||
}
|
||||
});
|
||||
walk(next_response, "commentRenderer", &mut |c| {
|
||||
let text = c
|
||||
.get("contentText")
|
||||
.map(|ct| collect_runs(ct))
|
||||
.filter(|t| !t.is_empty());
|
||||
let author = c
|
||||
.get("authorText")
|
||||
.and_then(|a| first_string(a, &["simpleText"]))
|
||||
.unwrap_or_else(|| "a commenter".into());
|
||||
if let Some(t) = text {
|
||||
out.push((author, t));
|
||||
}
|
||||
});
|
||||
out.truncate(MAX_COMMENTS_SCANNED);
|
||||
out
|
||||
}
|
||||
|
||||
/// Flatten a `runs`/`simpleText` rich-text node into plain text, preserving
|
||||
/// the run boundaries' own newlines (timestamps arrive as separate runs).
|
||||
fn collect_runs(v: &Value) -> String {
|
||||
if let Some(s) = v.get("simpleText").and_then(Value::as_str) {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
walk(v, "text", &mut |t| {
|
||||
if let Some(s) = t.as_str() {
|
||||
out.push_str(s);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The comments continuation token for the initial `next` call, discovered
|
||||
/// generically: the token whose enclosing area names the comment section.
|
||||
pub(crate) fn comments_token(initial_data: &Value) -> Option<String> {
|
||||
// The comment section's continuation is the one inside an
|
||||
// itemSectionRenderer whose identifier mentions comments.
|
||||
let mut token: Option<String> = None;
|
||||
walk(initial_data, "itemSectionRenderer", &mut |sec| {
|
||||
if token.is_some() {
|
||||
return;
|
||||
}
|
||||
let ident = sec
|
||||
.get("sectionIdentifier")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if !ident.contains("comment") {
|
||||
return;
|
||||
}
|
||||
walk(sec, "continuationCommand", &mut |cc| {
|
||||
if token.is_none() {
|
||||
if let Some(t) = cc.get("token").and_then(Value::as_str) {
|
||||
token = Some(t.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
token
|
||||
}
|
||||
|
||||
fn fmt_ts(ms: i64) -> String {
|
||||
let total = ms / 1000;
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Innertube client version, read from the page so the comments call speaks
|
||||
/// the same dialect the page was served with; a dated fallback otherwise.
|
||||
pub(crate) fn client_version(html: &str) -> String {
|
||||
slice_after_quote(html, "\"INNERTUBE_CONTEXT_CLIENT_VERSION\":\"")
|
||||
.unwrap_or("2.20260101.00.00")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn slice_after_quote<'a>(html: &'a str, needle: &str) -> Option<&'a str> {
|
||||
let at = html.find(needle)?;
|
||||
let rest = &html[at + needle.len()..];
|
||||
rest.split('"').next()
|
||||
}
|
||||
|
||||
// ---- the fetch ----------------------------------------------------------------
|
||||
|
||||
/// Just the video's identity (title, author) — for the LISTENER's draft
|
||||
/// header, whose sets typically have NO posted tracklist (that's why they
|
||||
/// get listened to): the full `fetch` returns Err for those and took the
|
||||
/// title down with it (Kyle's first own-set run was named by raw video id).
|
||||
pub async fn fetch_title(url: &str) -> Option<(String, String)> {
|
||||
let id = video_id_from_url(url)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||
Chrome/126.0.0.0 Safari/537.36",
|
||||
)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.ok()?;
|
||||
let html = client
|
||||
.get(format!("https://www.youtube.com/watch?v={id}&hl=en"))
|
||||
.header("Cookie", "CONSENT=YES+1; SOCS=CAI")
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.text()
|
||||
.await
|
||||
.ok()?;
|
||||
let player: Value = slice_json_after(&html, "ytInitialPlayerResponse")
|
||||
.and_then(|s| serde_json::from_str(s).ok())?;
|
||||
let details = player.get("videoDetails")?;
|
||||
let title = details.get("title").and_then(Value::as_str)?.to_string();
|
||||
let author = details
|
||||
.get("author")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((title, author))
|
||||
}
|
||||
|
||||
/// Fetch one video's metadata and pick its best tracklist. At most two
|
||||
/// requests; every rung failure downgrades to the next with a warning
|
||||
/// instead of failing the whole import (research §12).
|
||||
pub async fn fetch(url: &str) -> Result<SetlistFetch, String> {
|
||||
let id = video_id_from_url(url).ok_or("That doesn't look like a YouTube video link")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||
Chrome/126.0.0.0 Safari/537.36",
|
||||
)
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let html = client
|
||||
.get(format!("https://www.youtube.com/watch?v={id}&hl=en"))
|
||||
.header("Cookie", "CONSENT=YES+1; SOCS=CAI")
|
||||
.header("Accept-Language", "en")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("YouTube did not answer: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("YouTube refused the page: {e}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let player: Value = slice_json_after(&html, "ytInitialPlayerResponse")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.ok_or("Could not read the video's data — is the link public?")?;
|
||||
// Unavailable/private/region-locked videos still serve a page — with a
|
||||
// playability error instead of details. Say that plainly (research §8).
|
||||
let playability = player
|
||||
.get("playabilityStatus")
|
||||
.and_then(|p| p.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("OK");
|
||||
if playability != "OK" && player.get("videoDetails").is_none() {
|
||||
let reason = player
|
||||
.get("playabilityStatus")
|
||||
.and_then(|p| p.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("it may be private, removed, or region-locked");
|
||||
return Err(format!("YouTube says this video is unavailable — {reason}"));
|
||||
}
|
||||
let details = player.get("videoDetails").cloned().unwrap_or(Value::Null);
|
||||
let title = details
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("YouTube set")
|
||||
.to_string();
|
||||
let author = details
|
||||
.get("author")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let description = details
|
||||
.get("shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let initial_data: Value = slice_json_after(&html, "ytInitialData")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// Rung 1: chapters. Rung 2: the description's timestamped block.
|
||||
if let Some(list) = chapters_tracklist(&initial_data) {
|
||||
return Ok(SetlistFetch {
|
||||
title,
|
||||
author,
|
||||
tracklist: list,
|
||||
provenance: "the video's chapters".into(),
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
if let Some(list) = description_tracklist(&description) {
|
||||
return Ok(SetlistFetch {
|
||||
title,
|
||||
author,
|
||||
tracklist: list,
|
||||
provenance: "the video description".into(),
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// Rung 3: top comments — one innertube call, failure-soft.
|
||||
match comments_token(&initial_data) {
|
||||
None => warnings.push("The video page exposed no comments to scan.".into()),
|
||||
Some(token) => {
|
||||
let version = client_version(&html);
|
||||
let body = serde_json::json!({
|
||||
"context": { "client": {
|
||||
"clientName": "WEB", "clientVersion": version,
|
||||
"hl": "en", "gl": "US",
|
||||
}},
|
||||
"continuation": token,
|
||||
});
|
||||
let resp = client
|
||||
.post("https://www.youtube.com/youtubei/v1/next?prettyPrint=false")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Err(e) => warnings.push(format!("Comments could not be scanned: {e}")),
|
||||
Ok(r) => match r.json::<Value>().await {
|
||||
Err(e) => warnings.push(format!("Comments could not be read: {e}")),
|
||||
Ok(next) => {
|
||||
let mut best: Option<(usize, String, String)> = None;
|
||||
for (who, text) in comment_texts(&next) {
|
||||
let stamped =
|
||||
text.lines().filter(|l| is_timestamped_line(l.trim())).count();
|
||||
if stamped >= MIN_TRACKLIST_LINES
|
||||
&& best.as_ref().map(|(n, _, _)| stamped > *n).unwrap_or(true)
|
||||
{
|
||||
let block: Vec<&str> = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| is_timestamped_line(l))
|
||||
.collect();
|
||||
best = Some((stamped, who.clone(), block.join("\n")));
|
||||
}
|
||||
}
|
||||
if let Some((n, who, list)) = best {
|
||||
warnings.push(format!(
|
||||
"Tracklist found in a comment by {who} ({n} timestamped lines) — viewer-supplied, worth a skim."
|
||||
));
|
||||
return Ok(SetlistFetch {
|
||||
title,
|
||||
author,
|
||||
tracklist: list,
|
||||
provenance: format!("a comment by {who}"),
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
warnings.push("No comment carried a timestamped tracklist.".into());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"No tracklist found on \"{title}\" — no chapters, no timestamped description, and {} \
|
||||
Paste one into the tracklist door if you have it.",
|
||||
if warnings.iter().any(|w| w.contains("comment")) {
|
||||
"none of the top comments carry one."
|
||||
} else {
|
||||
"the comments could not be scanned."
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_ids_from_every_url_shape() {
|
||||
for (url, want) in [
|
||||
("https://www.youtube.com/watch?v=dQw4w9WgXcQ", Some("dQw4w9WgXcQ")),
|
||||
("https://youtu.be/dQw4w9WgXcQ?t=42", Some("dQw4w9WgXcQ")),
|
||||
("https://www.youtube.com/live/abc-DEF_123?feature=share", Some("abc-DEF_123")),
|
||||
("https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=RD", Some("dQw4w9WgXcQ")),
|
||||
("www.youtube.com/shorts/abc-DEF_123", Some("abc-DEF_123")),
|
||||
("dQw4w9WgXcQ", Some("dQw4w9WgXcQ")),
|
||||
("https://example.com/watch?v=dQw4w9WgXcQ", None),
|
||||
("https://www.youtube.com/watch?v=tooShort", None),
|
||||
] {
|
||||
assert_eq!(
|
||||
video_id_from_url(url).as_deref(),
|
||||
want,
|
||||
"{url} must yield {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_slicer_handles_braces_in_strings() {
|
||||
let html = r#"<script>var ytInitialData = {"a":"br{ace \" quote","b":{"c":1}};</script>"#;
|
||||
let json = slice_json_after(html, "ytInitialData").unwrap();
|
||||
let v: Value = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(v["b"]["c"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapters_walk_and_format() {
|
||||
let data: Value = serde_json::from_str(
|
||||
r#"{"markersMap":[{"value":{"chapters":[
|
||||
{"chapterRenderer":{"title":{"simpleText":"Bicep - Glue"},"timeRangeStartMillis":0}},
|
||||
{"chapterRenderer":{"title":{"simpleText":"Overmono - So U Kno"},"timeRangeStartMillis":330000}},
|
||||
{"chapterRenderer":{"title":{"simpleText":"Joy O - Sin Palta"},"timeRangeStartMillis":3900000}}
|
||||
]}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let list = chapters_tracklist(&data).unwrap();
|
||||
assert_eq!(
|
||||
list,
|
||||
"0:00 Bicep - Glue\n5:30 Overmono - So U Kno\n1:05:00 Joy O - Sin Palta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_block_beats_prose_and_survives_headers() {
|
||||
let desc = "Recorded live at the warehouse.\nBig thanks to everyone!\n\nTracklist:\n00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\nID - ID\n12:44 Artist Three - Closer\n\nFollow me on socials at 10:00 tonight";
|
||||
let block = description_tracklist(desc).unwrap();
|
||||
assert_eq!(
|
||||
block,
|
||||
"00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\n12:44 Artist Three - Closer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_stamps_is_no_tracklist() {
|
||||
assert!(description_tracklist("00:01 hi\nsome words\n03:30 drop!").is_none());
|
||||
assert!(!is_timestamped_line("10000 subscribers!"));
|
||||
assert!(is_timestamped_line("1:23:45 Artist - Title"));
|
||||
assert!(is_timestamped_line("Artist - Title [04:20]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_payloads_both_shapes() {
|
||||
let next: Value = serde_json::from_str(
|
||||
r#"{"frameworkUpdates":{"entityBatchUpdate":{"mutations":[
|
||||
{"payload":{"commentEntityPayload":{
|
||||
"properties":{"content":{"content":"00:00 A - B\n03:00 C - D\n06:00 E - F"}},
|
||||
"author":{"displayName":"@digger"}}}}
|
||||
]}},
|
||||
"old":[{"commentRenderer":{
|
||||
"authorText":{"simpleText":"@legacy"},
|
||||
"contentText":{"runs":[{"text":"great set"}]}}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let texts = comment_texts(&next);
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert_eq!(texts[0].0, "@digger");
|
||||
assert!(texts[0].1.contains("03:00 C - D"));
|
||||
assert_eq!(texts[1], ("@legacy".into(), "great set".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_token_found_by_section_identifier() {
|
||||
let data: Value = serde_json::from_str(
|
||||
r#"{"contents":[
|
||||
{"itemSectionRenderer":{"sectionIdentifier":"related-items",
|
||||
"contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"WRONG"}}}}]}},
|
||||
{"itemSectionRenderer":{"sectionIdentifier":"comment-item-section",
|
||||
"contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"RIGHT"}}}}]}}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(comments_token(&data).as_deref(), Some("RIGHT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
//! yt-dlp as an OPTIONAL external tool — the acquisition adapter for the
|
||||
//! DJ-set listener, Kyle's explicit call 2026-08-19 ("okay implement
|
||||
//! yt-dlp"), relaxing the no-child-processes posture for THIS feature
|
||||
//! only: anonymous in-process YouTube audio is dead upstream (PO-token
|
||||
//! wall, verified same day), and the download arms race belongs to the
|
||||
//! people who fight it full-time.
|
||||
//!
|
||||
//! Posture kept from the model downloads: the binary arrives ONLY from an
|
||||
//! explicit click, from yt-dlp's official GitHub release (a moving target
|
||||
//! by design — self-updating is why it keeps working, so there is no byte
|
||||
//! pin; the SOURCE is pinned instead). A PATH-installed yt-dlp is
|
||||
//! respected before the managed copy. Audio lands in a per-video cache so
|
||||
//! re-analysis is free (research §11), fetched as the m4a/AAC stream the
|
||||
//! app's own decoder reads — no ffmpeg, no conversion step.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// The NIGHTLY channel, deliberately: as of 2026-08 the stable release
|
||||
/// 403s on YouTube (extractor drift — burned live TWICE the same evening,
|
||||
/// the second time when the update button itself overwrote a working
|
||||
/// nightly with stale stable). Nightly is the yt-dlp project's own
|
||||
/// recommendation for extractor freshness; against YouTube it is the only
|
||||
/// channel that works.
|
||||
const RELEASE_URL: &str =
|
||||
"https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe";
|
||||
/// Deno's official Windows build — the RECOMMENDED EJS runtime (nightly
|
||||
/// yt-dlp + deno is the one combo proven against YouTube, 2026-08-19).
|
||||
/// Same posture as yt-dlp: no byte pin, the SOURCE is pinned; consent is
|
||||
/// the click; the tool's own --version is the sanity check.
|
||||
const DENO_RELEASE_URL: &str =
|
||||
"https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip";
|
||||
/// Windows: no console window for spawned children.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
fn tools_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("tools");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn managed_exe(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(tools_dir(app)?.join("yt-dlp.exe"))
|
||||
}
|
||||
|
||||
/// The yt-dlp this machine would use: the managed copy first, then PATH.
|
||||
pub fn locate(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
if let Ok(p) = managed_exe(app) {
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path_var)
|
||||
.map(|d| d.join("yt-dlp.exe"))
|
||||
.find(|c| c.is_file())
|
||||
}
|
||||
|
||||
fn tool_version(exe: &PathBuf) -> Option<String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let out = std::process::Command::new(exe)
|
||||
.arg("--version")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!v.is_empty()).then_some(v)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ytdlp_status(app: tauri::AppHandle) -> serde_json::Value {
|
||||
// The runtime rides along so the modal can offer the deno download
|
||||
// exactly when it matters: nothing found, or a PATH node/bun that EJS
|
||||
// may reject (the home laptop's node v19 lesson).
|
||||
let runtime = js_runtime(&app);
|
||||
match locate(&app) {
|
||||
Some(exe) => serde_json::json!({
|
||||
"present": true,
|
||||
"path": exe.to_string_lossy(),
|
||||
"managed": managed_exe(&app).map(|m| m == exe).unwrap_or(false),
|
||||
"version": tool_version(&exe),
|
||||
"runtime": runtime,
|
||||
}),
|
||||
None => serde_json::json!({ "present": false, "runtime": runtime }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download (or update) the managed yt-dlp from the official release.
|
||||
/// Consent = the click that invoked this. Streams to a temp name, renames
|
||||
/// atomically, then reports the tool's own --version as the sanity check —
|
||||
/// there is no byte pin to check against, the release moves by design.
|
||||
#[tauri::command]
|
||||
pub async fn ytdlp_download(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1 (setlist tools)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let dest = managed_exe(&app)?;
|
||||
let tmp = dest.with_extension("exe.downloading");
|
||||
let mut response = client
|
||||
.get(RELEASE_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
let mut received: u64 = 0;
|
||||
let mut last_emit: u64 = 0;
|
||||
use std::io::Write;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
received += chunk.len() as u64;
|
||||
if received - last_emit > 512 * 1024 {
|
||||
last_emit = received;
|
||||
let _ = app.emit(
|
||||
"playlist:scan-progress",
|
||||
serde_json::json!({ "stage": "tool", "received": received, "total": total }),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
if received < 2 * 1024 * 1024 {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("suspiciously small download ({received} bytes) — refused"));
|
||||
}
|
||||
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
|
||||
let version = match tool_version(&dest) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
return Err("downloaded tool did not answer --version — removed".into());
|
||||
}
|
||||
};
|
||||
Ok(serde_json::json!({ "version": version }))
|
||||
}
|
||||
|
||||
fn deno_version(exe: &PathBuf) -> Option<String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let out = std::process::Command::new(exe)
|
||||
.arg("--version")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
// First line reads "deno 2.9.5 (stable, release, x86_64-pc-windows-msvc)".
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let v = text.lines().next()?.split_whitespace().nth(1)?.to_string();
|
||||
(!v.is_empty()).then_some(v)
|
||||
}
|
||||
|
||||
/// Minimal single-entry ZIP extraction, central-directory-driven (the
|
||||
/// authoritative record even when local headers defer their sizes to data
|
||||
/// descriptors). Only what deno's release zip needs — stored or deflate,
|
||||
/// CRC-verified — hand-rolled over the flate2/crc32fast already in the
|
||||
/// tree rather than growing a zip dependency for one file.
|
||||
fn extract_zip_entry(bytes: &[u8], want: &str) -> Result<Vec<u8>, String> {
|
||||
const EOCD: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
|
||||
const CENTRAL: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
|
||||
const LOCAL: [u8; 4] = [0x50, 0x4b, 0x03, 0x04];
|
||||
let u16at = |at: usize| u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
|
||||
let u32at = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
if bytes.len() < 22 {
|
||||
return Err("archive too small to be a zip".into());
|
||||
}
|
||||
let eocd = (bytes.len().saturating_sub(65_557)..=bytes.len() - 22)
|
||||
.rev()
|
||||
.find(|&i| bytes[i..i + 4] == EOCD)
|
||||
.ok_or("no zip directory found")?;
|
||||
let mut at = u32at(eocd + 16);
|
||||
for _ in 0..u16at(eocd + 10) {
|
||||
if at + 46 > bytes.len() || bytes[at..at + 4] != CENTRAL {
|
||||
return Err("malformed zip directory".into());
|
||||
}
|
||||
let method = u16at(at + 10);
|
||||
let crc = u32at(at + 16) as u32;
|
||||
let comp_size = u32at(at + 20);
|
||||
let name_len = u16at(at + 28);
|
||||
let extra_len = u16at(at + 30);
|
||||
let comment_len = u16at(at + 32);
|
||||
let local_at = u32at(at + 42);
|
||||
let name = bytes
|
||||
.get(at + 46..at + 46 + name_len)
|
||||
.ok_or("malformed zip directory")?;
|
||||
if name == want.as_bytes() {
|
||||
if local_at + 30 > bytes.len() || bytes[local_at..local_at + 4] != LOCAL {
|
||||
return Err("malformed zip entry".into());
|
||||
}
|
||||
let data_at = local_at + 30 + u16at(local_at + 26) + u16at(local_at + 28);
|
||||
let data = bytes
|
||||
.get(data_at..data_at + comp_size)
|
||||
.ok_or("zip entry data out of range")?;
|
||||
let out = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::new();
|
||||
flate2::read::DeflateDecoder::new(data)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("zip inflate failed: {e}"))?;
|
||||
out
|
||||
}
|
||||
m => return Err(format!("unsupported zip method {m}")),
|
||||
};
|
||||
let mut hasher = crc32fast::Hasher::new();
|
||||
hasher.update(&out);
|
||||
if hasher.finalize() != crc {
|
||||
return Err("zip CRC mismatch — corrupted download, refused".into());
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
at += 46 + name_len + extra_len + comment_len;
|
||||
}
|
||||
Err(format!("{want} not found in the archive"))
|
||||
}
|
||||
|
||||
/// Download the managed Deno runtime from denoland's official release and
|
||||
/// unpack the one file that matters into the tools dir. Progress rides the
|
||||
/// same event stream as the yt-dlp download, stage "runtime".
|
||||
#[tauri::command]
|
||||
pub async fn deno_download(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("SoulStream/0.1 (setlist tools)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut response = client
|
||||
.get(DENO_RELEASE_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
let mut zip: Vec<u8> = Vec::with_capacity(total as usize);
|
||||
let mut last_emit: usize = 0;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
zip.extend_from_slice(&chunk);
|
||||
if zip.len() - last_emit > 1024 * 1024 {
|
||||
last_emit = zip.len();
|
||||
let _ = app.emit(
|
||||
"playlist:scan-progress",
|
||||
serde_json::json!({ "stage": "runtime", "received": zip.len(), "total": total }),
|
||||
);
|
||||
}
|
||||
}
|
||||
if zip.len() < 10 * 1024 * 1024 {
|
||||
return Err(format!(
|
||||
"suspiciously small download ({} bytes) — refused",
|
||||
zip.len()
|
||||
));
|
||||
}
|
||||
let dest = tools_dir(&app)?.join("deno.exe");
|
||||
// Inflating ~100 MB is real CPU work — off the async runtime's threads.
|
||||
let version = tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
|
||||
let exe = extract_zip_entry(&zip, "deno.exe")?;
|
||||
let tmp = dest.with_extension("exe.downloading");
|
||||
std::fs::write(&tmp, &exe).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
|
||||
match deno_version(&dest) {
|
||||
Some(v) => Ok(v),
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
Err("downloaded deno did not answer --version — removed".into())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
Ok(serde_json::json!({ "version": version }))
|
||||
}
|
||||
|
||||
/// Where a video's fetched audio lives; keyed by video id so a re-scan of
|
||||
/// the same set never re-downloads.
|
||||
fn audio_cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("setlist-audio");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn cached_audio(app: &tauri::AppHandle, video_id: &str) -> Option<PathBuf> {
|
||||
let dir = audio_cache_dir(app).ok()?;
|
||||
for ext in ["m4a", "mp4", "aac"] {
|
||||
let p = dir.join(format!("{video_id}.{ext}"));
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 2026 yt-dlp needs an external JS runtime for YouTube (the EJS change:
|
||||
/// "extraction without a JS runtime has been deprecated" — verified live,
|
||||
/// a runtime-less run 403s). Find one: managed deno first, then PATH
|
||||
/// deno/node/bun. Returns the `--js-runtimes` value, or None.
|
||||
fn js_runtime(app: &tauri::AppHandle) -> Option<String> {
|
||||
if let Ok(dir) = tools_dir(app) {
|
||||
let managed = dir.join("deno.exe");
|
||||
if managed.is_file() {
|
||||
return Some(format!("deno:{}", managed.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
for (exe, name) in [("deno.exe", "deno"), ("node.exe", "node"), ("bun.exe", "bun")] {
|
||||
if std::env::split_paths(&path_var).any(|d| d.join(exe).is_file()) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch a video's audio-only stream into the cache, emitting download
|
||||
/// progress parsed from yt-dlp's own lines. Blocking (the caller runs it
|
||||
/// on a worker thread). Strictly the m4a/AAC ladder — the app decodes that
|
||||
/// in-process; a set that only offers opus errors honestly rather than
|
||||
/// handing back a file nothing can read.
|
||||
pub fn fetch_audio_blocking(
|
||||
app: &tauri::AppHandle,
|
||||
video_id: &str,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<PathBuf, String> {
|
||||
use std::io::BufRead;
|
||||
use std::os::windows::process::CommandExt;
|
||||
if let Some(hit) = cached_audio(app, video_id) {
|
||||
return Ok(hit);
|
||||
}
|
||||
let exe = locate(app).ok_or("yt-dlp is not installed yet")?;
|
||||
let runtime = js_runtime(app).ok_or(
|
||||
"yt-dlp needs a JavaScript runtime for YouTube now (Deno, Node, or Bun) and none was \
|
||||
found on this machine — install one and try again",
|
||||
)?;
|
||||
let dir = audio_cache_dir(app)?;
|
||||
let template = dir.join("%(id)s.%(ext)s");
|
||||
let mut child = std::process::Command::new(&exe)
|
||||
.args([
|
||||
"--no-playlist",
|
||||
"--newline",
|
||||
"--no-color",
|
||||
"--js-runtimes",
|
||||
&runtime,
|
||||
"-f",
|
||||
"bestaudio[ext=m4a]/bestaudio[acodec^=mp4a]",
|
||||
"-o",
|
||||
&template.to_string_lossy(),
|
||||
&format!("https://www.youtube.com/watch?v={video_id}"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("could not start yt-dlp: {e}"))?;
|
||||
let stdout = child.stdout.take().ok_or("no stdout")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let _ = child.kill();
|
||||
return Err("canceled".into());
|
||||
}
|
||||
// "[download] 42.3% of 61.53MiB at ..."
|
||||
if let Some(rest) = line.trim().strip_prefix("[download]") {
|
||||
if let Some(pct) = rest.trim().split('%').next() {
|
||||
if let Ok(p) = pct.trim().parse::<f32>() {
|
||||
let _ = app.emit(
|
||||
"playlist:scan-progress",
|
||||
serde_json::json!({ "stage": "audio", "pct": p }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = child.wait().map_err(|e| e.to_string())?;
|
||||
if !status.success() {
|
||||
let mut err = String::new();
|
||||
if let Some(mut se) = child.stderr.take() {
|
||||
use std::io::Read;
|
||||
let _ = se.read_to_string(&mut err);
|
||||
}
|
||||
let tail: String = err.lines().rev().take(3).collect::<Vec<_>>().join(" | ");
|
||||
return Err(format!("yt-dlp failed: {tail}"));
|
||||
}
|
||||
cached_audio(app, video_id).ok_or_else(|| {
|
||||
"yt-dlp finished but no m4a landed — the set may only offer opus audio".into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Assemble a real zip in memory — local headers, central directory,
|
||||
/// EOCD — the structure `extract_zip_entry` actually parses.
|
||||
fn build_zip(entries: &[(&str, &[u8], u16)]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut central: Vec<u8> = Vec::new();
|
||||
for (name, data, method) in entries {
|
||||
let mut hasher = crc32fast::Hasher::new();
|
||||
hasher.update(data);
|
||||
let crc = hasher.finalize();
|
||||
let comp: Vec<u8> = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => {
|
||||
let mut enc = flate2::write::DeflateEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
);
|
||||
enc.write_all(data).unwrap();
|
||||
enc.finish().unwrap()
|
||||
}
|
||||
_ => unreachable!("test zips are stored or deflated"),
|
||||
};
|
||||
let local_at = out.len() as u32;
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[20, 0, 0, 0]); // version, flags
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]); // time, date
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]); // extra_len
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&comp);
|
||||
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&[20, 0, 20, 0, 0, 0]); // versions, flags
|
||||
central.extend_from_slice(&method.to_le_bytes());
|
||||
central.extend_from_slice(&[0; 4]); // time, date
|
||||
central.extend_from_slice(&crc.to_le_bytes());
|
||||
central.extend_from_slice(&(comp.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
central.extend_from_slice(&[0; 12]); // extra/comment/disk/attrs
|
||||
central.extend_from_slice(&local_at.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd_at = out.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]); // disk numbers
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd_at.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]); // comment_len
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_the_named_deflated_entry_among_others() {
|
||||
let payload: Vec<u8> = (0..40_000u32).flat_map(|i| i.to_le_bytes()).collect();
|
||||
let zip = build_zip(&[
|
||||
("LICENSE.md", b"decoy text", 8),
|
||||
("deno.exe", &payload, 8),
|
||||
]);
|
||||
let got = extract_zip_entry(&zip, "deno.exe").unwrap();
|
||||
assert_eq!(got, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_a_stored_entry() {
|
||||
let zip = build_zip(&[("deno.exe", b"stored bytes", 0)]);
|
||||
assert_eq!(extract_zip_entry(&zip, "deno.exe").unwrap(), b"stored bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_data_fails_the_crc_check() {
|
||||
let mut zip = build_zip(&[("deno.exe", b"soon to be flipped payload", 0)]);
|
||||
// Flip one payload byte: local header is 30 + name, data follows.
|
||||
let at = 30 + "deno.exe".len() + 3;
|
||||
zip[at] ^= 0xff;
|
||||
let err = extract_zip_entry(&zip, "deno.exe").unwrap_err();
|
||||
assert!(err.contains("CRC"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_name_is_an_honest_error() {
|
||||
let zip = build_zip(&[("other.txt", b"x", 0)]);
|
||||
assert!(extract_zip_entry(&zip, "deno.exe").is_err());
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -4,7 +4,6 @@
|
||||
import PlayerBar from "./components/PlayerBar.svelte";
|
||||
import NowPlaying from "./components/NowPlaying.svelte";
|
||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||
import LyricsOverlay from "./components/LyricsOverlay.svelte";
|
||||
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
|
||||
import ImportWindow from "./components/ImportWindow.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
@@ -23,8 +22,10 @@
|
||||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||||
import Lyrics from "./views/Lyrics.svelte";
|
||||
import FollowToast from "./components/FollowToast.svelte";
|
||||
import ImportToast from "./components/ImportToast.svelte";
|
||||
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
||||
import {
|
||||
ui,
|
||||
initEvents,
|
||||
@@ -48,6 +49,7 @@
|
||||
catalogArtist: CatalogArtist,
|
||||
catalogAlbum: CatalogAlbum,
|
||||
catalogDiscography: CatalogDiscography,
|
||||
lyrics: Lyrics,
|
||||
};
|
||||
const CurrentView = $derived(views[ui.view] ?? Home);
|
||||
|
||||
@@ -82,6 +84,16 @@
|
||||
// --bar-h + air rather than --pane-pad. Its ambience wash is the one
|
||||
// exception and deliberately runs to the pane top behind the glass.
|
||||
"home",
|
||||
// The playlist editor follows the album-page rule: its hero banner
|
||||
// runs under the frost and the page takes padding-top: --pane-pad.
|
||||
"playlist",
|
||||
// The playlist shelf follows the Library exception: its blurred
|
||||
// ambience pins behind the glass and must begin at the very top of
|
||||
// the pane, so the page carries its own top inset instead.
|
||||
"playlists",
|
||||
// The lyrics room follows the Library exception too: its full-bleed
|
||||
// duotone backdrop pins behind the glass from the very pane top.
|
||||
"lyrics",
|
||||
]);
|
||||
|
||||
initEvents();
|
||||
@@ -127,8 +139,8 @@
|
||||
<PlayerBar />
|
||||
<FollowToast />
|
||||
<ImportToast />
|
||||
<SetlistScanChip />
|
||||
<PreviewOverlay />
|
||||
<LyricsOverlay />
|
||||
<LyricsSyncEditor />
|
||||
<ArtworkViewer />
|
||||
<ImportWindow />
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<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>
|
||||
@@ -4,14 +4,15 @@
|
||||
// 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 { player, ui, playLocalFile, goToView } from "../lib/state.svelte.js";
|
||||
import {
|
||||
lyricsStore,
|
||||
lyricIdentity,
|
||||
ensureLyrics,
|
||||
noteTrackChange,
|
||||
activeLineIndex,
|
||||
activeWordIndex,
|
||||
wordClockMs,
|
||||
wordfill,
|
||||
totalOffsetMs,
|
||||
nudgeOffset,
|
||||
setAutoSync,
|
||||
@@ -253,19 +254,26 @@
|
||||
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">
|
||||
{#if index === active && lyricsStore.wordFill && line.words?.length}
|
||||
<!-- Karaoke word fill over REAL word times (the
|
||||
wordfill mock's dialed variant A): each word
|
||||
wipes across its OWN sung span, silence holds —
|
||||
carried, never invented (the Noctis rule). A
|
||||
line-level source still gets the whole-line
|
||||
highlight and nothing more: the interpolated
|
||||
sweep that tracked the layout instead of the
|
||||
voice stays rejected. -->
|
||||
<span
|
||||
class="wline"
|
||||
use:wordfill={{
|
||||
line,
|
||||
ms: wordClockMs(line, player.position ?? 0),
|
||||
endBound: lyricsStore.lines[index + 1]?.time_ms
|
||||
?? line.words[line.words.length - 1].time_ms + 2500,
|
||||
}}
|
||||
>
|
||||
{#each line.words as word, wi (wi)}
|
||||
<span class="w" class:sung={wi <= sungTo}>{word.text}</span>
|
||||
<span class="fw">{word.text}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -331,7 +339,14 @@
|
||||
{#snippet tools()}
|
||||
<span class="tools">
|
||||
{#if !big}
|
||||
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}>
|
||||
<button
|
||||
class="tool"
|
||||
title="Open the lyrics page"
|
||||
onclick={() =>
|
||||
goToView("lyrics", {
|
||||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||||
})}
|
||||
>
|
||||
<Icon name="expand" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -615,20 +630,29 @@
|
||||
}
|
||||
|
||||
/* ── karaoke rendering inside the active line ──
|
||||
Word mode: crisp steps on the source's own word times. */
|
||||
Word fill (the wordfill mock's dialed variant A): each word is its own
|
||||
gradient-clip raster wiping across the word's real sung span — accent
|
||||
left of the feathered boundary (±8%, mock-dialed), the pane's quieter
|
||||
unsung tone right of it. --lyr-p per element (inherits: false;
|
||||
registered @property in Lyrics.svelte). */
|
||||
.wline {
|
||||
display: block;
|
||||
}
|
||||
.w {
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.12s ease;
|
||||
.fw {
|
||||
--lyr-p: -8%;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-hover) calc(var(--lyr-p) - 8%),
|
||||
var(--text-secondary) calc(var(--lyr-p) + 8%)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
.w + .w {
|
||||
.fw + .fw {
|
||||
margin-left: 0.32em;
|
||||
}
|
||||
.w.sung {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.autochip {
|
||||
flex: none;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
openCurrentArtist,
|
||||
openCurrentAlbum,
|
||||
openCurrentSong,
|
||||
seekPlayer,
|
||||
libraryStore,
|
||||
artBroken,
|
||||
markArtBroken,
|
||||
@@ -943,7 +944,7 @@
|
||||
const now = performance.now();
|
||||
if (now - lastWaveSeekAt >= 75) {
|
||||
lastWaveSeekAt = now;
|
||||
api.playerControl("seek", target);
|
||||
seekPlayer(target);
|
||||
}
|
||||
}
|
||||
function waveUp(e) {
|
||||
@@ -951,7 +952,7 @@
|
||||
waveDragging = false;
|
||||
player.scrubbing = false;
|
||||
const target = waveTarget(e);
|
||||
if (target != null) api.playerControl("seek", target);
|
||||
if (target != null) seekPlayer(target);
|
||||
try {
|
||||
waveEl.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
@@ -1102,10 +1103,13 @@
|
||||
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.
|
||||
// machine — the row goes in as a stream row that
|
||||
// plays through the resolver, not a fake local
|
||||
// file. Its catalog id and cover ride along.
|
||||
path: currentItem.stream ? null : currentItem.path,
|
||||
art_path: currentItem.stream ? null : (currentItem.art ?? null),
|
||||
trackId: currentItem.trackId ?? null,
|
||||
artUrl: currentItem.stream ? (currentItem.artUrl ?? null) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
togglePlayerExpanded,
|
||||
showNowPlaying,
|
||||
saveStream,
|
||||
goToView,
|
||||
engineTime,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
@@ -265,7 +267,11 @@
|
||||
}
|
||||
function openLyrics() {
|
||||
menuOpen = false;
|
||||
ui.lyricsFull = true; // the fullscreen overlay (LyricsOverlay.svelte)
|
||||
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
|
||||
// is gone.
|
||||
goToView("lyrics", {
|
||||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Save to library, from the ⋯ menu ---------------------------------
|
||||
@@ -678,7 +684,9 @@
|
||||
const now = performance.now();
|
||||
if (force || now - lastScrubSeekAt >= 75) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("seek", target);
|
||||
// TRACK time in, ENGINE time out — a set-recording slice's
|
||||
// engine clock is offset by the slice start.
|
||||
api.playerControl("seek", engineTime(target));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +714,7 @@
|
||||
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
|
||||
if (activatingScratch || now - lastScrubSeekAt >= 50) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
|
||||
api.playerControl("scratch", engineTime(target), e.clientX < scrubLastX ? -rate : rate);
|
||||
}
|
||||
} else if (!canScratch) {
|
||||
regularScrub(target);
|
||||
@@ -719,7 +727,7 @@
|
||||
if (!draggingSeek) return;
|
||||
const target = previewScrub(e, el);
|
||||
if (target != null) {
|
||||
if (scratchActive) api.playerControl("end-scratch", target);
|
||||
if (scratchActive) api.playerControl("end-scratch", engineTime(target));
|
||||
else regularScrub(target, true); // a simple click remains exact seek
|
||||
}
|
||||
draggingSeek = false;
|
||||
|
||||
@@ -3,23 +3,55 @@
|
||||
// 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".
|
||||
//
|
||||
// Identified-but-unowned rows are VERIFIED against the CATALOG while
|
||||
// the review is open (Kyle: match them to "the streaming library part,
|
||||
// not Soulseek") — a strict name+duration match against Deezer-backed
|
||||
// catalog search. A hit attaches the catalog track id + artwork to the
|
||||
// row, so the committed playlist plays it as a catalog track.
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
setlistScan,
|
||||
startYoutubeListen,
|
||||
startFileListen,
|
||||
} from "../lib/setlistScan.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
|
||||
// "file": a probed draft arrives via `draft`; "paste": textarea
|
||||
// first; "youtube": a link field first — the set's own tracklist
|
||||
// evidence (chapters/description/comments) becomes the draft.
|
||||
mode = "file",
|
||||
draft = null,
|
||||
onimported = () => {},
|
||||
} = $props();
|
||||
|
||||
let pasteText = $state("");
|
||||
let youtubeUrl = $state("");
|
||||
let localDraft = $state(null);
|
||||
// The local listen door: the picked set recording + its optional
|
||||
// pasted tracklist (B-flow: pick first, paste after).
|
||||
let pickedFile = $state(null); // {path, name, duration_secs}
|
||||
let fileTracklist = $state("");
|
||||
let picking = $state(false);
|
||||
let name = $state("");
|
||||
let includeUnresolved = $state(true);
|
||||
let committing = $state(false);
|
||||
let parsing = $state(false);
|
||||
let error = $state("");
|
||||
// The listener: yt-dlp tool state + live scan progress.
|
||||
let tool = $state(null);
|
||||
let toolBusy = $state(false);
|
||||
let denoBusy = $state(false);
|
||||
// "deno" (managed or PATH) | "node"/"bun" (EJS may reject old ones —
|
||||
// deno stays on offer) | null (no runtime at all).
|
||||
const runtimeKind = $derived.by(() => {
|
||||
const r = tool?.runtime;
|
||||
if (!r) return null;
|
||||
return r.startsWith("deno") ? "deno" : r;
|
||||
});
|
||||
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
|
||||
|
||||
// The working draft: probed (file mode) or built from the paste box.
|
||||
const working = $derived(localDraft ?? draft);
|
||||
@@ -31,8 +63,108 @@
|
||||
committing = false;
|
||||
localDraft = null;
|
||||
pasteText = "";
|
||||
youtubeUrl = "";
|
||||
pickedFile = null;
|
||||
fileTracklist = "";
|
||||
includeUnresolved = true;
|
||||
scanning = null;
|
||||
name = draft?.name ?? "";
|
||||
if (mode === "youtube") {
|
||||
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
|
||||
}
|
||||
if (draft?.entries?.length) startChecks(draft);
|
||||
});
|
||||
$effect(() => {
|
||||
return api.onEvent("playlist:scan-progress", (p) => {
|
||||
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
|
||||
scanning = { ...scanning, ...p };
|
||||
});
|
||||
});
|
||||
|
||||
async function downloadTool() {
|
||||
if (toolBusy) return;
|
||||
toolBusy = true;
|
||||
error = "";
|
||||
scanning = { stage: "tool" };
|
||||
try {
|
||||
await api.ytdlpDownload();
|
||||
tool = await api.ytdlpStatus();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
toolBusy = false;
|
||||
scanning = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDeno() {
|
||||
if (denoBusy) return;
|
||||
denoBusy = true;
|
||||
error = "";
|
||||
scanning = { stage: "runtime" };
|
||||
try {
|
||||
await api.denoDownload();
|
||||
tool = await api.ytdlpStatus();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
denoBusy = false;
|
||||
scanning = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listens run in the BACKGROUND: starting one closes this modal and
|
||||
// hands the whole job to the setlistScan store — its chip narrates the
|
||||
// progress, and the finished draft re-opens the review from there.
|
||||
function listenYoutube() {
|
||||
if (!youtubeUrl.trim()) return;
|
||||
if (!startYoutubeListen(youtubeUrl.trim())) return;
|
||||
close();
|
||||
}
|
||||
|
||||
// The local door picks FIRST: the panel that follows shows the chosen
|
||||
// file and offers an optional pasted tracklist — the same declared
|
||||
// evidence the YouTube door fetches for itself — before the listen
|
||||
// starts. An empty box listens purely by ear.
|
||||
async function pickLocalFile() {
|
||||
if (picking || setlistScan.active) return;
|
||||
picking = true;
|
||||
error = "";
|
||||
try {
|
||||
const f = await api.playlistPickSetFile();
|
||||
if (f) {
|
||||
pickedFile = f;
|
||||
fileTracklist = "";
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function listenPicked() {
|
||||
if (!pickedFile) return;
|
||||
if (!startFileListen(pickedFile.path, fileTracklist.trim() || null, pickedFile.name))
|
||||
return;
|
||||
close();
|
||||
}
|
||||
|
||||
function cancelScan() {
|
||||
void api.playlistScanCancel();
|
||||
}
|
||||
|
||||
const scanLabel = $derived.by(() => {
|
||||
if (!scanning) return "";
|
||||
if (scanning.stage === "tool") return "Downloading yt-dlp…";
|
||||
if (scanning.stage === "runtime")
|
||||
return `Downloading deno…${scanning.total ? ` ${Math.round(((scanning.received ?? 0) / scanning.total) * 100)}%` : ""}`;
|
||||
if (scanning.stage === "audio")
|
||||
return `Fetching the set's audio…${scanning.pct != null ? ` ${Math.round(scanning.pct)}%` : ""}`;
|
||||
if (scanning.stage === "decode") return "Decoding the set…";
|
||||
if (scanning.stage === "listen")
|
||||
return `Listening… window ${scanning.window ?? 0} of ${scanning.total ?? "?"}`;
|
||||
return "Working…";
|
||||
});
|
||||
async function previewPaste() {
|
||||
if (!pasteText.trim() || parsing) return;
|
||||
@@ -45,6 +177,26 @@
|
||||
} else {
|
||||
localDraft = parsed;
|
||||
if (!name) name = parsed.name ?? "";
|
||||
startChecks(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
parsing = false;
|
||||
}
|
||||
}
|
||||
async function previewYoutube() {
|
||||
if (!youtubeUrl.trim() || parsing) return;
|
||||
parsing = true;
|
||||
error = "";
|
||||
try {
|
||||
const parsed = await api.playlistImportYoutube(youtubeUrl.trim());
|
||||
if (!parsed.entries.length) {
|
||||
error = "The set's tracklist parsed to nothing usable.";
|
||||
} else {
|
||||
localDraft = parsed;
|
||||
name = parsed.name ?? name;
|
||||
startChecks(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
@@ -55,11 +207,109 @@
|
||||
|
||||
const kept = $derived.by(() => {
|
||||
if (!working) return [];
|
||||
// Streams are first-class (identified, playable) — the checkbox
|
||||
// only gates the TRUE unknowns.
|
||||
return includeUnresolved
|
||||
? working.entries
|
||||
: working.entries.filter((e) => e.resolution === "local");
|
||||
: working.entries.filter((e) => e.resolution !== "unresolved");
|
||||
});
|
||||
|
||||
// ---- catalog verification of stream rows, live in the review -------
|
||||
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
|
||||
let checkGen = 0;
|
||||
const norm = (s) =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
/// Strict match: at least one exact side (title or artist), both at
|
||||
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
|
||||
/// ties so a famous track beats a namesake.
|
||||
function pickCatalogMatch(tracks, e) {
|
||||
const wantT = norm(e.title);
|
||||
const wantA = norm(e.artist);
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const t of tracks ?? []) {
|
||||
const tT = norm(t.title);
|
||||
const tA = norm(t.artist);
|
||||
let score = 0;
|
||||
if (tT === wantT) score += 4;
|
||||
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
|
||||
if (tA === wantA) score += 4;
|
||||
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
|
||||
if (score < 6) continue;
|
||||
if (
|
||||
e.duration_secs &&
|
||||
t.duration_secs &&
|
||||
Math.abs(t.duration_secs - e.duration_secs) <= 7
|
||||
)
|
||||
score += 2;
|
||||
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
|
||||
/// reads and writes `checks`, and doing that inside an $effect made
|
||||
/// every row verdict re-trigger the effect, which bumped the
|
||||
/// generation and killed its own walk — row one froze at "matching…"
|
||||
/// forever. Explicit kicks from the draft-arrival sites, via a
|
||||
/// microtask, keep reactivity out of the loop.
|
||||
function startChecks(w) {
|
||||
checks = {};
|
||||
const gen = ++checkGen;
|
||||
queueMicrotask(() => void verifyStreams(w, gen));
|
||||
}
|
||||
async function verifyStreams(w, gen) {
|
||||
for (const e of w.entries) {
|
||||
if (e.resolution !== "stream") continue;
|
||||
if (gen !== checkGen || !open) return;
|
||||
checks[e.ordinal] = "checking";
|
||||
try {
|
||||
// Anchor-exact first: a row the listener heard carries the
|
||||
// matched release's ISRC, and one /track/isrc: lookup beats
|
||||
// any name matcher. A miss falls through to the name walk.
|
||||
let hit = null;
|
||||
let anchored = false;
|
||||
if (e.isrc) {
|
||||
hit = await api.catalogTrackByIsrc(e.isrc);
|
||||
if (gen !== checkGen) return;
|
||||
anchored = !!hit;
|
||||
}
|
||||
if (!hit) {
|
||||
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
|
||||
if (gen !== checkGen) return;
|
||||
hit = pickCatalogMatch(res?.tracks, e);
|
||||
}
|
||||
if (hit) {
|
||||
// Attach catalog identity to the row commit will send.
|
||||
e.track_id = Number(hit.id);
|
||||
e.art_url = hit.cover ?? null;
|
||||
if (!e.album) e.album = hit.album ?? null;
|
||||
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
|
||||
checks[e.ordinal] = {
|
||||
album: hit.album ?? "",
|
||||
cover: hit.cover ?? null,
|
||||
anchored,
|
||||
};
|
||||
} else {
|
||||
checks[e.ordinal] = "none";
|
||||
}
|
||||
} catch {
|
||||
if (gen === checkGen) checks[e.ordinal] = "none";
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
const checkedFound = $derived(
|
||||
Object.values(checks).filter((v) => v && v !== "none" && v !== "checking").length,
|
||||
);
|
||||
|
||||
async function commit() {
|
||||
if (!working || committing) return;
|
||||
const finalName = (name || working.name || "Imported playlist").trim();
|
||||
@@ -71,6 +321,7 @@
|
||||
working.description ?? null,
|
||||
working.kind,
|
||||
kept,
|
||||
working.cover_url ?? null,
|
||||
);
|
||||
onimported(id, finalName);
|
||||
} catch (e) {
|
||||
@@ -80,6 +331,7 @@
|
||||
}
|
||||
|
||||
function close() {
|
||||
checkGen += 1; // stop any verification walk
|
||||
open = false;
|
||||
}
|
||||
|
||||
@@ -111,6 +363,8 @@
|
||||
soulstream: "SoulStream backup",
|
||||
text: "Pasted text",
|
||||
folder: "Folder",
|
||||
youtube: "YouTube",
|
||||
listened: "Recognized by ear",
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -132,6 +386,10 @@
|
||||
<h2>
|
||||
{#if working}
|
||||
Review import
|
||||
{:else if pickedFile}
|
||||
Listen to a set recording
|
||||
{:else if mode === "youtube"}
|
||||
Import a DJ set from YouTube
|
||||
{:else}
|
||||
Paste a tracklist
|
||||
{/if}
|
||||
@@ -142,6 +400,144 @@
|
||||
</header>
|
||||
|
||||
{#if !working}
|
||||
{#if pickedFile}
|
||||
<div class="srcfile">
|
||||
<Icon name="music" size={14} />
|
||||
<span class="fname" title={pickedFile.path}>{pickedFile.name}</span>
|
||||
{#if pickedFile.duration_secs != null}
|
||||
<span class="flen">{fmtTime(pickedFile.duration_secs * 1000)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="lead">
|
||||
Listening identifies the tracks by sound. If the set came with a
|
||||
tracklist, paste it too — it names the exact <b>version</b> played
|
||||
where the ear can only name the release it matched, and fills
|
||||
stretches nothing recognized. Leave it empty to listen purely by ear.
|
||||
</p>
|
||||
<textarea
|
||||
class="tl"
|
||||
bind:value={fileTracklist}
|
||||
placeholder={"00:00 Worakls - Red Dressed (Ben Böhmer Remix)\n05:30 Overmono - So U Kno\n…"}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="hint">
|
||||
Optional. Only timestamped lines count — the stamp anchors each
|
||||
name to its place in the mix.
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={() => (pickedFile = null)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={listenPicked}
|
||||
disabled={setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: "Identify the tracks by sound — runs in the background"}
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
</footer>
|
||||
{:else if mode === "youtube"}
|
||||
{#if scanning}
|
||||
<div class="scanbox">
|
||||
<span class="ring"></span>
|
||||
<div class="scanline">{scanLabel}</div>
|
||||
{#if scanning.hit}
|
||||
<div class="scanhit">{scanning.hit}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={cancelScan}>Cancel</button>
|
||||
</footer>
|
||||
{:else}
|
||||
<p class="lead">
|
||||
Paste a set's link. <b>Fetch tracklist</b> reads the video's own
|
||||
chapters, description, or a commented tracklist — free and instant.
|
||||
<b>Listen</b> downloads the audio and identifies the tracks by
|
||||
sound: a few minutes, and unreleased material stays an honest ID.
|
||||
</p>
|
||||
<input
|
||||
class="url"
|
||||
type="text"
|
||||
bind:value={youtubeUrl}
|
||||
placeholder="https://www.youtube.com/watch?v=…"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && previewYoutube()}
|
||||
/>
|
||||
<div class="toolline">
|
||||
{#if tool?.present}
|
||||
<span class="toolok">
|
||||
yt-dlp {tool.version ?? ""}{tool.managed ? "" : " (from PATH)"}
|
||||
</span>
|
||||
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
|
||||
update
|
||||
</button>
|
||||
{:else}
|
||||
<span>Listening needs the downloader tool:</span>
|
||||
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
|
||||
{toolBusy ? "downloading…" : "download yt-dlp (~17 MB, official nightly)"}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="toolsep">·</span>
|
||||
{#if runtimeKind === "deno"}
|
||||
<span class="toolok">deno</span>
|
||||
{:else if runtimeKind}
|
||||
<span>runtime: {runtimeKind}</span>
|
||||
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
|
||||
{denoBusy ? "downloading…" : "get deno (recommended, ~40 MB)"}
|
||||
</button>
|
||||
{:else}
|
||||
<span>and a JS runtime:</span>
|
||||
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
|
||||
{denoBusy ? "downloading…" : "download deno (~40 MB, official release)"}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="toolsep">·</span>
|
||||
<button
|
||||
class="linkbtn"
|
||||
onclick={pickLocalFile}
|
||||
disabled={parsing || picking || setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: ""}
|
||||
>
|
||||
or listen to a local file…
|
||||
</button>
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
onclick={listenYoutube}
|
||||
disabled={!youtubeUrl.trim() ||
|
||||
parsing ||
|
||||
!tool?.present ||
|
||||
!tool?.runtime ||
|
||||
setlistScan.active}
|
||||
title={setlistScan.active
|
||||
? "A listen is already running — see the chip"
|
||||
: !tool?.present
|
||||
? "Download yt-dlp first"
|
||||
: !tool?.runtime
|
||||
? "Download a JS runtime first"
|
||||
: "Identify the tracks by sound — runs in the background"}
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={previewYoutube}
|
||||
disabled={!youtubeUrl.trim() || parsing}
|
||||
>
|
||||
{parsing ? "Fetching…" : "Fetch tracklist"}
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
{:else}
|
||||
<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.
|
||||
@@ -162,15 +558,19 @@
|
||||
{parsing ? "Reading…" : "Preview"}
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
{: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
|
||||
<b class="ok">{working.resolved_count}</b> in library
|
||||
{#if working.stream_count > 0}
|
||||
 · <b class="st">{checkedFound}</b>/{working.stream_count} in catalog
|
||||
{/if}
|
||||
{#if working.unresolved_count > 0}
|
||||
 · <b class="un">{working.unresolved_count}</b> unresolved
|
||||
 · <b class="un">{working.unresolved_count}</b> unknown
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -187,8 +587,8 @@
|
||||
{#each working.entries as e (e.ordinal)}
|
||||
<div
|
||||
class="row"
|
||||
class:unresolved={e.resolution !== "local"}
|
||||
class:excluded={!includeUnresolved && e.resolution !== "local"}
|
||||
class:unresolved={e.resolution === "unresolved"}
|
||||
class:excluded={!includeUnresolved && e.resolution === "unresolved"}
|
||||
>
|
||||
<span class="n">{e.ordinal + 1}</span>
|
||||
{#if working.kind === "setlist"}
|
||||
@@ -215,8 +615,41 @@
|
||||
? "relinked"
|
||||
: "tags"}
|
||||
</span>
|
||||
{:else if e.resolution === "stream"}
|
||||
{#if checks[e.ordinal] === "checking"}
|
||||
<span class="tag st dim">matching…</span>
|
||||
{:else if checks[e.ordinal] && checks[e.ordinal] !== "none"}
|
||||
<span
|
||||
class="tag st"
|
||||
title={`${checks[e.ordinal].anchored ? "Exact recording match (ISRC)" : "Matched in the catalog"}${checks[e.ordinal].album ? ` — ${checks[e.ordinal].album}` : ""} · streams like any catalog track`}
|
||||
>
|
||||
<Icon name="check" size={11} />
|
||||
catalog
|
||||
</span>
|
||||
{:else if checks[e.ordinal] === "none"}
|
||||
<span
|
||||
class="tag un"
|
||||
title="No confident catalog match — still plays as a by-name stream"
|
||||
>
|
||||
not in catalog
|
||||
</span>
|
||||
{:else}
|
||||
<span class="tag un" title="No local match — kept as a placeholder">
|
||||
<span
|
||||
class="tag st dim"
|
||||
title="Identified but not in your library — plays as a stream"
|
||||
>
|
||||
stream
|
||||
</span>
|
||||
{/if}
|
||||
{:else if e.resolution === "segment"}
|
||||
<span
|
||||
class="tag st dim"
|
||||
title="Unidentified — will play this stretch of the set's own recording"
|
||||
>
|
||||
set audio
|
||||
</span>
|
||||
{:else}
|
||||
<span class="tag un" title="Identity too thin to play — kept as a placeholder">
|
||||
unresolved
|
||||
</span>
|
||||
{/if}
|
||||
@@ -242,7 +675,7 @@
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
{#if mode === "paste"}
|
||||
{#if mode === "paste" || mode === "youtube"}
|
||||
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||
@@ -336,6 +769,111 @@
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input.url {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
outline: none;
|
||||
}
|
||||
input.url:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* The local door's picked-file line (B-flow panel). */
|
||||
.srcfile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.srcfile .fname {
|
||||
font-family: Consolas, monospace;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.srcfile .flen {
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
textarea.tl {
|
||||
min-height: 170px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 7px;
|
||||
}
|
||||
.toolline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.toolok {
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.toolsep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.linkbtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.linkbtn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.linkbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.scanbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 42px 0 30px;
|
||||
}
|
||||
.scanline {
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scanhit {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ring {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.srcline {
|
||||
display: flex;
|
||||
@@ -376,6 +914,9 @@
|
||||
.counts .ok {
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.counts .st {
|
||||
color: #58b6e8;
|
||||
}
|
||||
.counts .un {
|
||||
color: #e8a33d;
|
||||
}
|
||||
@@ -465,6 +1006,13 @@
|
||||
background: rgba(62, 207, 126, 0.13);
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.tag.st {
|
||||
background: rgba(88, 182, 232, 0.13);
|
||||
color: #58b6e8;
|
||||
}
|
||||
.tag.dim {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.tag.un {
|
||||
background: rgba(232, 163, 61, 0.15);
|
||||
color: #e8a33d;
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
// 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.
|
||||
//
|
||||
// The open menu is PORTALED to <body> and positioned fixed (the SaveMenu
|
||||
// idiom): triggers live inside transformed hover overlays (.rowbtns,
|
||||
// .rowact) and glass headers, and a transform or backdrop-filter ancestor
|
||||
// both traps z-index and re-anchors position:fixed — the third
|
||||
// menu-trapped-in-a-context episode made this the standing rule. Scoped
|
||||
// styles still apply to the portaled node (the hash class travels with it).
|
||||
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
@@ -10,15 +17,22 @@
|
||||
let {
|
||||
// () => [{ title, artist, album, durationSecs, path, artPath }]
|
||||
tracks,
|
||||
align = "left", // "left" | "right"
|
||||
align = "left", // preferred horizontal edge: "left" | "right" of trigger
|
||||
iconSize = 13,
|
||||
title = "Add to playlist",
|
||||
compact = false, // icon-only trigger
|
||||
exclude = null, // playlist id to omit (a playlist's own rows)
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let busy = $state(false);
|
||||
let doneFor = $state(null); // playlist id that just accepted rows
|
||||
let triggerEl = $state(null);
|
||||
let menuEl = null;
|
||||
|
||||
const targets = $derived(
|
||||
playlistStore.playlists.filter((pl) => pl.id !== exclude),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !playlistStore.loaded) refreshPlaylists();
|
||||
@@ -30,6 +44,56 @@
|
||||
doneFor = null;
|
||||
}
|
||||
|
||||
/** Portal the menu to <body>, then place it against the trigger: below
|
||||
* and edge-aligned, flipped above when the viewport runs out. Measured
|
||||
* after mount so the content-driven width is what gets clamped. */
|
||||
function overlay(node) {
|
||||
document.body.appendChild(node);
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
const mw = node.offsetWidth;
|
||||
const mh = node.offsetHeight;
|
||||
let left = align === "right" ? r.right - mw : r.left;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
|
||||
let top = r.bottom + 7;
|
||||
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - 7 - mh);
|
||||
node.style.top = `${top}px`;
|
||||
node.style.left = `${left}px`;
|
||||
node.style.visibility = "visible";
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Close on outside click, Escape, resize, or any scroll OUTSIDE the menu
|
||||
// (the playlist list scrolls inside it) — a fixed menu must not drift away
|
||||
// from its trigger.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => {
|
||||
if (menuEl?.contains(e.target) || triggerEl?.contains(e.target)) return;
|
||||
open = false;
|
||||
};
|
||||
const onScroll = (e) => {
|
||||
if (menuEl && (e.target === menuEl || menuEl.contains(e.target))) return;
|
||||
open = false;
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") open = false;
|
||||
};
|
||||
document.addEventListener("click", onDoc, true);
|
||||
document.addEventListener("scroll", onScroll, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
return () => {
|
||||
document.removeEventListener("click", onDoc, true);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
});
|
||||
|
||||
function payload() {
|
||||
return (tracks() ?? []).map((t) => ({
|
||||
title: t.title ?? "",
|
||||
@@ -38,6 +102,11 @@
|
||||
duration_secs: t.durationSecs ?? t.duration_secs ?? null,
|
||||
path: t.path ?? null,
|
||||
art_path: t.artPath ?? t.art_path ?? null,
|
||||
// A pathless row is a STREAM row; these anchor it — the catalog
|
||||
// track id for identity, the cover URL for artwork it has no
|
||||
// local file to read from.
|
||||
track_id: t.trackId != null ? String(t.trackId) : (t.track_id ?? null),
|
||||
art_url: t.artUrl ?? t.art_url ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -84,16 +153,11 @@
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function windowClick() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={open ? windowClick : undefined} />
|
||||
|
||||
<div class="plm">
|
||||
<div class="plm" onkeydown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
class="plm-trigger"
|
||||
class:compact
|
||||
{title}
|
||||
@@ -104,16 +168,25 @@
|
||||
<Icon name="list-plus" size={iconSize} />
|
||||
{#if !compact}<span>Add to playlist</span>{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
</div>
|
||||
{#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()}>
|
||||
<div
|
||||
class="plm-menu"
|
||||
bind:this={menuEl}
|
||||
use:overlay
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="plm-item new" onclick={addToNew} disabled={busy}>
|
||||
<Icon name="plus" size={14} /> New playlist
|
||||
</button>
|
||||
{#if playlistStore.playlists.length}
|
||||
{#if targets.length}
|
||||
<div class="plm-rule"></div>
|
||||
<div class="plm-list">
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
{#each targets 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>
|
||||
@@ -127,8 +200,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.plm {
|
||||
@@ -145,23 +217,20 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
/* Fixed positioning escapes overflow clipping and every stacking context;
|
||||
placed (and made visible) by the overlay action once measured. */
|
||||
.plm-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 7px);
|
||||
left: 0;
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
min-width: 210px;
|
||||
max-width: 270px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
z-index: 80;
|
||||
z-index: 1000;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script>
|
||||
// The listen's presence while the import modal is closed — the import
|
||||
// toast's dark glass pill, one berth higher so both can show at once.
|
||||
// Running: live stage + window count + a cancel. Done: click to open
|
||||
// the review. Error: what happened + dismiss.
|
||||
import { goToView } from "../lib/state.svelte.js";
|
||||
import { setlistScan, dismissScan } from "../lib/setlistScan.svelte.js";
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (setlistScan.error) return setlistScan.error;
|
||||
if (setlistScan.draft) {
|
||||
const n = setlistScan.draft.entries?.length ?? 0;
|
||||
return `${setlistScan.label} — ${n} row${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
switch (setlistScan.stage) {
|
||||
case "tool":
|
||||
return "Fetching yt-dlp…";
|
||||
case "runtime":
|
||||
return "Fetching deno…";
|
||||
case "audio":
|
||||
return `Fetching the set's audio…${
|
||||
setlistScan.pct != null ? ` ${Math.round(setlistScan.pct)}%` : ""
|
||||
}`;
|
||||
case "decode":
|
||||
return "Decoding the set…";
|
||||
case "listen":
|
||||
return `Listening… ${setlistScan.window} of ${setlistScan.total || "?"}`;
|
||||
default:
|
||||
return "Listening…";
|
||||
}
|
||||
});
|
||||
|
||||
function review() {
|
||||
setlistScan.wantsReview = true;
|
||||
goToView("playlists");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if setlistScan.active}
|
||||
<div class="layer">
|
||||
<div class="toast" class:err={!!setlistScan.error}>
|
||||
{#if setlistScan.draft}
|
||||
<button class="body" onclick={review} title="Open the review">
|
||||
<span class="mark ok">✓</span>
|
||||
<span>{label} — <b>review</b></span>
|
||||
</button>
|
||||
<button
|
||||
class="x"
|
||||
onclick={dismissScan}
|
||||
title="Dismiss — the hearing is remembered, a re-listen is instant"
|
||||
>×</button>
|
||||
{:else if setlistScan.error}
|
||||
<span class="body">
|
||||
<span class="mark warn">!</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<button class="x" onclick={dismissScan} title="Dismiss">×</button>
|
||||
{:else}
|
||||
<span class="body">
|
||||
<span class="mark pulse">▸</span>
|
||||
<span>{label}</span>
|
||||
{#if setlistScan.stage === "listen" && setlistScan.hit}
|
||||
<span class="hit">{setlistScan.hit}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button class="x" onclick={dismissScan} title="Cancel the listen">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The import toast's berth, one pill higher, so both can show at once. */
|
||||
.layer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--player-height, 62px) + 8px + 26px + 44px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px 5px 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(22, 23, 28, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
animation: chip-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.toast.err {
|
||||
border-color: rgba(240, 205, 119, 0.4);
|
||||
}
|
||||
.body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 4px 8px 4px 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
max-width: 520px;
|
||||
}
|
||||
button.body {
|
||||
cursor: pointer;
|
||||
border-radius: 99px;
|
||||
}
|
||||
button.body:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.hit {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
.x {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.mark {
|
||||
flex: none;
|
||||
font-style: normal;
|
||||
}
|
||||
.mark.pulse {
|
||||
color: var(--accent);
|
||||
animation: chip-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.mark.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mark.warn {
|
||||
color: #f0cd77;
|
||||
}
|
||||
b {
|
||||
font-weight: 800;
|
||||
}
|
||||
@keyframes chip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
.mark.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+42
-4
@@ -73,7 +73,8 @@ export const showInFolder = (path) => invoke("show_in_folder", { path });
|
||||
export const refreshArtistArt = () => invoke("refresh_artist_art");
|
||||
export const playStream = (username, path, size, duration) =>
|
||||
invoke("play_stream", { username, path, size, duration });
|
||||
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
|
||||
export const playLocal = (path, duration, startSecs = null) =>
|
||||
invoke("play_local", { path, duration, startSecs });
|
||||
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
|
||||
export const playerControl = (action, value, rate) =>
|
||||
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
|
||||
@@ -81,7 +82,8 @@ export const playerControl = (action, value, rate) =>
|
||||
// playback from events, so after a reload this is the only way to find out that
|
||||
// audio is still running — see `restoreSession`.
|
||||
export const playerState = () => invoke("player_state");
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
|
||||
invoke("waveform", { path, buckets, startSecs, endSecs });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
@@ -170,6 +172,8 @@ export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
||||
durationSecs: fields.durationSecs ?? null,
|
||||
path: fields.path ?? null,
|
||||
artPath: fields.artPath ?? null,
|
||||
trackId: fields.trackId ?? null,
|
||||
artUrl: fields.artUrl ?? null,
|
||||
});
|
||||
// Cover as a data URL: custom master, else composed mosaic, else null.
|
||||
export const playlistCover = (id, maxEdge = null) =>
|
||||
@@ -181,11 +185,44 @@ export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id })
|
||||
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 const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
||||
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
|
||||
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
||||
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
|
||||
// The local door is split pick/scan so the modal can collect an optional
|
||||
// pasted tracklist between the two.
|
||||
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
|
||||
export const playlistScanFile = (path, tracklist = null) =>
|
||||
invoke("playlist_scan_file", { path, tracklist });
|
||||
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
|
||||
export const ytdlpStatus = () => invoke("ytdlp_status");
|
||||
export const ytdlpDownload = () => invoke("ytdlp_download");
|
||||
export const denoDownload = () => invoke("deno_download");
|
||||
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
|
||||
invoke("playlist_import_commit", {
|
||||
name,
|
||||
description: description ?? null,
|
||||
kind,
|
||||
entries,
|
||||
coverUrl,
|
||||
});
|
||||
// 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 });
|
||||
|
||||
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
|
||||
export const lyricModelStatus = () => invoke("lyric_model_status");
|
||||
export const lyricModelDownload = () => invoke("lyric_model_download");
|
||||
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
|
||||
export const vocalSepStatus = () => invoke("vocal_sep_status");
|
||||
export const vocalSepDownload = () => invoke("vocal_sep_download");
|
||||
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
|
||||
invoke("lyric_prep_request", {
|
||||
path: path ?? null,
|
||||
stream,
|
||||
durationSecs: durationSecs ?? null,
|
||||
lines,
|
||||
});
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
|
||||
@@ -220,6 +257,7 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
|
||||
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 catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
|
||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
||||
|
||||
Binary file not shown.
@@ -59,6 +59,7 @@ const ROOTS = new Set([
|
||||
"library",
|
||||
"downloads",
|
||||
"settings",
|
||||
"playlists",
|
||||
"catalog",
|
||||
]);
|
||||
|
||||
@@ -248,6 +249,18 @@ export function trail() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A playlist sits under the Playlists shelf the same way. The shelf is a
|
||||
// root, so when the page was reached through it the origin crumb already
|
||||
// says Playlists; this branch covers the direct arrivals (a pinned
|
||||
// playlist in the sidebar) by borrowing the shelf from history or
|
||||
// synthesising it.
|
||||
if (here.view === "playlist" && nav.origin?.view !== "playlists") {
|
||||
const known = [...nav.stack].reverse().find((e) => e.view === "playlists");
|
||||
crumbs.push(
|
||||
known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" },
|
||||
);
|
||||
}
|
||||
crumbs.push(here);
|
||||
return crumbs;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Background DJ-set listening. A scan OUTLIVES the import modal: starting a
|
||||
// listen closes the modal, progress rides a global chip above the player
|
||||
// bar, the app stays fully usable, and the finished draft waits on the chip
|
||||
// until it is reviewed or dismissed. One scan at a time — the backend's
|
||||
// cancel flag is global by design.
|
||||
import * as api from "./api.js";
|
||||
|
||||
export const setlistScan = $state({
|
||||
active: false,
|
||||
mode: null, // "youtube" | "file"
|
||||
label: "",
|
||||
stage: null, // "tool" | "runtime" | "audio" | "decode" | "listen"
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null, // finished ImportDraft awaiting review
|
||||
error: null,
|
||||
wantsReview: false, // chip clicked; the Playlists view consumes this
|
||||
});
|
||||
|
||||
let subscribed = false;
|
||||
function subscribe() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
api.onEvent("playlist:scan-progress", (p) => {
|
||||
// A ready chip must not be mutated by stray late events.
|
||||
if (!setlistScan.active || setlistScan.draft || !p?.stage) return;
|
||||
setlistScan.stage = p.stage;
|
||||
if (p.window != null) setlistScan.window = p.window;
|
||||
if (p.total != null) setlistScan.total = p.total;
|
||||
setlistScan.pct = p.pct ?? null;
|
||||
if (p.hit != null) setlistScan.hit = p.hit;
|
||||
});
|
||||
}
|
||||
|
||||
async function run(mode, label, call) {
|
||||
subscribe();
|
||||
Object.assign(setlistScan, {
|
||||
active: true,
|
||||
mode,
|
||||
label,
|
||||
stage: mode === "youtube" ? "audio" : "decode",
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null,
|
||||
error: null,
|
||||
wantsReview: false,
|
||||
});
|
||||
try {
|
||||
const draft = await call();
|
||||
if (draft) {
|
||||
setlistScan.draft = draft;
|
||||
setlistScan.label = draft.name ?? label;
|
||||
} else {
|
||||
// Defensive: a null draft must not wedge the chip.
|
||||
setlistScan.active = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = String(e);
|
||||
if (message.includes("canceled")) setlistScan.active = false;
|
||||
else setlistScan.error = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick a YouTube listen into the background. False = one already runs. */
|
||||
export function startYoutubeListen(url) {
|
||||
if (setlistScan.active) return false;
|
||||
void run("youtube", "YouTube set", () => api.playlistScanYoutube(url));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Listen to an already-picked set recording in the background, with an
|
||||
* optionally pasted tracklist riding along. False = one already runs. */
|
||||
export function startFileListen(path, tracklist = null, label = "Set recording") {
|
||||
if (setlistScan.active) return false;
|
||||
void run("file", label, () => api.playlistScanFile(path, tracklist));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hand the finished draft to the review and clear the chip. */
|
||||
export function takeScanDraft() {
|
||||
const draft = setlistScan.draft;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.active = false;
|
||||
setlistScan.wantsReview = false;
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Cancel a running scan, or drop a finished/failed one unseen. */
|
||||
export function dismissScan() {
|
||||
if (setlistScan.active && !setlistScan.draft && !setlistScan.error) {
|
||||
void api.playlistScanCancel();
|
||||
}
|
||||
setlistScan.active = false;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.error = null;
|
||||
setlistScan.wantsReview = false;
|
||||
}
|
||||
+275
-27
@@ -1624,7 +1624,9 @@ const VIEW_LABELS = {
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
settings: "Settings",
|
||||
playlists: "Playlists",
|
||||
playlist: "Playlist",
|
||||
lyrics: "Lyrics",
|
||||
};
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
@@ -1632,7 +1634,9 @@ const VIEW_GLYPHS = {
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
settings: "settings",
|
||||
playlists: "playlist",
|
||||
playlist: "music",
|
||||
lyrics: "music",
|
||||
};
|
||||
|
||||
/** Re-open a recorded place. The one function the history arrows and the trail
|
||||
@@ -3384,10 +3388,37 @@ export async function saveStream(scope) {
|
||||
|
||||
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||||
function currentArtistAlbum() {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
|
||||
// so its own fields are the whole identity — splitting would offer the
|
||||
// playlist name as an artist (and negative-cache the click into silence).
|
||||
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
|
||||
const sub = player.track?.sub ?? "";
|
||||
return sub.includes(" — ")
|
||||
const split = sub.includes(" — ")
|
||||
? sub.split(" — ").map((s) => s.trim())
|
||||
: [sub.trim(), ""];
|
||||
// Per field, the item's own artist/album win — the player bar's rule.
|
||||
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
|
||||
}
|
||||
|
||||
/** Anchor-exact album jump for a row that knows its catalog track id but not
|
||||
* its record (playlist stream rows from before commits stored albums): one
|
||||
* name search, then the ID picks the track — never a name guess. */
|
||||
async function openAlbumForCurrentTrackId(opts = {}) {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
const trackId = item?.trackId;
|
||||
if (trackId == null || !item?.artist || !item?.title) return false;
|
||||
try {
|
||||
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
|
||||
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
|
||||
if (hit?.album_id != null) {
|
||||
openCatalogAlbum(hit.album_id, { trackId, ...opts });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the name path
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
|
||||
@@ -3395,15 +3426,16 @@ export function openCurrentArtist() {
|
||||
const [artist] = currentArtistAlbum();
|
||||
if (artist) openArtistByName(artist);
|
||||
}
|
||||
export function openCurrentAlbum() {
|
||||
export async function openCurrentAlbum() {
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId);
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId()) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album);
|
||||
}
|
||||
export function openCurrentSong() {
|
||||
export async function openCurrentSong() {
|
||||
const trackTitle = player.track?.title;
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId, {
|
||||
@@ -3412,6 +3444,7 @@ export function openCurrentSong() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album, { trackTitle });
|
||||
}
|
||||
@@ -3827,6 +3860,18 @@ async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
for (const key of streamSourceKeys(trackId, artist, title)) {
|
||||
const src = map.get(key);
|
||||
if (!src) continue;
|
||||
// A remembered file whose NAME does not even contain the track's
|
||||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||||
// played it from the warm cache forever). Delete it and resolve
|
||||
// normally — a wrongly-dropped legit source only costs one search.
|
||||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||||
map.delete(key);
|
||||
saveStreamSources();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||||
} catch {
|
||||
@@ -5542,7 +5587,7 @@ export function initEvents() {
|
||||
// the reader back where they were. Taken exactly once, so an
|
||||
// ordinary resume later in the same track is unaffected.
|
||||
const seekTo = takeResumeSeek();
|
||||
if (seekTo != null) api.playerControl("seek", seekTo);
|
||||
if (seekTo != null) seekPlayer(seekTo);
|
||||
break;
|
||||
}
|
||||
case "paused":
|
||||
@@ -5559,7 +5604,19 @@ export function initEvents() {
|
||||
if (ev.gapless) advanceGaplessly();
|
||||
else nextTrack(true);
|
||||
break;
|
||||
case "position":
|
||||
case "position": {
|
||||
// A set-recording slice shows slice-relative time, keeps its
|
||||
// own duration (the engine reports the whole set's), and
|
||||
// cuts to the next row at its end boundary.
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
if (seg) {
|
||||
const rel = Math.max(0, ev.secs - seg.start);
|
||||
latestPlayerPosition = rel;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = rel;
|
||||
listenTick(rel);
|
||||
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
|
||||
break;
|
||||
}
|
||||
latestPlayerPosition = ev.secs;
|
||||
if (ev.duration != null) latestPlayerDuration = ev.duration;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
|
||||
@@ -5568,6 +5625,7 @@ export function initEvents() {
|
||||
}
|
||||
listenTick(ev.secs);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
player.status = "idle";
|
||||
player.error = ev.message;
|
||||
@@ -5593,8 +5651,7 @@ export function initEvents() {
|
||||
// Fully cached → final waveform, and the peer's slot is
|
||||
// definitely free, so make sure the next track is pre-buffering.
|
||||
if (cur?.stream) loadStreamWaveform(cur, true);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
} else if (
|
||||
ui.windowActive
|
||||
&& cur?.stream
|
||||
@@ -5823,22 +5880,33 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||||
}
|
||||
|
||||
/** Play an album/playlist (list of library tracks) starting at `start`.
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path. */
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path.
|
||||
*
|
||||
* A row with `needsResolve` is a playlist STREAM row: full identity, no
|
||||
* source yet. It queues as a pending item that resolves through the normal
|
||||
* ladder when reached (or by the look-ahead a track early), so Play on a
|
||||
* mixed playlist keeps every row in order instead of skipping the streams. */
|
||||
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
||||
autoAlbum = null;
|
||||
player.queue = tracks.map((t) => ({
|
||||
path: t.path,
|
||||
path: t.path ?? null,
|
||||
title: t.title,
|
||||
sub: subLabel,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration: t.duration_secs ?? null,
|
||||
art: t.art_path ?? artPath,
|
||||
quality: localQuality(t),
|
||||
art: t.art_path ?? (t.needsResolve ? null : artPath),
|
||||
artUrl: t.artUrl ?? null,
|
||||
trackId: t.trackId ?? null,
|
||||
quality: t.path ? localQuality(t) : null,
|
||||
// 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,
|
||||
// A set-recording SLICE: {start, end} seconds into the file at
|
||||
// `path` (end null = to the file's end). The playback layer seeks
|
||||
// in at start and cuts at end; display time is slice-relative.
|
||||
segment: t.segment ?? null,
|
||||
}));
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
playQueueIndex(start);
|
||||
@@ -5863,6 +5931,15 @@ function playQueueIndex(i) {
|
||||
};
|
||||
player.status = "buffering";
|
||||
player.duration = item.duration;
|
||||
// A playlist stream row queued without a source yet: resolve it now, then
|
||||
// re-enter with the source attached. The item mutates in place, so a
|
||||
// repeat visit (prev/next, repeat-all) is instant.
|
||||
if (!item.path && !item.stream) {
|
||||
player.track.source = "stream";
|
||||
player.streamTotal = 0;
|
||||
void resolvePendingQueueItem(i, item);
|
||||
return;
|
||||
}
|
||||
if (item.stream) {
|
||||
// Remote track streamed from a peer while it downloads to cache.
|
||||
rememberStreamSource(item);
|
||||
@@ -5881,8 +5958,7 @@ function playQueueIndex(i) {
|
||||
.then(() => {
|
||||
// Ordered behind play_stream so the gapless append can't
|
||||
// race the generation bump on the audio thread.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
})
|
||||
.catch((e) => {
|
||||
player.error = String(e);
|
||||
@@ -5891,12 +5967,27 @@ function playQueueIndex(i) {
|
||||
return;
|
||||
}
|
||||
player.streamTotal = 0;
|
||||
if (item.segment) {
|
||||
// A set-recording slice: the lane shows the SLICE's own waveform (a
|
||||
// ranged decode — a 90-minute skyline says nothing about a 4-minute
|
||||
// stretch), and the decoder comes up at the slice start. The early
|
||||
// cut at segment.end is enforced by the position handler.
|
||||
loadWaveform(item.path, item.segment);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
|
||||
// a pending row after one still resolves a track early.
|
||||
prepareFollowing();
|
||||
return;
|
||||
}
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
/** Play a specific queue position (Now Playing panel track clicks). */
|
||||
@@ -5910,14 +6001,23 @@ function gaplessNextIndex() {
|
||||
if (player.shuffle) return -1;
|
||||
if (player.repeat === "one") return player.queueIndex;
|
||||
const next = player.queueIndex + 1;
|
||||
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
// Set-recording slices never join the gapless machinery: a preload or
|
||||
// append would come up at the FILE's start, not the slice's, and the
|
||||
// slice's early cut is driven by the position handler, not the
|
||||
// engine's track-ended.
|
||||
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
|
||||
return -1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function preloadFollowingTrack() {
|
||||
const next = gaplessNextIndex();
|
||||
if (next < 0) return;
|
||||
const item = player.queue[next];
|
||||
if (item.stream) return; // gapless preload is local-only
|
||||
// Gapless preload is local-only; a pending stream row has no path at all.
|
||||
if (item.stream || !item.path) return;
|
||||
api.preloadLocal(item.path, item.duration).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -5967,6 +6067,21 @@ async function tryAppendNext() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Arm every next-track preparation that applies: a LOCAL next preloads
|
||||
* into the sink, a resolved STREAM next pre-buffers and offers the
|
||||
* gapless append, and a PENDING playlist row resolves a track early so
|
||||
* it can join the same chain. Each is a cheap no-op when it does not
|
||||
* apply — call this at every track start and every gapless boundary,
|
||||
* whatever the current source is. (The playlist lesson: only the
|
||||
* album-shaped paths were armed, so a mixed playlist went seamless for
|
||||
* exactly one boundary and stalled on every one after it.) */
|
||||
function prepareFollowing() {
|
||||
preloadFollowingTrack();
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
scheduleQueueLookahead();
|
||||
}
|
||||
|
||||
function advanceGaplessly() {
|
||||
const next = gaplessNextIndex();
|
||||
// The backend only labels a boundary gapless when it has this source
|
||||
@@ -6000,15 +6115,14 @@ function advanceGaplessly() {
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
loadStreamWaveform(item);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
scheduleAlbumFill();
|
||||
return;
|
||||
}
|
||||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
// ---- album continuation ----
|
||||
@@ -6164,6 +6278,118 @@ async function maybeExtendAlbumQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pending playlist stream rows -------------------------------------------
|
||||
// A mixed playlist queues its stream rows as PENDING items — identity, no
|
||||
// source. They resolve through the same ladder the album fill uses: the
|
||||
// remembered-source memory first (free), then the sanitized search. One
|
||||
// resolution is in flight per item, shared between play-on-reach and the
|
||||
// look-ahead, so hammering next can't stack searches for one row.
|
||||
|
||||
const pendingResolves = new WeakMap();
|
||||
|
||||
/** Resolve one pending descriptor to a playable source, or null. */
|
||||
async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||||
if (known) {
|
||||
return {
|
||||
path: known.path,
|
||||
duration: known.duration ?? t.duration ?? null,
|
||||
stream: { username: known.username, size: known.size },
|
||||
quality: known.quality ?? null,
|
||||
};
|
||||
}
|
||||
if (app.connection !== "online") return null;
|
||||
const goodCount = (rs) =>
|
||||
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
|
||||
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||||
let artistIncluded = true;
|
||||
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
|
||||
if (!responses.length && fallback && fallback !== primary) {
|
||||
responses = await collectSources(fallback, budgetMs, goodCount);
|
||||
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
|
||||
} else if (primary) {
|
||||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||||
}
|
||||
const best = songCandidates(
|
||||
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||||
)[0];
|
||||
if (!best) return null;
|
||||
return {
|
||||
path: best.file.path,
|
||||
duration: best.file.duration ?? t.duration ?? null,
|
||||
stream: { username: best.username, size: best.file.size },
|
||||
quality: qualityOf(best.file),
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-flight source resolution for a queue item; mutates it in place. */
|
||||
function resolveQueueItem(item) {
|
||||
const inFlight = pendingResolves.get(item);
|
||||
if (inFlight) return inFlight;
|
||||
const p = resolveStreamSourceFor(item)
|
||||
.then((src) => {
|
||||
if (src) Object.assign(item, src);
|
||||
return src;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => pendingResolves.delete(item));
|
||||
pendingResolves.set(item, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Two sourceless rows in a row is a signal (a whole stretch that cannot
|
||||
// resolve), and auto-advancing through a long dead playlist would fire one
|
||||
// Soulseek search per row — the exact flood the album fill's delay exists to
|
||||
// prevent. The streak resets on any success.
|
||||
let pendingFailStreak = 0;
|
||||
|
||||
async function resolvePendingQueueItem(i, item) {
|
||||
const src = await resolveQueueItem(item);
|
||||
// The world may have moved on mid-search: queue replaced, or the user is
|
||||
// on another row. An enriched item keeps its source either way.
|
||||
if (player.queue[i] !== item || player.queueIndex !== i) return;
|
||||
if (src) {
|
||||
pendingFailStreak = 0;
|
||||
playQueueIndex(i);
|
||||
return;
|
||||
}
|
||||
item.unplayable = true;
|
||||
player.error = `No streamable sources found for “${item.title}”`;
|
||||
pendingFailStreak += 1;
|
||||
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
|
||||
pendingFailStreak = 0;
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
nextTrack(true);
|
||||
}
|
||||
|
||||
// Resolve the NEXT pending row a track early, on the album fill's own
|
||||
// anti-flood delay, so the boundary into a stream row starts instantly
|
||||
// instead of stalling on a live search.
|
||||
let queueLookaheadTimer = null;
|
||||
function scheduleQueueLookahead() {
|
||||
clearTimeout(queueLookaheadTimer);
|
||||
queueLookaheadTimer = setTimeout(() => {
|
||||
queueLookaheadTimer = null;
|
||||
void resolveNextPendingItem();
|
||||
}, ALBUM_FILL_DELAY_MS);
|
||||
}
|
||||
|
||||
async function resolveNextPendingItem() {
|
||||
if (player.shuffle) return; // the next index is unknowable
|
||||
const next = player.queueIndex + 1;
|
||||
const item = player.queue[next];
|
||||
if (!item || item.path || item.stream || item.unplayable) return;
|
||||
const src = await resolveQueueItem(item);
|
||||
if (!src || !player.queue.includes(item)) return;
|
||||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||||
// append, exactly as if it had been queued with its source.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
}
|
||||
|
||||
// ---- waveform ----
|
||||
|
||||
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||||
@@ -6177,24 +6403,32 @@ let waveReqPath = null;
|
||||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||||
let waveRefreshAt = 0;
|
||||
|
||||
async function loadWaveform(path) {
|
||||
async function loadWaveform(path, range = null) {
|
||||
player.waveform = [];
|
||||
player.wavePending = !!path;
|
||||
waveReqPath = path;
|
||||
// A range = a set-recording slice's lane; each slice keys its own
|
||||
// request so two slices of one file never answer for each other.
|
||||
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
|
||||
waveReqPath = req;
|
||||
if (!path) return;
|
||||
try {
|
||||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||||
const peaks = await api.waveform(
|
||||
path,
|
||||
WAVE_BUCKETS,
|
||||
range?.start ?? null,
|
||||
range?.end ?? null,
|
||||
);
|
||||
// Ignore if the track changed while we were computing.
|
||||
if (waveReqPath === path) {
|
||||
if (waveReqPath === req) {
|
||||
player.waveform = peaks;
|
||||
player.waveformCoverage = 1;
|
||||
}
|
||||
} catch {
|
||||
if (waveReqPath === path) player.waveform = [];
|
||||
if (waveReqPath === req) player.waveform = [];
|
||||
} finally {
|
||||
// Either way the request is over; a failed local decode rests as dots
|
||||
// rather than swelling forever at a waveform that will never come.
|
||||
if (waveReqPath === path) player.wavePending = false;
|
||||
if (waveReqPath === req) player.wavePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6314,15 +6548,29 @@ export function nextTrack(auto = false) {
|
||||
playQueueIndex(next);
|
||||
}
|
||||
|
||||
/** Engine time for a TRACK-time value. A set-recording slice's engine
|
||||
* clock is the whole file's, so the slice offset is added exactly here —
|
||||
* every transport call that hands the engine a time (seek, scratch,
|
||||
* scratch release) must convert through this door. */
|
||||
export function engineTime(secs) {
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
return seg ? seg.start + Math.max(0, secs) : secs;
|
||||
}
|
||||
|
||||
/** Seek in TRACK time; see `engineTime`. */
|
||||
export function seekPlayer(secs) {
|
||||
api.playerControl("seek", engineTime(secs));
|
||||
}
|
||||
|
||||
export function prevTrack() {
|
||||
// Restart the current track if we're more than 3s in.
|
||||
if (player.position > 3) {
|
||||
api.playerControl("seek", 0);
|
||||
seekPlayer(0);
|
||||
return;
|
||||
}
|
||||
const prev = player.queueIndex - 1;
|
||||
if (prev >= 0) playQueueIndex(prev);
|
||||
else api.playerControl("seek", 0);
|
||||
else seekPlayer(0);
|
||||
}
|
||||
|
||||
export function toggleShuffle() {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||
const album = $derived(page?.album ?? null);
|
||||
@@ -132,18 +133,23 @@
|
||||
* library keeps albums, not songs, so the owned tracklist has to be read
|
||||
* from the matching local album rather than filtered out of a song list. */
|
||||
let libraryTicks = $state(new Set());
|
||||
/** The same rows with their paths kept, so a playlist add can point at the
|
||||
* owned file instead of a stream placeholder. */
|
||||
let libraryRows = $state([]);
|
||||
let ticksFor = null;
|
||||
$effect(() => {
|
||||
const id = localAlbum?.id ?? null;
|
||||
if (id === ticksFor) return;
|
||||
ticksFor = id;
|
||||
libraryTicks = new Set();
|
||||
libraryRows = [];
|
||||
if (id == null) return;
|
||||
let stale = false;
|
||||
api.albumTracks(id)
|
||||
.then((rows) => {
|
||||
if (!stale && ticksFor === id) {
|
||||
libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
|
||||
libraryRows = rows;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -152,6 +158,40 @@
|
||||
};
|
||||
});
|
||||
|
||||
// ---- add to playlist ----------------------------------------------------
|
||||
/** Local files for this record by normalized title — a local page carries
|
||||
* them in `page.libraryTracks`; an owned catalog page gets them from the
|
||||
* ticks fetch above. */
|
||||
const localByTitle = $derived.by(() => {
|
||||
const m = new Map();
|
||||
for (const lt of page?.libraryTracks ?? []) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
for (const lt of libraryRows) {
|
||||
const k = norm(lt.title || "");
|
||||
if (k && !m.has(k)) m.set(k, lt);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
/** A playlist row for a track on this record. Owned tracks go in as the
|
||||
* file on disk; the rest are placeholders that stream on click. */
|
||||
function playlistRow(t) {
|
||||
const local = localByTitle.get(norm(t.title || ""));
|
||||
return {
|
||||
title: t.title,
|
||||
artist: album?.artist ?? null,
|
||||
album: album?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
|
||||
path: local?.path ?? null,
|
||||
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
|
||||
// Stream anchors, mirroring what playTrack hands the resolver. A
|
||||
// local record's cover is a file path, not a URL — omit it there.
|
||||
trackId: t.id ?? null,
|
||||
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
// An upcoming album that still has unreleased songs can't be streamed or
|
||||
// saved as a whole — only its already-released singles play (per track).
|
||||
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
|
||||
@@ -804,6 +844,12 @@
|
||||
<span class="badge" aria-hidden="true">{previewable}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={17}
|
||||
title="Add this record to a playlist"
|
||||
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -900,6 +946,14 @@
|
||||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||||
{/if}
|
||||
<span class="rowbtns">
|
||||
{#if !locked}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [playlistRow(t)]}
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="mini-btn play"
|
||||
disabled={locked}
|
||||
@@ -1364,6 +1418,20 @@
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
cursor: default;
|
||||
}
|
||||
/* The add-to-playlist trigger wears the iconbtn look — the component
|
||||
itself stays unstyled chrome (the Library rail set the precedent). */
|
||||
.actions :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.actions :global(.plm-trigger:hover) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
/* Count for a record only some of whose tracks have a clip — the same
|
||||
partial-availability report the artist page makes. */
|
||||
.badge {
|
||||
@@ -1745,10 +1813,14 @@
|
||||
}
|
||||
.trow:hover .tdur,
|
||||
.trow:focus-within .tdur {
|
||||
/* pointer-events too: the faded duration is a stacking context and
|
||||
must never eat clicks meant for the buttons over it. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow.locked:hover .tdur {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tag-unreleased {
|
||||
@@ -1799,6 +1871,40 @@
|
||||
.rowbtns .play {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* Three buttons now reach past the duration column into the listeners
|
||||
bar on counted records, so the cluster carries its own fade-in shim —
|
||||
on the hover highlight it reads as the buttons simply taking over. */
|
||||
.rowbtns {
|
||||
padding-left: 14px;
|
||||
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
|
||||
}
|
||||
.trow:focus-within {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rowbtns :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
/* While its menu is open the pointer is off in the portaled dropdown, so
|
||||
hover alone would drop the cluster mid-interaction. */
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
|
||||
display: flex;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||||
|
||||
@@ -5071,6 +5072,19 @@
|
||||
<span class="track-album">{track.album}</span>
|
||||
<span class="lis" class:unknown={track.listeners == null} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>{fmtListeners(track.listeners)}</span>
|
||||
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: track.title,
|
||||
artist: page.artist.name,
|
||||
album: track.album ?? null,
|
||||
duration_secs: track.duration_secs ?? null,
|
||||
trackId: track.id ?? null,
|
||||
artUrl: track.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="mini-get"
|
||||
title="Save to library"
|
||||
@@ -5425,6 +5439,19 @@
|
||||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||||
{/if}
|
||||
{#if !locked}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={13}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: t.title,
|
||||
artist: page.artist.name,
|
||||
album: openAlbum?.title ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
trackId: t.id ?? null,
|
||||
artUrl: t.cover ?? openAlbum?.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="mini-btn play"
|
||||
title="Play"
|
||||
@@ -7633,6 +7660,11 @@
|
||||
}
|
||||
|
||||
.popular-row {
|
||||
/* The anchor the absolute row actions were always written against —
|
||||
without it the save button resolved against the page and its
|
||||
reveal rules never existed, so it was dead UI. The 24px last
|
||||
column is the space the overlaid actions land on. */
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px;
|
||||
align-items: center;
|
||||
@@ -7647,6 +7679,56 @@
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Hover slides the actions over the right end and the duration gets out
|
||||
of the way — the album page's treatment. The listener count stays: the
|
||||
list is ordered by it. */
|
||||
.popular-row :global(.plm) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 36px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.popular-row :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.popular-row :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.popular-row:hover .mini-get,
|
||||
.popular-row:focus-within .mini-get,
|
||||
.popular-row:hover :global(.plm),
|
||||
.popular-row:focus-within :global(.plm) {
|
||||
opacity: 1;
|
||||
}
|
||||
.popular-row:hover .tdur,
|
||||
.popular-row:focus-within .tdur {
|
||||
/* pointer-events too: the faded duration is a stacking context and
|
||||
must never eat clicks meant for the buttons over it. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* While its menu is open the pointer is off in the portaled dropdown, so
|
||||
hover alone would drop the cluster mid-interaction. */
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
|
||||
opacity: 1;
|
||||
}
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.popular-row.now .rank,
|
||||
.popular-row.now .track-name {
|
||||
color: var(--accent-hover);
|
||||
@@ -8984,6 +9066,45 @@
|
||||
.trow:hover .tdur,
|
||||
.trow:focus-within .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Add-to-playlist joins the expander row's overlay pair, one slot left of
|
||||
Play; its menu is portaled, so hover alone would drop the cluster while
|
||||
the menu is open — the aria-expanded rules hold it. */
|
||||
.trow :global(.plm) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 64px;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.trow :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trow :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.trow:hover :global(.plm),
|
||||
.trow:focus-within :global(.plm),
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-btn,
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
|
||||
opacity: 1;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.trow.locked {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const TABS = ["All", "Songs", "Albums", "Artists", "In your library"];
|
||||
|
||||
@@ -1284,6 +1285,19 @@
|
||||
<span>{fmtCount(listenersOf(t))}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={14}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: t.title,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
trackId: t.id ?? null,
|
||||
artUrl: t.cover ?? null,
|
||||
}]}
|
||||
/>
|
||||
<button
|
||||
class="sv"
|
||||
title="Save to library"
|
||||
@@ -1928,7 +1942,7 @@
|
||||
/* ---- song rows ------------------------------------------------------ */
|
||||
.srow {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 36px 1fr auto auto;
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 7px 9px;
|
||||
@@ -1936,7 +1950,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.srow.has-count {
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto;
|
||||
grid-template-columns: 20px 36px 1fr auto auto auto auto;
|
||||
}
|
||||
.srow:hover {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
@@ -2023,6 +2037,28 @@
|
||||
.sv:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* Add-to-playlist rides one cell left of Save, same reveal; the
|
||||
aria-expanded rules hold the pair while its portaled menu is open. */
|
||||
.srow :global(.plm) {
|
||||
opacity: 0;
|
||||
}
|
||||
.srow :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.srow :global(.plm-trigger:hover) {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.srow:hover :global(.plm),
|
||||
.srow:focus-within :global(.plm),
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) .sv {
|
||||
opacity: 1;
|
||||
}
|
||||
.srow:has(:global(.plm-trigger[aria-expanded="true"])) {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
/* Listener weight: the bar's LENGTH is the count, curved by
|
||||
src/lib/listenerBars.js so one runaway hit cannot squash the rest of the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const uploads = $derived(
|
||||
Object.values(network.uploads)
|
||||
@@ -149,6 +150,15 @@
|
||||
title="Show in folder"
|
||||
onclick={() => api.showInFolder(d.local_path)}
|
||||
><Icon name="folder-open" size={16} /></button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={15}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: d.name.replace(/\.[^.]+$/, ""),
|
||||
path: d.local_path,
|
||||
}]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -305,4 +315,14 @@
|
||||
.reveal:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0.8;
|
||||
color: inherit;
|
||||
}
|
||||
.row :global(.plm-trigger:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,791 @@
|
||||
<script>
|
||||
// The lyrics PAGE — variant C "Immersion" of research/lyrics-redesign,
|
||||
// implemented to the user's dialed recipe verbatim (2026-08-18):
|
||||
// 38px/1028px centred column, gap 0.76em, idle 42%, active ×1.20,
|
||||
// hero dim 57% + blur 3px, accent wash 22%, duotone 56%, focus falloff
|
||||
// 0.3px·d² (capped), sung lines dimmed harder, accent karaoke fill,
|
||||
// scroll-to-seek, word timing when the source carries it. Hero drift and
|
||||
// auto-hide chrome were tried in the mock and left off.
|
||||
//
|
||||
// A real page in the shell — glass bar above, player bar below, Esc goes
|
||||
// back. It replaced the fullscreen overlay room.
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
player,
|
||||
ui,
|
||||
theme,
|
||||
catalog,
|
||||
getArt,
|
||||
getArtMaster,
|
||||
navBack,
|
||||
resolveArtistSeedByName,
|
||||
getCatalogArtistPage,
|
||||
getCatalogArtistHeroSeed,
|
||||
} from "../lib/state.svelte.js";
|
||||
import {
|
||||
lyricsStore,
|
||||
lyricIdentity,
|
||||
ensureLyrics,
|
||||
noteTrackChange,
|
||||
activeLineIndex,
|
||||
wordClockMs,
|
||||
wordfill,
|
||||
totalOffsetMs,
|
||||
nudgeOffset,
|
||||
setAutoSync,
|
||||
} from "../lib/lyrics.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
// ---- the playing identity -------------------------------------------
|
||||
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
|
||||
const title = $derived(player.track?.title ?? "");
|
||||
const artistName = $derived(
|
||||
(player.track?.artist ?? "").trim()
|
||||
|| String(player.track?.sub ?? "").split(" — ")[0].trim(),
|
||||
);
|
||||
const albumName = $derived(currentItem?.album ?? null);
|
||||
|
||||
// Chip artwork: a local file's embedded art, else the stream's cover URL.
|
||||
let chipArt = $state(null);
|
||||
$effect(() => {
|
||||
const item = currentItem;
|
||||
chipArt = null;
|
||||
if (!item) return;
|
||||
if (item.art) {
|
||||
let stale = false;
|
||||
getArt(item.art, 256).then((u) => { if (!stale) chipArt = u; }).catch(() => {});
|
||||
return () => { stale = true; };
|
||||
}
|
||||
chipArt = item.artUrl ?? null;
|
||||
});
|
||||
|
||||
/** A Deezer cover URL re-asked at hero size — a display upgrade of the
|
||||
* SAME asset, not a different lookup. */
|
||||
function coverAtHeroSize(url) {
|
||||
return typeof url === "string"
|
||||
? url.replace(/\/(\d+)x\1(-[0-9a-f-]+\.jpg)$/i, "/1000x1000$2")
|
||||
: url;
|
||||
}
|
||||
|
||||
// ---- the backdrop ladder --------------------------------------------
|
||||
// Artist hero first — the staged artist page's fully-resolved set when
|
||||
// the user has it open in this session, else the cached payload's
|
||||
// banners/seed (the raw payload keeps the hero SEED; the resolved set
|
||||
// only stages on the artist page itself). The record's cover stands in
|
||||
// until a hero lands, and stays for artists without one — at MASTER
|
||||
// resolution for local files, never the 256px chip thumb (a thumbnail
|
||||
// stretched over the whole stage is what "low quality" looks like).
|
||||
/** Every http hero candidate a page payload carries, resolved set first. */
|
||||
function bannersOf(p) {
|
||||
const list = p?.banners?.length
|
||||
? p.banners
|
||||
: p?.banner
|
||||
? [p.banner]
|
||||
: p?.hero_seed_banner
|
||||
? [p.hero_seed_banner]
|
||||
: [];
|
||||
return list.filter((b) => typeof b === "string" && b.startsWith("http"));
|
||||
}
|
||||
let heroUrl = $state(null);
|
||||
let heroGen = 0;
|
||||
$effect(() => {
|
||||
const name = artistName;
|
||||
const gen = ++heroGen;
|
||||
heroUrl = null;
|
||||
if (!name) return;
|
||||
// The artist page already staged this artist? Its resolved hero set
|
||||
// is the best source and costs nothing.
|
||||
const staged = catalog.artistPage;
|
||||
if (
|
||||
staged?.artist?.name
|
||||
&& staged.artist.name.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
) {
|
||||
const b = bannersOf(staged);
|
||||
if (b.length) {
|
||||
heroUrl = b[0];
|
||||
return;
|
||||
}
|
||||
}
|
||||
untrack(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const seed = await resolveArtistSeedByName(name);
|
||||
if (heroGen !== gen || seed?.id == null) return;
|
||||
// The full payload's banners cover custom heroes; the
|
||||
// HERO SEED is the standard case — the same image the
|
||||
// artist page paints first.
|
||||
const page = await getCatalogArtistPage(seed.id).catch(() => null);
|
||||
if (heroGen !== gen) return;
|
||||
const b = bannersOf(page);
|
||||
if (b.length) {
|
||||
heroUrl = b[0];
|
||||
return;
|
||||
}
|
||||
const hs = await getCatalogArtistHeroSeed(seed.id);
|
||||
if (heroGen !== gen) return;
|
||||
const url =
|
||||
typeof hs === "string"
|
||||
? hs
|
||||
: hs?.banner ?? hs?.url ?? hs?.src ?? null;
|
||||
if (typeof url === "string" && url.startsWith("http")) heroUrl = url;
|
||||
} catch {
|
||||
// no banner — the cover backdrop stands
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
// The stage's cover: the real pixels. Local files ask for the MASTER
|
||||
// (the deliberate act getArt's doc demands of full-bleed surfaces);
|
||||
// stream covers get re-asked at hero size.
|
||||
let coverBig = $state(null);
|
||||
$effect(() => {
|
||||
const item = currentItem;
|
||||
coverBig = null;
|
||||
if (!item) return;
|
||||
if (item.art) {
|
||||
let stale = false;
|
||||
getArtMaster(item.art).then((u) => { if (!stale) coverBig = u; }).catch(() => {});
|
||||
return () => { stale = true; };
|
||||
}
|
||||
coverBig = coverAtHeroSize(item.artUrl ?? null);
|
||||
});
|
||||
|
||||
const backdrop = $derived(heroUrl ?? coverBig ?? chipArt);
|
||||
|
||||
// ---- lyrics ----------------------------------------------------------
|
||||
let shownKey = null;
|
||||
$effect(() => {
|
||||
const identity = lyricIdentity();
|
||||
const key = identity
|
||||
? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}`
|
||||
: "";
|
||||
if (key !== shownKey) shownKey = key;
|
||||
// untrack: ensureLyrics reads and writes store fields synchronously —
|
||||
// tracked, a failed fetch retriggers forever (the pane documents it).
|
||||
untrack(() => {
|
||||
noteTrackChange();
|
||||
void ensureLyrics();
|
||||
});
|
||||
});
|
||||
|
||||
const lines = $derived(lyricsStore.status === "synced" ? lyricsStore.lines : []);
|
||||
const active = $derived(
|
||||
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
|
||||
);
|
||||
|
||||
// ---- follow + scrub --------------------------------------------------
|
||||
let feed = $state(null);
|
||||
let holdUntil = 0;
|
||||
let scrubIdx = $state(null);
|
||||
let scrubbing = $state(false);
|
||||
let chipPos = $state({ top: 0, left: 0 });
|
||||
let settleTimer = 0;
|
||||
let programmaticUntil = 0;
|
||||
|
||||
// The lens centres on the scrubbed line while the wheel is live,
|
||||
// otherwise on the playing one.
|
||||
const focusIdx = $derived(scrubIdx ?? active);
|
||||
|
||||
function centreOn(index, smooth = true) {
|
||||
if (!feed || index < 0) return;
|
||||
const el = feed.querySelector(`[data-line="${index}"]`);
|
||||
if (!el) return;
|
||||
const fr = feed.getBoundingClientRect();
|
||||
const lr = el.getBoundingClientRect();
|
||||
const top = feed.scrollTop + (lr.top + lr.height / 2) - (fr.top + fr.height / 2);
|
||||
programmaticUntil = performance.now() + 800;
|
||||
feed.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: smooth && ui.windowActive ? "smooth" : "auto",
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const index = active;
|
||||
if (lyricsStore.status !== "synced") return;
|
||||
if (performance.now() < holdUntil) return;
|
||||
centreOn(index);
|
||||
});
|
||||
|
||||
function fmtStamp(ms) {
|
||||
const secs = Math.max(0, Math.round((ms + totalOffsetMs()) / 1000));
|
||||
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function onUserScroll() {
|
||||
if (performance.now() < programmaticUntil) return;
|
||||
if (!feed || lyricsStore.status !== "synced") return;
|
||||
holdUntil = performance.now() + 3500;
|
||||
const fr = feed.getBoundingClientRect();
|
||||
const y = fr.top + fr.height / 2;
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
feed.querySelectorAll("[data-line]").forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const d = Math.abs(r.top + r.height / 2 - y);
|
||||
if (d < bd) { bd = d; best = Number(el.dataset.line); }
|
||||
});
|
||||
if (best < 0) return;
|
||||
scrubIdx = best;
|
||||
scrubbing = true;
|
||||
chipPos = { top: Math.round(y - 16), left: Math.max(10, Math.round(fr.left + fr.width / 2 - 640)) };
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(settleScrub, 340);
|
||||
}
|
||||
|
||||
function settleScrub() {
|
||||
const idx = scrubIdx;
|
||||
scrubbing = false;
|
||||
scrubIdx = null;
|
||||
if (idx == null) return;
|
||||
const line = lines[idx];
|
||||
if (!line) return;
|
||||
// The jump: scrolling through the words IS seeking the song.
|
||||
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
|
||||
api.playerControl("seek", secs).catch(() => {});
|
||||
holdUntil = 0;
|
||||
}
|
||||
|
||||
function seekTo(line) {
|
||||
clearTimeout(settleTimer);
|
||||
scrubbing = false;
|
||||
scrubIdx = null;
|
||||
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
|
||||
api.playerControl("seek", secs).catch(() => {});
|
||||
holdUntil = 0;
|
||||
}
|
||||
|
||||
/** Focus falloff, the recipe's quadratic ramp: min(d²/4, 8) × 0.3px. */
|
||||
function dofBlur(i) {
|
||||
const d = Math.abs(i - focusIdx);
|
||||
return Math.min((d * d) / 4, 8);
|
||||
}
|
||||
|
||||
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 onkeydown(e) {
|
||||
if (e.key === "Escape") void navBack();
|
||||
}
|
||||
|
||||
// The room follows the music out: nothing playing, nothing to show.
|
||||
$effect(() => {
|
||||
if (ui.view === "lyrics" && !player.track) void navBack();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={ui.view === "lyrics" ? onkeydown : undefined} />
|
||||
|
||||
<div class="lyrpage" style="--wash-accent: {theme.accent}">
|
||||
<div class="full" aria-hidden="true">
|
||||
{#if backdrop}
|
||||
{#key backdrop}
|
||||
<img src={backdrop} alt="" draggable="false" />
|
||||
{/key}
|
||||
<span class="tint"></span>
|
||||
{/if}
|
||||
<span class="grade"></span>
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
<div class="chip">
|
||||
{#if chipArt}
|
||||
<img src={chipArt} alt="" draggable="false" />
|
||||
{:else}
|
||||
<span class="chip-fallback"><Icon name="music" size={18} /></span>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="t">{title}</div>
|
||||
<div class="a">{artistName}{albumName ? ` — ${albumName}` : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lyricsStore.status === "synced"}
|
||||
<div class="tools">
|
||||
<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>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="feed"
|
||||
bind:this={feed}
|
||||
onwheel={onUserScroll}
|
||||
ontouchmove={onUserScroll}
|
||||
onscroll={onUserScroll}
|
||||
>
|
||||
<div class="pad"></div>
|
||||
{#each lines as line, index (index)}
|
||||
{#if !line.text}
|
||||
<button
|
||||
class="line gapdots"
|
||||
class:on={index === active}
|
||||
class:past={index < active}
|
||||
data-line={index}
|
||||
style="--dblur: {dofBlur(index)}"
|
||||
onclick={() => seekTo(line)}
|
||||
title="Play from here"
|
||||
><i></i><i></i><i></i></button>
|
||||
{:else}
|
||||
<button
|
||||
class="line"
|
||||
class:on={index === active}
|
||||
class:past={index < active}
|
||||
class:scrub={index === scrubIdx}
|
||||
data-line={index}
|
||||
style="--dblur: {dofBlur(index)}"
|
||||
onclick={() => seekTo(line)}
|
||||
title="Play from here"
|
||||
>
|
||||
{#if index === active && lyricsStore.wordFill && line.words?.length}
|
||||
<!-- Karaoke word fill over REAL word times (the
|
||||
wordfill mock's dialed variant A): each word
|
||||
wipes across its OWN sung span, silence
|
||||
holds — never invented (the Noctis rule). -->
|
||||
<span
|
||||
class="wline"
|
||||
use:wordfill={{
|
||||
line,
|
||||
ms: wordClockMs(line, player.position ?? 0),
|
||||
endBound: lines[index + 1]?.time_ms
|
||||
?? line.words[line.words.length - 1].time_ms + 2500,
|
||||
}}
|
||||
>
|
||||
{#each line.words as word, wi (wi)}
|
||||
<span class="fw">{word.text}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Line mode: the active line is a SOLID accent
|
||||
fill (.line.on) — the interpolated sweep is
|
||||
gone (Kyle 2026-08-19: mid-word splits read
|
||||
as glitches; only REAL word times may move
|
||||
through letters, and those live behind the
|
||||
experimental toggle above). -->
|
||||
{line.text}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="pad"></div>
|
||||
</div>
|
||||
{#if scrubbing && scrubIdx != null && lines[scrubIdx]}
|
||||
<div class="scrubchip" style="top: {chipPos.top}px; left: {chipPos.left}px">
|
||||
{fmtStamp(lines[scrubIdx].time_ms)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if lyricsStore.status === "plain"}
|
||||
<div class="feed plain">
|
||||
<div class="pad"></div>
|
||||
{#each (lyricsStore.plain ?? "").split("\n") as pline, i (i)}
|
||||
<p class="line stat">{pline.trim() ? pline : "· · ·"}</p>
|
||||
{/each}
|
||||
<div class="pad"></div>
|
||||
</div>
|
||||
<div class="statechip">not synced — the rail's lyrics pane can time them</div>
|
||||
{:else if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
|
||||
<div class="statebox">
|
||||
<span class="ring"></span>
|
||||
</div>
|
||||
{:else if lyricsStore.status === "instrumental"}
|
||||
<div class="statebox">
|
||||
<Icon name="music" size={30} />
|
||||
<p>Instrumental — sit back.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="statebox">
|
||||
<Icon name="music" size={30} />
|
||||
<p>No lyrics found for this one.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* The stage fills the pane, edge to edge, and never scrolls itself —
|
||||
the feed inside it does. Follows the Library/discography exception:
|
||||
the backdrop must begin at the very pane top, behind the glass.
|
||||
Height arithmetic: 100% resolves against the pane's CONTENT box —
|
||||
both --pane-pad bands already subtracted — while the glass bar
|
||||
cancels the top one, so a bare 100% ends 2×pad short and the hero
|
||||
stops in a black band above the player bar (Kyle, 2026-08-19).
|
||||
Reclaim both pads and bleed the bottom one away. */
|
||||
.lyrpage {
|
||||
position: relative;
|
||||
margin: 0 -28px calc(-1 * var(--pane-pad, 24px));
|
||||
height: calc(100% + 2 * var(--pane-pad, 24px));
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
/* The type scale below is container-relative: the page is the
|
||||
container (its height is an explicit calc, so `size` is legal). */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* ---- backdrop: hero (or cover) in the record's own duotone ---- */
|
||||
.full {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
z-index: 0;
|
||||
}
|
||||
.full img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 24%;
|
||||
/* recipe: hero-dim 57%, hero-blur 3px, duotone base */
|
||||
filter: grayscale(1) brightness(0.43) blur(3px) contrast(1.05);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.full .tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* recipe: duotone 56%, tone pulled toward neutral so it reads like a
|
||||
sleeve, not paint */
|
||||
background: color-mix(in srgb, var(--wash-accent) 80%, #77706e);
|
||||
mix-blend-mode: color;
|
||||
opacity: 0.56;
|
||||
}
|
||||
.full .grade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 108%, rgba(10, 10, 10, 0.9), rgba(10, 10, 10, 0.42) 55%, rgba(10, 10, 10, 0.28)),
|
||||
radial-gradient(80% 60% at 50% 0%, color-mix(in srgb, var(--wash-accent) 22%, transparent), transparent 62%);
|
||||
}
|
||||
|
||||
/* ---- identity chip, top centre under the glass ---- */
|
||||
.chip {
|
||||
position: absolute;
|
||||
top: 72px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 3;
|
||||
background: rgba(12, 12, 12, 0.5);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 9px 16px 9px 9px;
|
||||
max-width: min(60%, 560px);
|
||||
}
|
||||
.chip img,
|
||||
.chip-fallback {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #232323;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.chip .t {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip .a {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- source + nudge, top right under the glass ---- */
|
||||
.tools {
|
||||
position: absolute;
|
||||
top: calc(var(--bar-h, 46px) + 12px);
|
||||
right: 24px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.src {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.autochip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-hover);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.nudge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.nudge .tool {
|
||||
color: var(--text-secondary);
|
||||
padding: 0 3px;
|
||||
}
|
||||
.nudge .tool:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nudge .off.on {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ---- the feed ---- */
|
||||
.feed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
|
||||
mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
|
||||
}
|
||||
.feed::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.feed .pad {
|
||||
flex: none;
|
||||
height: 52vh;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: relative;
|
||||
flex: none;
|
||||
/* recipe: 38px / 1028px / 0.76em gap, centred — the 38 now the CAP
|
||||
of a container-relative scale, so a small window shrinks the type
|
||||
with it (Kyle, 2026-08-19) and a full-size pane is pixel-identical
|
||||
to the dialed recipe. Coefficients: 3.5cqi ≈ 38px at the pane
|
||||
width where the 1028px feed first compresses (~1086px incl the
|
||||
reclaimed side pads); 5.3cqb ≈ 38px at the ~717px pane height of
|
||||
the dialed layout; the SMALLER axis governs, floored for tiny
|
||||
windows. Everything inside the line is em-based and follows. */
|
||||
font-size: clamp(20px, min(3.5cqi, 5.3cqb), 38px);
|
||||
line-height: 1.28;
|
||||
font-weight: 700;
|
||||
max-width: min(1028px, 86%);
|
||||
margin: 0.38em 0;
|
||||
text-align: center;
|
||||
/* recipe: idle 42% */
|
||||
color: color-mix(in srgb, var(--text-primary) 42%, transparent);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
color 260ms ease,
|
||||
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 260ms ease,
|
||||
filter 260ms ease;
|
||||
/* recipe: focus falloff 0.3px × d²/4, capped */
|
||||
filter: blur(calc(var(--dblur, 1) * 0.3px));
|
||||
cursor: pointer;
|
||||
}
|
||||
/* recipe: sung lines dim harder than unsung */
|
||||
.line.past {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.line.on {
|
||||
/* recipe: active ×1.20; SOLID accent letters — the whole line is
|
||||
the highlight (word mode's children clip their own fills and
|
||||
ignore this color). */
|
||||
transform: scale(1.2);
|
||||
color: var(--accent-hover);
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.line.scrub {
|
||||
color: var(--text-primary);
|
||||
filter: none;
|
||||
}
|
||||
.line:not(.on):hover {
|
||||
color: color-mix(in srgb, var(--text-primary) 80%, transparent);
|
||||
filter: none;
|
||||
}
|
||||
.line:not(.on):hover::before {
|
||||
content: "▶";
|
||||
position: absolute;
|
||||
left: -30px;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
font-size: 0.38em;
|
||||
color: var(--accent-hover);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* accent karaoke fill: ONE glyph raster — the active line's text is
|
||||
painted by a gradient clipped to the glyphs, sung accent left of the
|
||||
feathered boundary, unsung white right of it. (Two stacked text layers
|
||||
fringe against each other; this cannot.)
|
||||
|
||||
NAMESPACED on pain of death: @property registrations are DOCUMENT
|
||||
GLOBAL — registering bare `--p` as a percentage broke the artist
|
||||
page's scroll variable of the same name (its unitless writes stopped
|
||||
parsing and the whole hero pipeline collapsed). */
|
||||
@property --lyr-p {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
/* karaoke word fill (the wordfill mock's dialed variant A): each word is
|
||||
its OWN gradient-clip raster — accent left of its feathered boundary,
|
||||
unsung white right of it — wiping across the word's real sung span.
|
||||
Feather ±8%, mock-dialed. --lyr-p is per-element (inherits: false). */
|
||||
.wline .fw {
|
||||
--lyr-p: -8%;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-hover) calc(var(--lyr-p) - 8%),
|
||||
var(--text-primary) calc(var(--lyr-p) + 8%)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
.wline .fw + .fw {
|
||||
margin-left: 0.28em;
|
||||
}
|
||||
|
||||
/* instrumental gap: three breathing dots */
|
||||
.line.gapdots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.28em;
|
||||
}
|
||||
.line.gapdots i {
|
||||
width: 0.32em;
|
||||
height: 0.32em;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-primary) 40%, transparent);
|
||||
}
|
||||
.line.gapdots.on i {
|
||||
animation: dotpulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.line.gapdots.on i:nth-child(2) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.line.gapdots.on i:nth-child(3) {
|
||||
animation-delay: 0.44s;
|
||||
}
|
||||
@keyframes dotpulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
background: color-mix(in srgb, var(--text-primary) 45%, transparent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.55);
|
||||
background: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.line.gapdots.on i {
|
||||
animation: none;
|
||||
background: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* scrub time chip, pinned to the focus band */
|
||||
.scrubchip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: rgba(12, 12, 12, 0.62);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scrubchip::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- non-synced states ---- */
|
||||
.feed.plain .line.stat {
|
||||
cursor: default;
|
||||
filter: none;
|
||||
}
|
||||
.statechip {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(12, 12, 12, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.statebox {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.ring {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.16);
|
||||
border-top-color: var(--accent-hover);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
+450
-126
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script>
|
||||
// One playlist, as an editor: cover (custom or mosaic), inline rename and
|
||||
// description, play/shuffle, search-within, pointer-drag reorder over
|
||||
// stable occurrence ids, per-row repair for unresolved imports, setlist
|
||||
@@ -110,6 +110,70 @@
|
||||
}
|
||||
});
|
||||
|
||||
// A stream row saved before covers rode along backfills its artwork ONCE:
|
||||
// an exact-identity catalog match — normalized artist AND title equality,
|
||||
// the import ladder's own evidence class — supplies the cover and the
|
||||
// track id, persisted on the row. Sequential, one search per row, each
|
||||
// row attempted once per session; no match simply keeps the glyph.
|
||||
const normId = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const artAttempted = new Set();
|
||||
let backfillBusy = false;
|
||||
$effect(() => {
|
||||
if (!playlist) return;
|
||||
const want = items.filter(
|
||||
(i) =>
|
||||
i.kind === "stream" &&
|
||||
!i.art_url &&
|
||||
i.artist &&
|
||||
i.title &&
|
||||
!artAttempted.has(i.id),
|
||||
);
|
||||
if (!want.length || backfillBusy) return;
|
||||
void backfillArt(playlist.id, want);
|
||||
});
|
||||
async function backfillArt(plId, rows) {
|
||||
backfillBusy = true;
|
||||
try {
|
||||
for (const row of rows) {
|
||||
if (ui.playlistId !== plId) return;
|
||||
if (artAttempted.has(row.id)) continue;
|
||||
artAttempted.add(row.id);
|
||||
let hit = null;
|
||||
try {
|
||||
const res = await api.catalogSearch(`${row.artist} ${row.title}`);
|
||||
const same = (t) =>
|
||||
normId(t.artist) === normId(row.artist) &&
|
||||
normId(t.title) === normId(row.title);
|
||||
const hits = (res?.tracks ?? []).filter(same);
|
||||
hit =
|
||||
hits.find((t) => row.album && normId(t.album) === normId(row.album)) ??
|
||||
hits[0] ??
|
||||
null;
|
||||
} catch {
|
||||
// Offline or throttled — let the next page visit retry.
|
||||
artAttempted.delete(row.id);
|
||||
return;
|
||||
}
|
||||
if (!hit?.cover) continue;
|
||||
try {
|
||||
await api.playlistRelinkItem(plId, row.id, {
|
||||
trackId: hit.id != null ? String(hit.id) : null,
|
||||
artUrl: hit.cover,
|
||||
});
|
||||
const live = items.find((i) => i.id === row.id);
|
||||
if (live) {
|
||||
live.art_url = hit.cover;
|
||||
if (hit.id != null && !live.track_id) live.track_id = String(hit.id);
|
||||
}
|
||||
} catch {
|
||||
// The row vanished mid-backfill — nothing to persist onto.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
backfillBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (s) => (s ?? "").toLowerCase();
|
||||
const visible = $derived.by(() => {
|
||||
const q = norm(query.trim());
|
||||
@@ -122,27 +186,68 @@
|
||||
);
|
||||
});
|
||||
|
||||
const playableRows = $derived(items.filter((i) => i.path && !i.missing));
|
||||
// Setlist cue times show only when EVERY row carries one — the import's
|
||||
// own "coherent or not at all" rule (folder order works the same way). A
|
||||
// partially stamped list (a track added by hand after import) reads as a
|
||||
// plain numbered list instead of a column mixing times with indexes.
|
||||
// Gated on the full list, not the filtered view: hiding the unstamped
|
||||
// rows must not flip the column.
|
||||
const stampsCoherent = $derived(items.length > 0 && items.every((i) => i.start_ms != null));
|
||||
|
||||
// Rows Play/Shuffle can queue: present local files plus stream rows —
|
||||
// streams queue as PENDING items that resolve when reached (or a track
|
||||
// early via the look-ahead), so the playlist plays in order as one queue.
|
||||
const queueableRows = $derived(
|
||||
items.filter((i) => (i.path && !i.missing) || i.kind === "stream"),
|
||||
);
|
||||
const missingCount = $derived(items.filter((i) => i.missing).length);
|
||||
const unresolvedCount = $derived(items.filter((i) => i.kind === "unresolved").length);
|
||||
const totalSecs = $derived(items.reduce((sum, i) => sum + (i.duration_secs ?? 0), 0));
|
||||
|
||||
const nowItemId = $derived(player.queue[player.queueIndex]?.plItemId ?? null);
|
||||
|
||||
// Where each set-recording slice ends: the NEXT stamped row in the full
|
||||
// (unfiltered) order bounds it; the last slice runs to the file's end.
|
||||
const segmentEndOf = $derived.by(() => {
|
||||
const ends = new Map();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind !== "segment" || it.start_ms == null) continue;
|
||||
const next = items.slice(i + 1).find((n) => n.start_ms != null);
|
||||
ends.set(it.id, next ? next.start_ms : null);
|
||||
}
|
||||
return ends;
|
||||
});
|
||||
|
||||
function queueRowOf(item) {
|
||||
const local = item.path && !item.missing;
|
||||
const tid = Number(item.track_id);
|
||||
const endMs = segmentEndOf.get(item.id);
|
||||
const segment =
|
||||
item.kind === "segment" && local && item.start_ms != null
|
||||
? { start: item.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
|
||||
: null;
|
||||
return {
|
||||
path: item.path,
|
||||
path: local ? item.path : null,
|
||||
needsResolve: !local,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
album: item.album,
|
||||
duration_secs: item.duration_secs,
|
||||
art_path: item.art_path,
|
||||
duration_secs:
|
||||
item.duration_secs ??
|
||||
(segment && segment.end != null
|
||||
? Math.round(segment.end - segment.start)
|
||||
: null),
|
||||
art_path: local ? item.art_path : null,
|
||||
artUrl: local ? null : (item.art_url ?? null),
|
||||
trackId: Number.isFinite(tid) ? tid : null,
|
||||
plItemId: item.id,
|
||||
segment,
|
||||
};
|
||||
}
|
||||
|
||||
function playFrom(item = null) {
|
||||
const rows = playableRows;
|
||||
const rows = queueableRows;
|
||||
if (!rows.length) return;
|
||||
let start = 0;
|
||||
if (item) {
|
||||
@@ -153,7 +258,7 @@
|
||||
}
|
||||
|
||||
function shufflePlay() {
|
||||
const rows = playableRows;
|
||||
const rows = queueableRows;
|
||||
if (!rows.length) return;
|
||||
playFrom(rows[Math.floor(Math.random() * rows.length)]);
|
||||
// After the queue exists: toggleShuffle seeds its seen-set from the
|
||||
@@ -162,11 +267,20 @@
|
||||
}
|
||||
|
||||
function rowClick(item) {
|
||||
if (item.path && !item.missing) {
|
||||
if ((item.path && !item.missing) || item.kind === "stream") {
|
||||
// Play from this row WITHIN the playlist queue — a stream row
|
||||
// resolves on reach, and what follows it keeps playing.
|
||||
playFrom(item);
|
||||
} else if (item.title && item.artist && item.title.toLowerCase() !== "id") {
|
||||
// No local file — resolve it from the network like any streamed song.
|
||||
void streamSong(item.artist, item.title);
|
||||
// A weak row that can still stream one-off (kept for safety; the
|
||||
// kinds above should cover everything playable).
|
||||
const tid = Number(item.track_id);
|
||||
void streamSong(item.artist, item.title, {
|
||||
cover: item.art_url ?? undefined,
|
||||
trackId: Number.isFinite(tid) ? tid : undefined,
|
||||
album: item.album ?? undefined,
|
||||
expectSecs: item.duration_secs ?? undefined,
|
||||
});
|
||||
} else {
|
||||
// An unknown ("ID") or artist-less placeholder has nothing to
|
||||
// search for; the repair panel is the useful next step.
|
||||
@@ -301,6 +415,8 @@
|
||||
start_ms: item.start_ms,
|
||||
source_line: item.source_line,
|
||||
note: item.note,
|
||||
track_id: item.track_id,
|
||||
art_url: item.art_url,
|
||||
},
|
||||
],
|
||||
position,
|
||||
@@ -446,19 +562,30 @@
|
||||
|
||||
{#if playlist}
|
||||
<div class="playlist-page">
|
||||
<header>
|
||||
<header class="hero">
|
||||
<!-- A blurred copy of the cover bled up behind the glass bar so the
|
||||
bar has something to frost — the album page's banner recipe,
|
||||
painted from the playlist's own cover or mosaic. -->
|
||||
<div class="banner" aria-hidden="true">
|
||||
{#if cover}
|
||||
<img src={cover} alt="" draggable="false" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="identity">
|
||||
<div class="cover-slot">
|
||||
{#if cover}
|
||||
<img class="cover-img" src={cover} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="cover-fallback"><Icon name="playlist" size={56} /></div>
|
||||
{/if}
|
||||
<!-- There is no artwork viewer for playlist covers, so the
|
||||
hover affordance stays "choose the photo". -->
|
||||
<button class="cover-edit" title="Choose cover photo" onclick={chooseCover}>
|
||||
<Icon name="image" size={16} />
|
||||
<span>Choose photo</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="head-meta">
|
||||
<div class="copy">
|
||||
<p class="kind">
|
||||
{isSetlist ? "Setlist" : "Playlist"}
|
||||
{#if playlist.pinned}<span class="pin-chip"><Icon name="pin" size={10} /> Pinned</span>{/if}
|
||||
@@ -466,6 +593,7 @@
|
||||
{#if renaming}
|
||||
<input
|
||||
class="h1-input"
|
||||
class:long={nameInput.length > 22}
|
||||
bind:value={nameInput}
|
||||
onblur={saveName}
|
||||
onkeydown={(e) => {
|
||||
@@ -476,7 +604,11 @@
|
||||
spellcheck="false"
|
||||
/>
|
||||
{:else}
|
||||
<button class="h1" title="Rename" onclick={startRename}>{playlist.name}</button>
|
||||
<button
|
||||
class="h1"
|
||||
class:long={playlist.name.length > 22}
|
||||
title="Rename"
|
||||
onclick={startRename}>{playlist.name}</button>
|
||||
{/if}
|
||||
{#if editingDesc}
|
||||
<textarea
|
||||
@@ -503,7 +635,7 @@
|
||||
<Icon name="pencil" size={12} /> Add a description
|
||||
</button>
|
||||
{/if}
|
||||
<p class="line">
|
||||
<p class="meta">
|
||||
{items.length} {items.length === 1 ? "track" : "tracks"}
|
||||
{#if totalSecs} · {fmtTotal(totalSecs)}{/if}
|
||||
{#if unresolvedCount > 0}
|
||||
@@ -515,23 +647,25 @@
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="play-all"
|
||||
class="pill"
|
||||
onclick={() => playFrom()}
|
||||
disabled={playableRows.length === 0}
|
||||
disabled={queueableRows.length === 0}
|
||||
>
|
||||
<Icon name="play" size={15} /> Play
|
||||
<Icon name="play" size={12} /> Play
|
||||
</button>
|
||||
<button
|
||||
class="round"
|
||||
class="iconbtn"
|
||||
class:on={player.shuffle}
|
||||
title="Shuffle"
|
||||
aria-label="Shuffle this playlist"
|
||||
onclick={shufflePlay}
|
||||
disabled={playableRows.length === 0}
|
||||
disabled={queueableRows.length === 0}
|
||||
>
|
||||
<Icon name="shuffle" size={16} />
|
||||
<Icon name="shuffle" size={17} />
|
||||
</button>
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="round"
|
||||
class="iconbtn"
|
||||
title="More"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -539,7 +673,7 @@
|
||||
menuOpen = !menuOpen;
|
||||
}}
|
||||
>
|
||||
<Icon name="more-horizontal" size={18} />
|
||||
<Icon name="more-horizontal" size={17} />
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
@@ -604,6 +738,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loaded && items.length === 0}
|
||||
@@ -618,6 +753,14 @@
|
||||
<p class="empty-note small">Nothing here matches “{query}”.</p>
|
||||
{:else}
|
||||
<div class="tracklist" class:dragging={!!drag}>
|
||||
<!-- The album page's column header, over this page's own
|
||||
columns: grip gutter, #, then Title spanning the art. -->
|
||||
<div class="tcols">
|
||||
<span></span>
|
||||
<span class="th-no">#</span>
|
||||
<span class="th-title">Title</span>
|
||||
<span class="th-dur"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{#each visible as item, index (item.id)}
|
||||
{@const isNow = nowItemId != null && nowItemId === item.id}
|
||||
{@const isDragged = drag?.id === item.id}
|
||||
@@ -633,13 +776,17 @@
|
||||
? `transform: translateY(${rowShift(index)}px)`
|
||||
: ""}
|
||||
>
|
||||
{#if !query.trim()}
|
||||
<!-- Always rendered: the rows are a grid now, and a
|
||||
vanishing first cell would shift every column.
|
||||
While a filter hides rows the grip goes inert
|
||||
(gripDown already refuses) and stays invisible. -->
|
||||
<span
|
||||
class="grip"
|
||||
class:off={!!query.trim()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Drag to reorder"
|
||||
title="Drag to reorder"
|
||||
title={query.trim() ? "" : "Drag to reorder"}
|
||||
onpointerdown={(e) => gripDown(e, index)}
|
||||
onpointermove={gripMove}
|
||||
onpointerup={gripUp}
|
||||
@@ -647,14 +794,13 @@
|
||||
>
|
||||
<Icon name="grip" size={14} />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="idx">
|
||||
{#if isNow}
|
||||
<span
|
||||
class="eq"
|
||||
class:active={player.status === "playing" && ui.windowActive}
|
||||
><b></b><b></b><b></b></span>
|
||||
{:else if isSetlist && item.start_ms != null}
|
||||
{:else if isSetlist && stampsCoherent}
|
||||
<span class="stamp">{fmtStamp(item.start_ms)}</span>
|
||||
{:else}
|
||||
{index + 1}
|
||||
@@ -663,6 +809,10 @@
|
||||
<span class="thumb">
|
||||
{#if item.art_path && rowArt[item.art_path]}
|
||||
<img src={rowArt[item.art_path]} alt="" draggable="false" />
|
||||
{:else if item.art_url}
|
||||
<!-- A stream row's cover is a catalog URL — it
|
||||
has no local file to read artwork from. -->
|
||||
<img src={item.art_url} alt="" loading="lazy" draggable="false" />
|
||||
{:else}
|
||||
<span class="thumb-fallback"><Icon name="music" size={13} /></span>
|
||||
{/if}
|
||||
@@ -672,6 +822,13 @@
|
||||
{item.title}
|
||||
{#if item.kind === "unresolved"}
|
||||
<span class="tag un">unresolved</span>
|
||||
{:else if item.kind === "segment" && !item.missing}
|
||||
<span
|
||||
class="tag seg"
|
||||
title="Unidentified — plays this stretch of the set's own recording"
|
||||
>
|
||||
set audio
|
||||
</span>
|
||||
{:else if item.missing}
|
||||
<span class="tag miss">file missing</span>
|
||||
{/if}
|
||||
@@ -683,7 +840,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
<span class="rowact">
|
||||
{#if item.kind === "unresolved" || item.missing}
|
||||
{#if item.kind !== "local" || item.missing}
|
||||
<button
|
||||
class="ra"
|
||||
title="Find a local match"
|
||||
@@ -752,12 +909,73 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
header {
|
||||
/* The glass bar cancels the pane's top padding so the page's box starts
|
||||
flush with the top of the pane and the banner can run under the frost;
|
||||
the page takes that inset back for its own content (App.svelte's
|
||||
BAR_VIEWS rule — this page follows the album page exactly). */
|
||||
.playlist-page {
|
||||
padding-top: var(--pane-pad);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- hero banner ----
|
||||
CatalogAlbum's hero, on this page's anatomy. Same scaling system: one
|
||||
number sizes the artwork, and every length in the hero is derived from
|
||||
it so the block scales as one piece. */
|
||||
.hero {
|
||||
--cover-scale: 1.175; /* 282px, the album page's own size */
|
||||
--cover: calc(240px * var(--cover-scale));
|
||||
position: relative;
|
||||
margin: 0 -28px;
|
||||
padding: 0 28px;
|
||||
min-height: calc(var(--cover) + 68px);
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 26px;
|
||||
/* Narrow panes: the meta drops BELOW the cover instead of being
|
||||
isolation: isolate;
|
||||
/* The isolation traps the ⋯ menu's z-index inside the hero's own
|
||||
stacking context, and the track rows below are positioned too —
|
||||
painted after the hero, they would lie OVER the open menu. Lifting
|
||||
the hero one step keeps its menu above the rows while staying far
|
||||
under the glass bar's 40. (The album page never hits this: its hero
|
||||
has no dropdown.) */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* A blurred copy of the cover — custom photo or the four-cover mosaic —
|
||||
bled up behind the glass bar so the bar has something to frost. */
|
||||
.banner {
|
||||
position: absolute;
|
||||
inset: -70px 0 0 0;
|
||||
z-index: -2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.banner img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(42px) saturate(1.45);
|
||||
transform: scale(1.25);
|
||||
opacity: 0.62;
|
||||
}
|
||||
.banner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 10, 12, 0.5) 0%,
|
||||
rgba(14, 14, 16, 0.42) 42%,
|
||||
var(--bg-base) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: calc(22px * var(--cover-scale));
|
||||
/* Fixed: the hero's derived height counts on this foot. */
|
||||
padding-bottom: 22px;
|
||||
width: 100%;
|
||||
/* Narrow panes: the copy drops BELOW the cover instead of being
|
||||
crushed beside it (the title was truncating to one letter at a
|
||||
~420px content pane). */
|
||||
flex-wrap: wrap;
|
||||
@@ -765,9 +983,9 @@
|
||||
|
||||
.cover-slot {
|
||||
position: relative;
|
||||
width: 208px;
|
||||
height: 208px;
|
||||
flex-shrink: 0;
|
||||
width: var(--cover);
|
||||
height: var(--cover);
|
||||
flex: none;
|
||||
}
|
||||
.cover-img,
|
||||
.cover-fallback {
|
||||
@@ -775,7 +993,7 @@
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.cover-fallback {
|
||||
display: grid;
|
||||
@@ -805,49 +1023,62 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.head-meta {
|
||||
/* Wraps under the cover once it cannot hold a real title beside it. */
|
||||
min-width: min(300px, 100%);
|
||||
/* The scaling root — every length below is em, so type, gaps, pills and
|
||||
the search field scale by the SAME factor (the album page's rule).
|
||||
Unlike the album's fixed five rows, this column's height varies — a
|
||||
description can be absent or three lines — so it cannot tune a gap to
|
||||
fill the cover exactly. It bottom-anchors instead: the actions row
|
||||
always ends level with the artwork and the copy stacks up from there. */
|
||||
.copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 1.15em;
|
||||
font-size: calc(14px * var(--cover-scale));
|
||||
}
|
||||
|
||||
.kind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 0.6em;
|
||||
font-size: 0.714em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.pin-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-highlight);
|
||||
border-radius: 8px;
|
||||
padding: 2px 7px;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.h1,
|
||||
.h1-input {
|
||||
display: block;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 40px;
|
||||
font-size: 3em;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.015em;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.h1.long,
|
||||
.h1-input.long {
|
||||
font-size: 2.286em;
|
||||
}
|
||||
.h1 {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.h1:hover {
|
||||
@@ -857,106 +1088,117 @@
|
||||
}
|
||||
.h1-input {
|
||||
width: 100%;
|
||||
background: var(--bg-highlight);
|
||||
background: rgba(12, 12, 14, 0.45);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
outline: 2px solid var(--accent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.desc,
|
||||
.desc-input {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.79em;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-align: left;
|
||||
max-width: 560px;
|
||||
max-width: 40em;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.desc {
|
||||
border-radius: 5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.desc:hover {
|
||||
color: var(--text-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.desc.add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.73em;
|
||||
}
|
||||
.desc.add:hover {
|
||||
color: var(--text-secondary);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.desc-input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
resize: vertical;
|
||||
background: var(--bg-highlight);
|
||||
background: rgba(12, 12, 14, 0.45);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
outline: 2px solid var(--accent);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 0.79em;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.line {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
.meta {
|
||||
font-size: 0.893em;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.line .warn {
|
||||
.meta .warn {
|
||||
color: #e8a33d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
gap: 0.714em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.play-all {
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 24px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
color: #08080a;
|
||||
font-size: 0.786em;
|
||||
font-weight: 800;
|
||||
padding: 0.571em 1.286em;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.play-all:hover:not(:disabled) {
|
||||
.pill:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.play-all:disabled {
|
||||
opacity: 0.5;
|
||||
.pill:disabled {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.round {
|
||||
/* 2em is the pill's own height; matching it keeps the action row one
|
||||
height (the album page's measured rule). */
|
||||
.iconbtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
color 0.14s,
|
||||
border-color 0.14s,
|
||||
background 0.14s;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.round:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--bg-highlight);
|
||||
.iconbtn:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.round:disabled {
|
||||
opacity: 0.45;
|
||||
/* Shuffle stays lit while it is on, so the state it left behind on the
|
||||
player is visible from here rather than being a silent global. */
|
||||
.iconbtn.on {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.iconbtn:disabled {
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
@@ -1000,20 +1242,29 @@
|
||||
margin: 5px 8px;
|
||||
}
|
||||
|
||||
/* The ghost pill's language (translucent white over the banner, blurred),
|
||||
at the action row's 2em height. */
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
padding: 0 0.9em;
|
||||
height: 2em;
|
||||
/* Shrinks with the pane rather than forcing the action row to wrap. */
|
||||
min-width: 130px;
|
||||
max-width: 280px;
|
||||
min-width: 8em;
|
||||
max-width: 17em;
|
||||
flex: 1;
|
||||
border-radius: 18px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.search:focus-within {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
@@ -1021,19 +1272,21 @@
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
font-size: 0.786em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--text-muted);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 500;
|
||||
}
|
||||
.search .clear {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.search .clear:hover {
|
||||
color: var(--text-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
@@ -1059,15 +1312,47 @@
|
||||
.tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 14px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Header and rows share one template so the columns line up — the album
|
||||
page's rule, over this page's own columns: grip, #, art, title, clock.
|
||||
The row actions ride the right end as a hover overlay (below) rather
|
||||
than owning a column. */
|
||||
.tcols,
|
||||
.trow {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 44px 34px minmax(0, 1fr) 48px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.tcols {
|
||||
padding: 0 10px 7px;
|
||||
margin-bottom: 3px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.th-no {
|
||||
text-align: right;
|
||||
}
|
||||
/* Spans the art column too: that is where a row's content begins. */
|
||||
.th-title {
|
||||
grid-column: 3 / 5;
|
||||
}
|
||||
.th-dur {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trow {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13.5px;
|
||||
transition: transform 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1099,7 +1384,6 @@
|
||||
width: 18px;
|
||||
color: transparent;
|
||||
cursor: grab;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.trow:hover .grip,
|
||||
@@ -1109,10 +1393,16 @@
|
||||
.trow.dragged .grip {
|
||||
cursor: grabbing;
|
||||
}
|
||||
/* Reorder is disabled while a filter hides rows; the grip stays for the
|
||||
grid's geometry but neither shows nor invites. */
|
||||
.grip.off {
|
||||
cursor: default;
|
||||
}
|
||||
.trow:hover .grip.off {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.idx {
|
||||
width: 44px;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
@@ -1181,31 +1471,39 @@
|
||||
background: rgba(232, 163, 61, 0.15);
|
||||
color: #e8a33d;
|
||||
}
|
||||
.tag.seg {
|
||||
background: rgba(120, 170, 255, 0.14);
|
||||
color: #8fb8ff;
|
||||
}
|
||||
.tag.miss {
|
||||
background: rgba(241, 94, 108, 0.13);
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
/* The album page's hover reveal: the actions slide over the right end of
|
||||
the row and the duration gets out of the way. */
|
||||
.rowact {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.13s;
|
||||
}
|
||||
.trow:hover .rowact {
|
||||
opacity: 1;
|
||||
.trow:hover .rowact,
|
||||
.trow:focus-within .rowact {
|
||||
display: flex;
|
||||
}
|
||||
.ra {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ra:hover {
|
||||
background: var(--bg-press);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ra.rm:hover {
|
||||
@@ -1214,12 +1512,18 @@
|
||||
|
||||
.du {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
font-size: 11.5px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trow:hover .du,
|
||||
.trow:focus-within .du {
|
||||
/* pointer-events too: opacity forms a stacking context, and .du sits
|
||||
AFTER .rowact in the DOM — invisible, it still painted above the
|
||||
action buttons and ate their clicks. */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eq {
|
||||
display: inline-flex;
|
||||
@@ -1253,7 +1557,8 @@
|
||||
}
|
||||
|
||||
.matchpanel {
|
||||
margin: 2px 10px 8px 74px;
|
||||
/* Indented to the art column's start, under the row it repairs. */
|
||||
margin: 2px 10px 8px 96px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-elevated);
|
||||
@@ -1380,6 +1685,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The album page's narrow-pane step: the whole hero scales down as one
|
||||
piece, with the title pinned to readable fixed sizes. */
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
--cover-scale: 0.62;
|
||||
}
|
||||
.identity {
|
||||
gap: 16px;
|
||||
}
|
||||
.h1,
|
||||
.h1-input {
|
||||
font-size: 32px;
|
||||
}
|
||||
.h1.long,
|
||||
.h1-input.long {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trow {
|
||||
transition: none;
|
||||
|
||||
+250
-66
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
// The playlist shelf: every playlist as a cover card — custom art or the
|
||||
// composed four-cover mosaic — with pinning, duplication, export, and the
|
||||
// two import doors (a playlist file, a pasted tracklist).
|
||||
// the import doors (playlist file, folder, pasted tracklist, YouTube set).
|
||||
import {
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
@@ -10,6 +10,8 @@
|
||||
playAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
|
||||
import { fade } from "svelte/transition";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
|
||||
|
||||
@@ -34,6 +36,27 @@
|
||||
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
|
||||
|
||||
// The stat row's numbers — derived from what the store already carries.
|
||||
const totals = $derived.by(() => {
|
||||
let tracks = 0;
|
||||
let secs = 0;
|
||||
for (const p of playlistStore.playlists) {
|
||||
tracks += p.track_count ?? 0;
|
||||
secs += p.duration_secs ?? 0;
|
||||
}
|
||||
return { tracks, secs };
|
||||
});
|
||||
|
||||
// The ambience wash borrows the first pinned playlist's cover, else the
|
||||
// first cover that has resolved. Covers arrive lazily, so this settles a
|
||||
// beat after the cards do — the keyed fade below makes that a dissolve.
|
||||
const ambUrl = $derived.by(() => {
|
||||
const pick =
|
||||
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
|
||||
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
|
||||
return pick ? playlistStore.covers[pick.id] : null;
|
||||
});
|
||||
|
||||
async function newPlaylist() {
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
@@ -90,6 +113,23 @@
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
// A finished background listen: the chip's "review" click lands here —
|
||||
// consume the waiting draft into the ordinary review modal.
|
||||
$effect(() => {
|
||||
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
|
||||
importDraft = takeScanDraft();
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function importFromYoutube() {
|
||||
newMenuOpen = false;
|
||||
importDraft = null;
|
||||
importMode = "youtube";
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
@@ -102,21 +142,49 @@
|
||||
const gen = ++playCardGen;
|
||||
const rows = await api.playlistItems(pl.id);
|
||||
if (gen !== playCardGen) return;
|
||||
const playable = rows.filter((r) => r.path && !r.missing);
|
||||
// Present local files plus stream rows — streams queue as pending
|
||||
// items that resolve when reached, same as the playlist page's Play.
|
||||
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
|
||||
if (!playable.length) {
|
||||
open(pl); // nothing playable: the page explains why
|
||||
return;
|
||||
}
|
||||
// Set-recording slices: each is bounded by the NEXT stamped row in
|
||||
// the full order (the playlist page derives the same map).
|
||||
const segmentEnds = new Map();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
if (r.kind !== "segment" || r.start_ms == null) continue;
|
||||
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
|
||||
segmentEnds.set(r.id, next ? next.start_ms : null);
|
||||
}
|
||||
playAlbum(
|
||||
playable.map((r) => ({
|
||||
path: r.path,
|
||||
playable.map((r) => {
|
||||
const local = r.path && !r.missing;
|
||||
const tid = Number(r.track_id);
|
||||
const endMs = segmentEnds.get(r.id);
|
||||
const segment =
|
||||
r.kind === "segment" && local && r.start_ms != null
|
||||
? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
|
||||
: null;
|
||||
return {
|
||||
path: local ? r.path : null,
|
||||
needsResolve: !local,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs: r.duration_secs,
|
||||
art_path: r.art_path,
|
||||
duration_secs:
|
||||
r.duration_secs ??
|
||||
(segment && segment.end != null
|
||||
? Math.round(segment.end - segment.start)
|
||||
: null),
|
||||
art_path: local ? r.art_path : null,
|
||||
artUrl: local ? null : (r.art_url ?? null),
|
||||
trackId: Number.isFinite(tid) ? tid : null,
|
||||
plItemId: r.id,
|
||||
})),
|
||||
segment,
|
||||
};
|
||||
}),
|
||||
pl.name,
|
||||
0,
|
||||
);
|
||||
@@ -186,20 +254,28 @@
|
||||
<svelte:window onclick={pageClick} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="head-text">
|
||||
<h1>Playlists</h1>
|
||||
{#if playlistStore.playlists.length}
|
||||
<p class="sub">
|
||||
{playlistStore.playlists.length}
|
||||
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
|
||||
</p>
|
||||
{#if ambUrl}
|
||||
<!-- The Library's ambience recipe: a blurred cover pinned behind the
|
||||
glass, faded out before the cards. Keyed so a cover change
|
||||
CROSSFADES — background-image is not an animatable property, so
|
||||
the outgoing wash survives as its own element while the incoming
|
||||
one fades up over it. -->
|
||||
{#key ambUrl}
|
||||
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
|
||||
{/key}
|
||||
<div class="ambience-glow"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
|
||||
<!-- The Library's own header shape: h1 + tools on one line, the stat row
|
||||
under them. An empty shelf keeps the same header — the Import door
|
||||
matters most at the moment there is nothing here. -->
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<h1>Playlists</h1>
|
||||
<div class="tools">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="btn ghost"
|
||||
class="tool"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuFor = null;
|
||||
@@ -226,13 +302,37 @@
|
||||
Paste a tracklist…
|
||||
<span class="hint">plain lines or timestamped setlist</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromYoutube}>
|
||||
<Icon name="link" size={15} />
|
||||
YouTube DJ set…
|
||||
<span class="hint">tracks from chapters, description or comments</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
|
||||
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if playlistStore.playlists.length}
|
||||
<div class="stats">
|
||||
<span class="stat">
|
||||
<b>{playlistStore.playlists.length}</b>
|
||||
<i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
|
||||
</span>
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
|
||||
{#if totals.secs}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
|
||||
{/if}
|
||||
{#if pinned.length}
|
||||
<span class="stat-div"></span>
|
||||
<span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
@@ -353,63 +453,147 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* The Library page's own frame: the blurred ambience pins behind the
|
||||
glass bar, so the page starts at the very top of the pane and carries
|
||||
its own inset instead of taking padding-top from the BAR_VIEWS rule
|
||||
(the Library exception, documented at the set in App.svelte). The
|
||||
isolation keeps the z-indexed washes inside this page's own stacking
|
||||
context — without it they escape past the transparent pane and paint
|
||||
behind the app's opaque black. */
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--pad-top: calc(var(--bar-h, 46px) + 14px);
|
||||
padding: var(--pad-top) 0 40px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* ------------------------------------------------------------ ambience
|
||||
The Library's recipe at this page's colour: blurred cover, faded out
|
||||
before the cards; z-index 0 lies OVER the in-flow header (that is the
|
||||
approved look — the h1 carries a text-shadow for it), pointer-events
|
||||
none so nothing under it goes dead. */
|
||||
.ambience {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 430px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-position: center 30%;
|
||||
filter: blur(90px) saturate(1.5);
|
||||
opacity: 0.2;
|
||||
transform: translateZ(0);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
mask-image: linear-gradient(180deg, #000 35%, transparent);
|
||||
}
|
||||
.ambience-glow {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
right: -28px;
|
||||
top: calc(-1 * var(--pad-top));
|
||||
height: 300px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
640px 260px at 14% 0%,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- hero */
|
||||
.hero {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 31px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* The Library's tool button: translucent elevated glass, one height. */
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s,
|
||||
transform 0.14s;
|
||||
}
|
||||
.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);
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-press);
|
||||
.tool:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
.tool:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.tool.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #08080a;
|
||||
}
|
||||
.tool.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: #08080a;
|
||||
}
|
||||
|
||||
/* The stat row — the Library's value/label idiom for the collection. */
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 15px 0 20px;
|
||||
}
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat i {
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-div {
|
||||
width: 1px;
|
||||
height: 17px;
|
||||
flex: none;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
<script>
|
||||
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||||
import { setWordFill } from "../lib/lyrics.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let settings = $state(null);
|
||||
let saving = $state(false);
|
||||
// Lyric alignment model (Tier B word timing) — downloaded only from the
|
||||
// explicit click below, per the preparation-worker consent charter.
|
||||
let lyricModel = $state(null);
|
||||
let modelBusy = $state(false);
|
||||
let modelPct = $state(0);
|
||||
let modelError = $state("");
|
||||
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
|
||||
// row below, shown only once the aligner model above exists.
|
||||
let sepModel = $state(null);
|
||||
let sepBusy = $state(false);
|
||||
let sepPct = $state(0);
|
||||
let sepError = $state("");
|
||||
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
|
||||
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
|
||||
$effect(() => {
|
||||
let unlisten = null;
|
||||
let unlistenSep = null;
|
||||
void import("@tauri-apps/api/event").then(({ listen }) => {
|
||||
listen("lyrics:model-progress", (e) => {
|
||||
const p = e.payload ?? {};
|
||||
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
|
||||
}).then((u) => (unlisten = u));
|
||||
listen("lyrics:sep-model-progress", (e) => {
|
||||
const p = e.payload ?? {};
|
||||
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
|
||||
}).then((u) => (unlistenSep = u));
|
||||
});
|
||||
return () => {
|
||||
unlisten?.();
|
||||
unlistenSep?.();
|
||||
};
|
||||
});
|
||||
async function downloadModel() {
|
||||
if (modelBusy) return;
|
||||
modelBusy = true;
|
||||
modelError = "";
|
||||
modelPct = 0;
|
||||
try {
|
||||
await api.lyricModelDownload();
|
||||
lyricModel = await api.lyricModelStatus();
|
||||
} catch (e) {
|
||||
modelError = String(e).slice(0, 160);
|
||||
} finally {
|
||||
modelBusy = false;
|
||||
}
|
||||
}
|
||||
async function downloadSepModel() {
|
||||
if (sepBusy) return;
|
||||
sepBusy = true;
|
||||
sepError = "";
|
||||
sepPct = 0;
|
||||
try {
|
||||
await api.vocalSepDownload();
|
||||
sepModel = await api.vocalSepStatus();
|
||||
} catch (e) {
|
||||
sepError = String(e).slice(0, 160);
|
||||
} finally {
|
||||
sepBusy = false;
|
||||
}
|
||||
}
|
||||
let savedFlash = $state(false);
|
||||
let saveError = $state("");
|
||||
let refreshingArtists = $state(false);
|
||||
@@ -44,6 +105,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setWordTiming(next) {
|
||||
if (next !== settings.word_timing) {
|
||||
await commit({ wordTiming: next });
|
||||
// Applies live: surfaces stop/start rendering word markup
|
||||
// reactively, and turning it on asks for the current track.
|
||||
setWordFill(next);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseDownloadDir() {
|
||||
const dir = await api.pickFolder();
|
||||
if (dir) await commit({ downloadDir: dir });
|
||||
@@ -250,6 +320,79 @@
|
||||
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Lyrics</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Word-by-word fill <span class="exp-tag">Experimental</span></div>
|
||||
<div class="hint">
|
||||
Fills each word as it is sung, karaoke-style. Still experimental: timing
|
||||
can drift on dense or echo-heavy songs. Off keeps the reliable
|
||||
line-by-line sync everywhere and pauses the background word analysis.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.word_timing ? "on" : "off"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
|
||||
>
|
||||
<option value="off">Off (recommended for now)</option>
|
||||
<option value="on">On</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if settings.word_timing}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Precise word timing</div>
|
||||
<div class="hint">
|
||||
Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that
|
||||
listens to each lyric line and times every word against the actual singing,
|
||||
in most languages. Runs entirely on this computer, in the background, never
|
||||
against the playing track. Without it, word timing stays a lighter estimate.
|
||||
Already-timed songs re-prepare automatically the next time you open their
|
||||
lyrics.
|
||||
</div>
|
||||
</div>
|
||||
{#if lyricModel?.present}
|
||||
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
|
||||
{:else}
|
||||
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
|
||||
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if modelError}
|
||||
<p class="muted small">Download failed: {modelError}</p>
|
||||
{/if}
|
||||
{#if lyricModel?.present}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Vocal isolation</div>
|
||||
<div class="hint">
|
||||
Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from
|
||||
the Ultimate Vocal Remover project) that lifts the singing away from
|
||||
the band before words are timed — noticeably steadier timing on dense
|
||||
mixes. Same rules as above: runs on this computer, in the background,
|
||||
never against the playing track. Timed songs re-prepare the next time
|
||||
you open their lyrics.
|
||||
</div>
|
||||
</div>
|
||||
{#if sepModel?.present}
|
||||
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
|
||||
{:else}
|
||||
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
|
||||
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sepError}
|
||||
<p class="muted small">Download failed: {sepError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Streaming</h2>
|
||||
<div class="row">
|
||||
@@ -736,4 +879,44 @@
|
||||
.save-error {
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.model-btn {
|
||||
flex: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.model-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.model-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
.exp-tag {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.model-ok {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #3ecf7e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user