sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane
This commit is contained in:
1 parent
606250c361
commit
eba29824c9
12 files changed
+1595
-281
No files matched your search
+173
-4
@@ -1,7 +1,7 @@
|
||||
//! Music library: SQLite-backed albums and tracks. All methods are
|
||||
//! synchronous — call from a blocking context.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
@@ -70,6 +70,37 @@ pub struct PlayStats {
|
||||
pub top_count: u32,
|
||||
}
|
||||
|
||||
/// One album in the sidebar's recently-played list.
|
||||
///
|
||||
/// Two kinds, because most listening here is STREAMED and never touches the
|
||||
/// local library. A streamed play is anchored on the catalog album id — an
|
||||
/// exact id, not a name — and carries its own remote cover.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecentAlbum {
|
||||
/// "library" or "catalog" — decides which page a click opens.
|
||||
pub kind: String,
|
||||
pub album_id: Option<i64>,
|
||||
pub catalog_album_id: Option<String>,
|
||||
pub artist: String,
|
||||
pub title: String,
|
||||
/// Local cover, a file path (library rows).
|
||||
pub art_path: Option<String>,
|
||||
/// Remote cover URL (catalog rows).
|
||||
pub art_url: Option<String>,
|
||||
/// Newest play of this album, so the frontend can group by day.
|
||||
pub last_played_at: i64,
|
||||
}
|
||||
|
||||
/// Catalog identity for a streamed play. `album_id` is the anchor; the rest
|
||||
/// ride along because a streamed album has no local row to read them back from.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CatalogPlay<'a> {
|
||||
pub album_id: Option<&'a str>,
|
||||
pub title: Option<&'a str>,
|
||||
pub artist: Option<&'a str>,
|
||||
pub cover: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct Library {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
@@ -129,10 +160,50 @@ impl Library {
|
||||
seconds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC);
|
||||
",
|
||||
)?;
|
||||
// Migrate databases created before artist portraits were added.
|
||||
let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []);
|
||||
// Migrate databases created before plays remembered which album they
|
||||
// belonged to. Rows written before this stay NULL and are simply absent
|
||||
// from the recents list — they cannot be recovered by matching on name,
|
||||
// and guessing an album from a title is exactly what the identity rules
|
||||
// forbid.
|
||||
let _ = conn.execute("ALTER TABLE plays ADD COLUMN album_id INTEGER", []);
|
||||
// Streamed listening never touches the local library, so it needs its
|
||||
// own anchor: the catalog album id. Exact id, never a name match. The
|
||||
// title/artist/cover ride along because a streamed album has no local
|
||||
// row to read them back from.
|
||||
let _ = conn.execute("ALTER TABLE plays ADD COLUMN catalog_album_id TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_title TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_artist TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_cover TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_plays_catalog ON plays(catalog_album_id)",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_plays_album ON plays(album_id)",
|
||||
[],
|
||||
);
|
||||
// Backfill history that predates the column — but ONLY where the
|
||||
// (artist, title) pair resolves to exactly ONE album in the library.
|
||||
// A title that appears on both an album and a compilation is genuinely
|
||||
// ambiguous, so it is left NULL rather than guessed at: a name is not
|
||||
// an identity, and a wrong cover is worse than no row. Streamed plays
|
||||
// have no library track at all and stay NULL forever, which is correct.
|
||||
let _ = conn.execute(
|
||||
"UPDATE plays SET album_id = (
|
||||
SELECT t.album_id FROM tracks t
|
||||
WHERE t.title = plays.title COLLATE NOCASE
|
||||
AND t.artist = plays.artist COLLATE NOCASE
|
||||
GROUP BY t.title
|
||||
HAVING COUNT(DISTINCT t.album_id) = 1
|
||||
)
|
||||
WHERE album_id IS NULL",
|
||||
[],
|
||||
);
|
||||
// Migrate databases created before per-track audio props were stored.
|
||||
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []);
|
||||
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []);
|
||||
@@ -194,15 +265,113 @@ impl Library {
|
||||
// first accumulates listened time and tops up its seconds while it plays,
|
||||
// so a skipped track never writes a row and per-play counts stay honest.
|
||||
|
||||
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
|
||||
/// `path` is the track's file path, and it is the ONLY thing used to find
|
||||
/// the album: `tracks.path` is UNIQUE, so this is an exact anchor. Never
|
||||
/// resolve the album by artist/title — a name match is not an identity.
|
||||
/// A streamed track with no library row simply records album_id NULL.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn record_play(
|
||||
&self,
|
||||
artist: &str,
|
||||
title: &str,
|
||||
path: Option<&str>,
|
||||
cat: CatalogPlay<'_>,
|
||||
now: i64,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let album_id: Option<i64> = match path {
|
||||
Some(p) => conn
|
||||
.query_row(
|
||||
"SELECT album_id FROM tracks WHERE path = ?1",
|
||||
rusqlite::params![p],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?,
|
||||
None => None,
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)",
|
||||
rusqlite::params![artist, title, now],
|
||||
"INSERT INTO plays
|
||||
(artist, title, started_at, seconds, album_id,
|
||||
catalog_album_id, cat_title, cat_artist, cat_cover)
|
||||
VALUES (?1, ?2, ?3, 0, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
artist,
|
||||
title,
|
||||
now,
|
||||
album_id,
|
||||
cat.album_id,
|
||||
cat.title,
|
||||
cat.artist,
|
||||
cat.cover
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// The sidebar's recently-played list: one row per album, most recent first.
|
||||
///
|
||||
/// `min_secs` is not optional in spirit. A play row is opened after ~1s of
|
||||
/// audio, so every skip writes one; without a floor this is a skip log, not
|
||||
/// a history. Measured on the live table: 183 grouped runs, 135 of which
|
||||
/// survive a 45-second floor.
|
||||
/// Count of play rows, so a destructive call can report what it will take.
|
||||
pub fn play_row_count(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM plays", [], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
/// Wipe all listening history. Returns the number of rows removed.
|
||||
/// Only `plays` — albums, tracks and playlists are untouched.
|
||||
pub fn clear_play_history(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.execute("DELETE FROM plays", [])?)
|
||||
}
|
||||
|
||||
pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result<Vec<RecentAlbum>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Two sources, one list. The catalog arm excludes rows that already
|
||||
// have a library album so a local track with catalog identity cannot
|
||||
// appear twice. Within one catalog_album_id the title/artist/cover are
|
||||
// identical on every row, so MAX() is just "pick one".
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT * FROM (
|
||||
SELECT 'library' AS kind, a.id AS lib_id, NULL AS cat_id,
|
||||
a.artist AS artist, a.title AS title,
|
||||
a.art_path AS art_path, NULL AS art_url,
|
||||
MAX(p.started_at) AS last_at
|
||||
FROM plays p
|
||||
JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.album_id IS NOT NULL AND p.seconds >= ?2
|
||||
GROUP BY a.id
|
||||
UNION ALL
|
||||
SELECT 'catalog', NULL, p.catalog_album_id,
|
||||
MAX(p.cat_artist), MAX(p.cat_title),
|
||||
NULL, MAX(p.cat_cover),
|
||||
MAX(p.started_at)
|
||||
FROM plays p
|
||||
WHERE p.catalog_album_id IS NOT NULL
|
||||
AND p.album_id IS NULL
|
||||
AND p.seconds >= ?2
|
||||
GROUP BY p.catalog_album_id
|
||||
)
|
||||
ORDER BY last_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![limit, min_secs], |r| {
|
||||
Ok(RecentAlbum {
|
||||
kind: r.get(0)?,
|
||||
album_id: r.get(1)?,
|
||||
catalog_album_id: r.get(2)?,
|
||||
artist: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||
title: r.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
||||
art_path: r.get(5)?,
|
||||
art_url: r.get(6)?,
|
||||
last_played_at: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Cold-start bug audit for the artist-page pipeline. Companion to
|
||||
//! `artist_page_bench` (latency) — this one checks INVARIANTS across a spread
|
||||
//! of artist shapes and prints a statistics row per artist:
|
||||
//!
|
||||
//! - overview / page / identity wall-clock, run the way the frontend runs
|
||||
//! them (all three overlapped, one fresh client per artist, no disk cache)
|
||||
//! - album and popular-row counts at each stage
|
||||
//! - listeners coverage (the Last.fm anchor working end-to-end)
|
||||
//! - release-date coverage (the MusicBrainz dates pass reaching the page)
|
||||
//! - BUG lines for anything that violates an invariant
|
||||
//!
|
||||
//! Artists are audited SEQUENTIALLY — each fresh client has its own
|
||||
//! MusicBrainz pacer, and overlapping artists would multiply request rate
|
||||
//! against the 1 req/s policy (and contaminate the numbers, see the
|
||||
//! paced-probe rule).
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run -p metadata --release --example cold_audit -- ["Artist" ...]
|
||||
//! (no args = the built-in spread)
|
||||
|
||||
use metadata::MetadataClient;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut artists: Vec<String> = std::env::args().skip(1).collect();
|
||||
if artists.is_empty() {
|
||||
artists = [
|
||||
"Alvvays", // small, clean MB identity
|
||||
"Weezer", // medium, deep catalog
|
||||
"Queen", // huge, 1119 MB pressings
|
||||
"Death Grips", // mixtape/street edge
|
||||
"King Gizzard & The Lizard Wizard", // Last.fm mbid-index miss + '&'
|
||||
"Boards of Canada", // electronic, sparse Deezer
|
||||
"Duster", // namesake-collision shape
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut bugs: Vec<String> = Vec::new();
|
||||
println!(
|
||||
"{:<36} {:>8} {:>8} {:>8} {:>6} {:>6} {:>7} {:>9} {:>8}",
|
||||
"artist", "overview", "page", "identity", "ov-alb", "pg-alb", "popular", "listeners", "dates"
|
||||
);
|
||||
|
||||
for artist in &artists {
|
||||
let client = Arc::new(MetadataClient::new());
|
||||
let search = client.catalog_search(artist).await;
|
||||
let Some(found) = search
|
||||
.artists
|
||||
.iter()
|
||||
.find(|a| normalize(&a.name) == normalize(artist))
|
||||
else {
|
||||
bugs.push(format!("{artist}: no exact-name artist in catalog search"));
|
||||
continue;
|
||||
};
|
||||
let id = found.id;
|
||||
|
||||
// The frontend shape: overview, page and the identity warm-up all in
|
||||
// flight together on one client.
|
||||
let (overview, page, identity) = tokio::join!(
|
||||
async {
|
||||
let t = Instant::now();
|
||||
let overview = client.catalog_artist_overview(id).await;
|
||||
(t.elapsed().as_millis(), overview)
|
||||
},
|
||||
async {
|
||||
let t = Instant::now();
|
||||
let page = client.catalog_artist(id).await;
|
||||
(t.elapsed().as_millis(), page)
|
||||
},
|
||||
async {
|
||||
let t = Instant::now();
|
||||
let identity = client.catalog_artist_identity(id, artist).await;
|
||||
(t.elapsed().as_millis(), identity)
|
||||
},
|
||||
);
|
||||
|
||||
let (ov_ms, overview) = overview;
|
||||
let (pg_ms, page) = page;
|
||||
let (id_ms, identity) = identity;
|
||||
|
||||
let mut flag = |what: &str| bugs.push(format!("{artist}: {what}"));
|
||||
|
||||
let (ov_albums, _ov_popular) = match &overview {
|
||||
Some(ov) => {
|
||||
if ov.artist.id != id {
|
||||
flag("overview echoed a different artist id");
|
||||
}
|
||||
if ov.artist.name.is_empty() {
|
||||
flag("overview artist name empty");
|
||||
}
|
||||
if ov.albums.is_empty() {
|
||||
flag("overview albums empty");
|
||||
}
|
||||
if ov.popular_tracks.is_empty() {
|
||||
flag("overview popular empty");
|
||||
}
|
||||
(ov.albums.len(), ov.popular_tracks.len())
|
||||
}
|
||||
None => {
|
||||
flag("overview returned None");
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
let (pg_albums, pg_popular, with_listeners, with_dates) = match &page {
|
||||
Some(pg) => {
|
||||
if pg.artist.id != id {
|
||||
flag("page echoed a different artist id");
|
||||
}
|
||||
if pg.albums.is_empty() {
|
||||
flag("page albums empty");
|
||||
}
|
||||
if pg.popular_tracks.is_empty() {
|
||||
flag("page popular empty");
|
||||
}
|
||||
let listeners = pg
|
||||
.popular_tracks
|
||||
.iter()
|
||||
.filter(|t| t.listeners.is_some())
|
||||
.count();
|
||||
let dates = pg
|
||||
.albums
|
||||
.iter()
|
||||
.filter(|a| a.release_date.is_some())
|
||||
.count();
|
||||
// The page waits for real supplements; zero listener counts on
|
||||
// a popular row set means the Last.fm anchor died end-to-end.
|
||||
if !pg.popular_tracks.is_empty() && listeners == 0 {
|
||||
flag("page popular has zero Last.fm listener counts");
|
||||
}
|
||||
(pg.albums.len(), pg.popular_tracks.len(), listeners, dates)
|
||||
}
|
||||
None => {
|
||||
flag("page returned None");
|
||||
(0, 0, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
if identity.artist_id != id {
|
||||
flag("identity echoed a different artist id");
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<36} {:>6}ms {:>6}ms {:>6}ms {:>6} {:>6} {:>7} {:>6}/{:<2} {:>4}/{:<3}",
|
||||
artist, ov_ms, pg_ms, id_ms, ov_albums, pg_albums, pg_popular,
|
||||
with_listeners, pg_popular, with_dates, pg_albums
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
if bugs.is_empty() {
|
||||
println!("NO INVARIANT FAILURES");
|
||||
} else {
|
||||
println!("FAILURES ({}):", bugs.len());
|
||||
for bug in &bugs {
|
||||
println!(" BUG {bug}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
+294
-61
@@ -361,6 +361,15 @@ const MB_RELEASE_GROUP_PAGES: usize = 3;
|
||||
/// The failures this exists for are transient by nature — a 503 out of the
|
||||
/// 1 req/s limiter, or this machine having throttled itself with probes — and
|
||||
/// the cost of giving up is the whole date + attribution pass for that artist.
|
||||
/// How long the overview holds first paint for the supplements chain (linked
|
||||
/// MBID → Last.fm ranks + original release dates). Warm the chain answers in
|
||||
/// single-digit milliseconds and the budget never bites; cold it is 2.2s+ of
|
||||
/// MusicBrainz behind the 1 req/s gate, and the overview paints without it
|
||||
/// rather than becoming the slow page it exists to preempt. Wide enough for
|
||||
/// a disk-cache read on a busy runtime, an order of magnitude under a cold
|
||||
/// network chain.
|
||||
const OVERVIEW_SUPPLEMENTS_BUDGET: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Two retries behind a widening backoff is enough to ride out a blip without
|
||||
/// turning a genuine outage into a slow page: the request rides the same
|
||||
/// 1 req/s gate as everything else, and the identity pass is deferred, so
|
||||
@@ -1110,6 +1119,13 @@ pub struct MetadataClient {
|
||||
/// The fast artwork response and full page intentionally overlap, so this
|
||||
/// prevents their common profile/provider lookups from being duplicated.
|
||||
catalog_inflight: Mutex<HashMap<String, CatalogFetch>>,
|
||||
/// URLs whose most recent failure was a 4xx — an answer about the
|
||||
/// RESOURCE (it does not exist), not about the request. `cached_json`
|
||||
/// deliberately caches no failures, so its `None` conflates "MusicBrainz
|
||||
/// has no such entity" with "MusicBrainz hiccuped"; this marker is what
|
||||
/// lets a caller retry the hiccup without punishing every legitimately
|
||||
/// absent resource with the same ladder. Entries clear on later success.
|
||||
definitive_miss: Mutex<HashSet<String>>,
|
||||
/// Artist name → (fetched-at, resolved art). Art resolution hits two
|
||||
/// providers (TheAudioDB + Fanart.tv); this keeps repeat artist visits
|
||||
/// instant and keeps us polite to both services.
|
||||
@@ -1209,6 +1225,7 @@ impl MetadataClient {
|
||||
deezer_gate: Mutex::new(VecDeque::new()),
|
||||
catalog_cache: Mutex::new(HashMap::new()),
|
||||
catalog_inflight: Mutex::new(HashMap::new()),
|
||||
definitive_miss: Mutex::new(HashSet::new()),
|
||||
art_cache: Mutex::new(HashMap::new()),
|
||||
art_inflight: Mutex::new(HashMap::new()),
|
||||
provider_art_cache: Mutex::new(HashMap::new()),
|
||||
@@ -1600,6 +1617,13 @@ impl MetadataClient {
|
||||
Some(Instant::now() + retry_after);
|
||||
return Err(());
|
||||
}
|
||||
// A 4xx is definitive — the resource is absent,
|
||||
// not the server unwell. Mark it so retry ladders
|
||||
// upstream can tell the two apart (see
|
||||
// `definitive_miss`).
|
||||
if status.is_client_error() {
|
||||
self.definitive_miss.lock().await.insert(url.to_string());
|
||||
}
|
||||
net_trace(url, &format!("http-{status}"));
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -1625,6 +1649,7 @@ impl MetadataClient {
|
||||
let Some(body) = body else {
|
||||
return None;
|
||||
};
|
||||
self.definitive_miss.lock().await.remove(url);
|
||||
if body.get("error").is_none() {
|
||||
if let Some(path) = disk.as_deref() {
|
||||
write_disk_cache(path, url, &body);
|
||||
@@ -3453,24 +3478,72 @@ impl MetadataClient {
|
||||
/// artist id is known, so a cold page pays one network round rather than
|
||||
/// waiting for provider artwork or the exhaustive detail/classification
|
||||
/// pass used by `catalog_artist`.
|
||||
pub async fn catalog_artist_overview(&self, artist_id: u64) -> Option<CatalogArtistOverview> {
|
||||
pub async fn catalog_artist_overview(
|
||||
self: &Arc<Self>,
|
||||
artist_id: u64,
|
||||
) -> Option<CatalogArtistOverview> {
|
||||
let overview_started = Instant::now();
|
||||
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
|
||||
let albums_url =
|
||||
format!("https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0");
|
||||
let popular_url =
|
||||
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
|
||||
// The Last.fm chain needs only the artist id, so it rides in this join
|
||||
// rather than extending the critical path behind it. Both of its hops
|
||||
// are disk cached for a day; a cold page overlaps them with the Apple
|
||||
// identity work below, which takes longer.
|
||||
let (profile, album_page, popular, supplements) = tokio::join!(
|
||||
// The supplements chain (linked MBID → Last.fm ranks + original
|
||||
// release dates) rides this join under a BUDGET. Warm — memory or
|
||||
// same-day disk cache, every visit after the first — it answers in
|
||||
// single-digit milliseconds and rides for free, and the page is the
|
||||
// page it always was. Cold it is a MusicBrainz chain serialized
|
||||
// behind the 1 req/s gate, measured 2.2–2.6s on a THREE-album artist
|
||||
// against ~300ms for everything else in this join — it had quietly
|
||||
// become the whole critical path of the stage that exists to paint
|
||||
// fast. A missed budget cancels nothing: the task is spawned, keeps
|
||||
// filling the exact caches the full page is about to read, and that
|
||||
// page — which genuinely needs the dates for classification and
|
||||
// replaces this stage's content wholesale — collects the answer
|
||||
// moments later. The discography FLIP already glides that correction.
|
||||
// Overview: no recordings browse (the `false`). It paints fast and
|
||||
// the full page replaces its Popular list wholesale.
|
||||
let supplements_task = tokio::spawn({
|
||||
let client = Arc::clone(self);
|
||||
async move { client.artist_supplements(artist_id, false).await }
|
||||
});
|
||||
// Candidate discovery needs only the name; the identity SELECTION
|
||||
// needs this join's album evidence and is pure CPU. Split exactly the
|
||||
// way the full page splits it, so the Apple round trip rides the join
|
||||
// instead of queueing serially behind it.
|
||||
let apple_fut = async {
|
||||
let name = self
|
||||
.deezer(&profile_url)
|
||||
.await
|
||||
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
|
||||
match name {
|
||||
Some(name) => self.apple_artist_candidates(&name).await,
|
||||
None => Arc::new(Vec::new()),
|
||||
}
|
||||
};
|
||||
let (profile, album_page, popular, apple_candidates, supplements) = tokio::join!(
|
||||
self.deezer(&profile_url),
|
||||
self.deezer(&albums_url),
|
||||
self.deezer(&popular_url),
|
||||
// Overview: no recordings browse. It paints fast and the full page
|
||||
// replaces its Popular list wholesale.
|
||||
self.artist_supplements(artist_id, false),
|
||||
apple_fut,
|
||||
async {
|
||||
let supplements_started = Instant::now();
|
||||
match tokio::time::timeout(OVERVIEW_SUPPLEMENTS_BUDGET, supplements_task).await {
|
||||
Ok(Ok(supplements)) => {
|
||||
stage_trace("overview.supplements", supplements_started, "within-budget");
|
||||
supplements
|
||||
}
|
||||
Ok(Err(_)) => ArtistSupplements::default(),
|
||||
Err(_) => {
|
||||
stage_trace(
|
||||
"overview.supplements",
|
||||
supplements_started,
|
||||
"budget-missed, detached",
|
||||
);
|
||||
ArtistSupplements::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
stage_trace("overview.deezer_join", overview_started, "");
|
||||
let mut artist = parse_artist(&profile?)?;
|
||||
@@ -3481,13 +3554,13 @@ impl MetadataClient {
|
||||
.iter()
|
||||
.filter_map(deezer_release_evidence)
|
||||
.collect();
|
||||
// Resolve every exact-name Apple performer, then use the requested
|
||||
// Deezer id's weighted release evidence to identify the right one.
|
||||
// This adds no MusicBrainz traffic and shares its Apple I/O with the
|
||||
// full page when both frontend stages overlap.
|
||||
// Select among the exact-name Apple performers fetched in the join,
|
||||
// using the requested Deezer id's weighted release evidence to
|
||||
// identify the right one. This adds no MusicBrainz traffic and shares
|
||||
// its Apple I/O with the full page when both frontend stages overlap.
|
||||
let apple_started = Instant::now();
|
||||
let apple = self
|
||||
.apple_catalog_for_deezer(artist_id, &artist.name, &evidence)
|
||||
.apple_catalog_for_deezer_candidates(artist_id, &artist.name, apple_candidates, &evidence)
|
||||
.await;
|
||||
stage_trace("overview.apple", apple_started, "");
|
||||
stage_trace("overview.total", overview_started, "");
|
||||
@@ -3527,14 +3600,91 @@ impl MetadataClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Popular's fast lane. The overview paints the section immediately under
|
||||
/// its supplements BUDGET — cold, that means Deezer's order with no
|
||||
/// listener counts — and the REAL list (Last.fm order + counts) used to
|
||||
/// arrive only with the full page, queued behind the dates and recordings
|
||||
/// walks it never reads. This waits on exactly what Popular consumes: the
|
||||
/// linked MBID and one Last.fm call. Every Deezer/Apple input rides the
|
||||
/// caches the overview just filled, and the ranks chain singleflights
|
||||
/// with the overview's detached supplements task, so this usually JOINS
|
||||
/// in-flight work rather than adding any. Cold it answers in ~2s against
|
||||
/// the full page's 6–9s; warm it is the overview's own list.
|
||||
///
|
||||
/// The full page still owns the final word — re-attribution and the
|
||||
/// Last.fm-ranked tracks Deezer does not sell arrive with it.
|
||||
pub async fn catalog_artist_popular(
|
||||
self: &Arc<Self>,
|
||||
artist_id: u64,
|
||||
) -> Option<Vec<CatalogTrack>> {
|
||||
let popular_started = Instant::now();
|
||||
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
|
||||
let albums_url =
|
||||
format!("https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0");
|
||||
let popular_url =
|
||||
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
|
||||
let ranks_fut = async {
|
||||
let mbid = self.deezer_linked_mbid(artist_id).await?;
|
||||
self.lastfm_listener_ranks(&mbid).await
|
||||
};
|
||||
let apple_fut = async {
|
||||
let name = self
|
||||
.deezer(&profile_url)
|
||||
.await
|
||||
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
|
||||
match name {
|
||||
Some(name) => self.apple_artist_candidates(&name).await,
|
||||
None => Arc::new(Vec::new()),
|
||||
}
|
||||
};
|
||||
let (profile, album_page, popular, apple_candidates, ranks) = tokio::join!(
|
||||
self.deezer(&profile_url),
|
||||
self.deezer(&albums_url),
|
||||
self.deezer(&popular_url),
|
||||
apple_fut,
|
||||
ranks_fut,
|
||||
);
|
||||
let artist = parse_artist(&profile?)?;
|
||||
let album_page = album_page?;
|
||||
let album_items = album_page.get("data")?.as_array()?;
|
||||
let evidence: Vec<DeezerReleaseEvidence> = album_items
|
||||
.iter()
|
||||
.filter_map(deezer_release_evidence)
|
||||
.collect();
|
||||
let apple = self
|
||||
.apple_catalog_for_deezer_candidates(
|
||||
artist_id,
|
||||
&artist.name,
|
||||
apple_candidates,
|
||||
&evidence,
|
||||
)
|
||||
.await;
|
||||
if apple.identity_unavailable {
|
||||
return None;
|
||||
}
|
||||
// The same foreign filter the overview applies to its pool.
|
||||
let mut foreign_album_ids = HashSet::new();
|
||||
for item in album_items {
|
||||
let Some(album) = parse_album(item) else { continue };
|
||||
let genre_id = item.get("genre_id").and_then(|id| id.as_u64());
|
||||
if apple_release_is_foreign(&apple, &album.title, genre_id) {
|
||||
foreign_album_ids.insert(album.id);
|
||||
}
|
||||
}
|
||||
let pool = deezer_popular_pool(popular.as_ref(), artist_id, &foreign_album_ids, &apple);
|
||||
let ranks = ranks.unwrap_or_default();
|
||||
let tracks = apply_lastfm_order(pool, &ranks.by_key, POPULAR_KEEP);
|
||||
stage_trace(
|
||||
"popular.total",
|
||||
popular_started,
|
||||
&format!("{} rows, ranked={}", tracks.len(), !ranks.by_key.is_empty()),
|
||||
);
|
||||
(!tracks.is_empty()).then_some(tracks)
|
||||
}
|
||||
|
||||
pub async fn catalog_artist(self: &Arc<Self>, artist_id: u64) -> Option<CatalogArtistPage> {
|
||||
let page_started = Instant::now();
|
||||
let profile = self
|
||||
.deezer(&format!("https://api.deezer.com/artist/{artist_id}"))
|
||||
.await?;
|
||||
stage_trace("page.profile", page_started, "");
|
||||
let mut artist = parse_artist(&profile)?;
|
||||
self.apply_known_portrait(&mut artist);
|
||||
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
|
||||
|
||||
// Provider artwork is an independent frontend stage. Do not make the
|
||||
// exhaustive release list wait for its Bing hero fallback: that used to
|
||||
@@ -3601,8 +3751,20 @@ impl MetadataClient {
|
||||
albums, evidence,
|
||||
))
|
||||
};
|
||||
let q = urlencode(&format!("artist:\"{}\"", artist.name));
|
||||
// Only the search and Apple legs need the NAME. They ride the same
|
||||
// singleflighted profile fetch inside the join instead of queueing the
|
||||
// whole gather behind a serial profile round trip — the id-keyed legs
|
||||
// (curated, popular, and above all the cold supplements chain, which
|
||||
// is the join's long pole) start one Deezer round trip sooner.
|
||||
let search_fut = async {
|
||||
let Some(name) = self
|
||||
.deezer(&profile_url)
|
||||
.await
|
||||
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()))
|
||||
else {
|
||||
return Vec::new(); // no profile, no page — the join's own bail
|
||||
};
|
||||
let q = urlencode(&format!("artist:\"{name}\""));
|
||||
let mut candidates = Vec::new();
|
||||
let mut index = 0;
|
||||
for _ in 0..2 {
|
||||
@@ -3637,20 +3799,48 @@ impl MetadataClient {
|
||||
}
|
||||
candidates
|
||||
};
|
||||
let apple_fut = async {
|
||||
let name = self
|
||||
.deezer(&profile_url)
|
||||
.await
|
||||
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
|
||||
match name {
|
||||
Some(name) => self.apple_artist_candidates(&name).await,
|
||||
None => Arc::new(Vec::new()),
|
||||
}
|
||||
};
|
||||
let popular_url =
|
||||
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
|
||||
let gather_started = Instant::now();
|
||||
// Rides the gather join for the same reason it does in the overview:
|
||||
// it needs only the artist id, and both its hops are disk cached, so
|
||||
// overlapping costs nothing the exhaustive album walk was not paying.
|
||||
let (curated, search_candidates, popular, apple_candidates, supplements) = tokio::join!(
|
||||
// The supplements chain (cold: several MusicBrainz requests behind
|
||||
// the mandatory 1 req/s gate — the longest thing this function does)
|
||||
// is SPAWNED here and collected beside the album-detail wave below,
|
||||
// the only other multi-second leg. Its output is first consumed after
|
||||
// the details land (dates, attribution, popular), so holding the
|
||||
// gather for it bought nothing — audited on Queen, the details wave
|
||||
// cost 5.3s serialized after a gather that was itself pinned to the
|
||||
// supplements chain. Spawned, not merely deferred: an
|
||||
// identity-unavailable bail below must leave it warming the caches
|
||||
// the identity pass reads next, exactly like the overview's budget
|
||||
// miss does.
|
||||
let supplements_task = tokio::spawn({
|
||||
let client = Arc::clone(self);
|
||||
async move { client.artist_supplements(artist_id, true).await }
|
||||
});
|
||||
let (profile, curated, search_candidates, popular, apple_candidates) = tokio::join!(
|
||||
async {
|
||||
let profile = self.deezer(&profile_url).await;
|
||||
stage_trace("page.profile", page_started, "");
|
||||
profile
|
||||
},
|
||||
curated_fut,
|
||||
search_fut,
|
||||
self.deezer(&popular_url),
|
||||
self.apple_artist_candidates(&artist.name),
|
||||
self.artist_supplements(artist_id, true),
|
||||
apple_fut,
|
||||
);
|
||||
stage_trace("page.gather_join", gather_started, "");
|
||||
let mut artist = parse_artist(&profile?)?;
|
||||
self.apply_known_portrait(&mut artist);
|
||||
let (curated, evidence) = curated?;
|
||||
let apple_started = Instant::now();
|
||||
let apple = self
|
||||
@@ -3838,7 +4028,7 @@ impl MetadataClient {
|
||||
}
|
||||
let details_started = Instant::now();
|
||||
let detail_count = need.len();
|
||||
let fetched = stream::iter(need.iter().copied())
|
||||
let details_fut = stream::iter(need.iter().copied())
|
||||
.map(|id| {
|
||||
let url = format!("https://api.deezer.com/album/{id}");
|
||||
async move { self.deezer(&url).await }
|
||||
@@ -3847,12 +4037,26 @@ impl MetadataClient {
|
||||
// request completes. Fixed 25-item waves made every later album
|
||||
// wait for the single slowest response in the preceding wave.
|
||||
.buffer_unordered(25)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
stage_trace(
|
||||
"page.details",
|
||||
details_started,
|
||||
&format!("count={detail_count}"),
|
||||
.collect::<Vec<_>>();
|
||||
// Collected together: the Deezer detail wave and the MusicBrainz
|
||||
// supplements tail are the two remaining multi-second legs, and
|
||||
// neither reads the other. Each leg stamps its own trace, so an audit
|
||||
// still sees which of the two the join was actually waiting on.
|
||||
let (fetched, supplements) = tokio::join!(
|
||||
async {
|
||||
let fetched = details_fut.await;
|
||||
stage_trace(
|
||||
"page.details",
|
||||
details_started,
|
||||
&format!("count={detail_count}"),
|
||||
);
|
||||
fetched
|
||||
},
|
||||
async {
|
||||
let supplements = supplements_task.await.unwrap_or_default();
|
||||
stage_trace("page.supplements_wait", details_started, "");
|
||||
supplements
|
||||
}
|
||||
);
|
||||
let mut details: HashMap<u64, serde_json::Value> = HashMap::new();
|
||||
for d in fetched.into_iter().flatten() {
|
||||
@@ -4412,27 +4616,35 @@ impl MetadataClient {
|
||||
return empty_catalog_artist_identity(artist_id);
|
||||
};
|
||||
stage_trace("identity.linked_mbid", identity_started, "");
|
||||
let ownership_started = Instant::now();
|
||||
let ownership = self
|
||||
.artist_release_ownership(artist_id, artist, &mbid)
|
||||
.await;
|
||||
stage_trace(
|
||||
"identity.ownership",
|
||||
ownership_started,
|
||||
&format!(
|
||||
"owned={} foreign={} fused={}",
|
||||
ownership.owned.len(),
|
||||
ownership.foreign.len(),
|
||||
ownership.cotenancy_proven
|
||||
),
|
||||
);
|
||||
let join_started = Instant::now();
|
||||
// Socials are footer content, not sorting evidence, yet every request
|
||||
// in this join serializes behind MusicBrainz's 1 req/s gate — so the
|
||||
// socials lookup was ~1 s of extra time-to-correctly-sorted on every
|
||||
// cold artist. They now ride [`Self::catalog_artist_socials`], which
|
||||
// the frontend calls only after this merge-critical response lands.
|
||||
let (albums, mixtapes, type_hints) = tokio::join!(
|
||||
// Ownership rides the join too: its MusicBrainz legs share the gate
|
||||
// with the three walks either way (the request count is what the
|
||||
// policy prices), but serialized ahead of them its non-MusicBrainz
|
||||
// leg — the Apple candidate fetch — added a tail the gate never
|
||||
// required.
|
||||
let (ownership, albums, mixtapes, type_hints) = tokio::join!(
|
||||
async {
|
||||
let ownership_started = Instant::now();
|
||||
let ownership = self
|
||||
.artist_release_ownership(artist_id, artist, &mbid)
|
||||
.await;
|
||||
stage_trace(
|
||||
"identity.ownership",
|
||||
ownership_started,
|
||||
&format!(
|
||||
"owned={} foreign={} fused={}",
|
||||
ownership.owned.len(),
|
||||
ownership.foreign.len(),
|
||||
ownership.cotenancy_proven
|
||||
),
|
||||
);
|
||||
ownership
|
||||
},
|
||||
self.musicbrainz_studio_albums(artist, &mbid),
|
||||
self.musicbrainz_mixtapes(artist, &mbid),
|
||||
self.musicbrainz_type_hints(&mbid)
|
||||
@@ -5045,7 +5257,31 @@ impl MetadataClient {
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json"
|
||||
);
|
||||
let body = self.musicbrainz_json(&url).await?;
|
||||
let mut body = self.musicbrainz_json(&url).await;
|
||||
// THE anchor request: dates, attribution, listeners and the identity
|
||||
// supplement all hang off this one lookup, so a single transient
|
||||
// failure here used to orphan the whole visit from MusicBrainz with
|
||||
// no second ask — observed live: a 242ms failure at open left a page
|
||||
// with 0/24 dates and an unranked Popular, while the identical
|
||||
// lookup answered normally two seconds later. Retried on the same
|
||||
// ladder the dates fetch runs — but ONLY when the miss was not a
|
||||
// 4xx: "MusicBrainz has no Deezer link for this artist" is a real
|
||||
// answer, and an artist without one must not pay two gated requests
|
||||
// and 3.6s of backoff on every cold visit to hear it again.
|
||||
if body.is_none() && !self.definitive_miss.lock().await.contains(&url) {
|
||||
for attempt in 0..MB_RETRY_ATTEMPTS {
|
||||
tokio::time::sleep(MB_RETRY_BACKOFF * (attempt + 1)).await;
|
||||
net_trace(&url, &format!("mb-retry {}", attempt + 1));
|
||||
body = self.musicbrainz_json(&url).await;
|
||||
if body.is_some() {
|
||||
break;
|
||||
}
|
||||
if self.definitive_miss.lock().await.contains(&url) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = body?;
|
||||
body.get("relations")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
@@ -5948,9 +6184,17 @@ impl MetadataClient {
|
||||
artist_id: u64,
|
||||
with_recordings: bool,
|
||||
) -> ArtistSupplements {
|
||||
// Traced separately from the joined legs below: this hop is serial —
|
||||
// nothing downstream can start without the MBID — and it was invisible
|
||||
// in the stage audit that found the overview's critical path, because
|
||||
// every leg it gates IS traced and the ~1.5s cold gap between them and
|
||||
// the join total belonged to no stage.
|
||||
let mbid_started = Instant::now();
|
||||
let Some(mbid) = self.deezer_linked_mbid(artist_id).await else {
|
||||
stage_trace("supplements.linked_mbid", mbid_started, "no-mbid");
|
||||
return ArtistSupplements::default();
|
||||
};
|
||||
stage_trace("supplements.linked_mbid", mbid_started, "");
|
||||
let (ranks, (first_released, musicbrainz_unavailable), recordings) = tokio::join!(
|
||||
self.lastfm_listener_ranks(&mbid),
|
||||
self.musicbrainz_first_release_dates(&mbid),
|
||||
@@ -6815,17 +7059,6 @@ impl MetadataClient {
|
||||
catalog
|
||||
}
|
||||
|
||||
async fn apple_catalog_for_deezer(
|
||||
&self,
|
||||
deezer_id: u64,
|
||||
artist: &str,
|
||||
evidence: &[DeezerReleaseEvidence],
|
||||
) -> Arc<AppleArtistCatalog> {
|
||||
let candidates = self.apple_artist_candidates(artist).await;
|
||||
self.apple_catalog_for_deezer_candidates(deezer_id, artist, candidates, evidence)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn apple_catalog_for_deezer_candidates(
|
||||
&self,
|
||||
deezer_id: u64,
|
||||
|
||||
@@ -2520,6 +2520,19 @@ pub async fn catalog_artist_overview(
|
||||
.ok_or_else(|| "artist overview lookup failed".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn catalog_artist_popular(
|
||||
state: State<'_, LibraryState>,
|
||||
artist_id: u64,
|
||||
) -> Result<Vec<metadata::CatalogTrack>, String> {
|
||||
state.touch_catalog_activity();
|
||||
state
|
||||
.metadata
|
||||
.catalog_artist_popular(artist_id)
|
||||
.await
|
||||
.ok_or_else(|| "artist popular lookup failed".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn catalog_related(
|
||||
state: State<'_, LibraryState>,
|
||||
@@ -3957,20 +3970,69 @@ pub async fn open_external(url: String) -> Result<(), String> {
|
||||
// ---- play history ----
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_play(
|
||||
lib: State<'_, LibraryState>,
|
||||
artist: String,
|
||||
title: String,
|
||||
path: Option<String>,
|
||||
catalog_album_id: Option<String>,
|
||||
album_title: Option<String>,
|
||||
album_artist: Option<String>,
|
||||
cover_url: Option<String>,
|
||||
) -> Result<i64, String> {
|
||||
let l = lib.library.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
l.record_play(&artist, &title, now_secs())
|
||||
let cat = library::CatalogPlay {
|
||||
album_id: catalog_album_id.as_deref(),
|
||||
title: album_title.as_deref(),
|
||||
artist: album_artist.as_deref(),
|
||||
cover: cover_url.as_deref(),
|
||||
};
|
||||
l.record_play(&artist, &title, path.as_deref(), cat, now_secs())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Wipe listening history. Destructive and not undoable — `plays` only;
|
||||
/// albums, tracks and playlists are untouched. Returns rows removed.
|
||||
#[tauri::command]
|
||||
pub async fn clear_play_history(lib: State<'_, LibraryState>) -> Result<usize, String> {
|
||||
let l = lib.library.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
l.clear_play_history().map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn play_row_count(lib: State<'_, LibraryState>) -> Result<i64, String> {
|
||||
let l = lib.library.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || l.play_row_count().map_err(|e| e.to_string()))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// The sidebar's recently-played list. `min_secs` keeps skips out of it.
|
||||
#[tauri::command]
|
||||
pub async fn recent_albums(
|
||||
lib: State<'_, LibraryState>,
|
||||
limit: Option<u32>,
|
||||
min_secs: Option<u32>,
|
||||
) -> Result<Vec<library::RecentAlbum>, String> {
|
||||
let l = lib.library.clone();
|
||||
let limit = limit.unwrap_or(20);
|
||||
let min_secs = min_secs.unwrap_or(45);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
l.recent_albums(limit, min_secs).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_play_seconds(
|
||||
lib: State<'_, LibraryState>,
|
||||
|
||||
@@ -73,6 +73,9 @@ pub fn run() {
|
||||
commands::record_play,
|
||||
commands::add_play_seconds,
|
||||
commands::artist_play_stats,
|
||||
commands::recent_albums,
|
||||
commands::clear_play_history,
|
||||
commands::play_row_count,
|
||||
commands::list_playlists,
|
||||
commands::create_playlist,
|
||||
commands::delete_playlist,
|
||||
@@ -93,6 +96,7 @@ pub fn run() {
|
||||
commands::catalog_ocr_experiment,
|
||||
commands::catalog_artist_hero_seed,
|
||||
commands::catalog_artist_overview,
|
||||
commands::catalog_artist_popular,
|
||||
commands::pick_artist_hero,
|
||||
commands::clear_artist_hero,
|
||||
commands::catalog_related,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import Downloads from "./views/Downloads.svelte";
|
||||
import Settings from "./views/Settings.svelte";
|
||||
import Playlist from "./views/Playlist.svelte";
|
||||
import Playlists from "./views/Playlists.svelte";
|
||||
import CatalogSearch from "./views/CatalogSearch.svelte";
|
||||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||
@@ -24,6 +25,7 @@
|
||||
autoConnect,
|
||||
refreshLibrary,
|
||||
refreshPlaylists,
|
||||
refreshRecents,
|
||||
} from "./lib/state.svelte.js";
|
||||
import { floatScroll } from "./lib/scrollbar.js";
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
downloads: Downloads,
|
||||
settings: Settings,
|
||||
playlist: Playlist,
|
||||
playlists: Playlists,
|
||||
catalog: CatalogSearch,
|
||||
catalogArtist: CatalogArtist,
|
||||
catalogAlbum: CatalogAlbum,
|
||||
@@ -56,6 +59,7 @@
|
||||
autoConnect();
|
||||
refreshLibrary();
|
||||
refreshPlaylists();
|
||||
refreshRecents();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
playlist: '<path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
square: '<rect width="16" height="16" x="4" y="4" rx="2"/>',
|
||||
restore: '<rect width="12" height="12" x="4" y="8" rx="2"/><path d="M8 4h10a2 2 0 0 1 2 2v10"/>',
|
||||
|
||||
+646
-174
@@ -1,5 +1,19 @@
|
||||
<script>
|
||||
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar, goToView } from "../lib/state.svelte.js";
|
||||
import {
|
||||
app,
|
||||
ui,
|
||||
network,
|
||||
player,
|
||||
downloads,
|
||||
playlistStore,
|
||||
recentsStore,
|
||||
refreshPlaylists,
|
||||
refreshRecents,
|
||||
toggleSidebar,
|
||||
goToView,
|
||||
openCatalogAlbum,
|
||||
getArt,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
@@ -10,82 +24,242 @@
|
||||
goToView(id, id !== "library" ? { albumId: null } : {});
|
||||
}
|
||||
|
||||
async function newPlaylist() {
|
||||
async function newPlaylist(e) {
|
||||
e.stopPropagation(); // the + rides the Playlists row; don't also navigate
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
}
|
||||
|
||||
function openPlaylist(id) {
|
||||
const name = playlistStore.playlists.find((p) => p.id === id)?.name;
|
||||
goToView("playlist", { playlistId: id, label: name ?? "Playlist" });
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "search", label: "Search", icon: "search" },
|
||||
{ id: "library", label: "Your Library", icon: "library" },
|
||||
{ id: "downloads", label: "Downloads", icon: "download" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
{ id: "playlists", label: "Playlists", icon: "playlist" },
|
||||
];
|
||||
|
||||
// ---- the Soulseek status, moved here out of the title bar ----
|
||||
function statusClick() {
|
||||
if (app.connection === "online") {
|
||||
api.disconnect();
|
||||
app.connection = "offline";
|
||||
} else if (app.connection === "offline") {
|
||||
app.showLogin = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Two letters of the username, so the tile reads as an identity before any
|
||||
// portrait exists. `app.avatarUrl` is the hook the PFP feature will fill;
|
||||
// until then the <img> has no src and the initials show through.
|
||||
const initials = $derived(
|
||||
(app.username || "").replace(/[^a-z0-9]/gi, "").slice(0, 2).toLowerCase() || "··",
|
||||
);
|
||||
|
||||
// ---- recently played ----
|
||||
// Day buckets are computed from local calendar dates, not from elapsed
|
||||
// seconds: something played at 00:30 is "Today", not "8 hours ago".
|
||||
function dayKey(unixSecs) {
|
||||
const d = new Date(unixSecs * 1000);
|
||||
const now = new Date();
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const t = d.getTime();
|
||||
if (t >= midnight) return "Today";
|
||||
if (t >= midnight - 86400000) return "Yesterday";
|
||||
return "Earlier";
|
||||
}
|
||||
|
||||
// One flat list with day markers folded in, so the markup is a single pass
|
||||
// and there is no nesting to keep aligned.
|
||||
const recentRows = $derived.by(() => {
|
||||
const rows = [];
|
||||
let day = null;
|
||||
for (const a of recentsStore.albums) {
|
||||
const k = dayKey(a.last_played_at);
|
||||
if (k !== day) {
|
||||
day = k;
|
||||
rows.push({ kind: "day", label: k, key: `d:${k}` });
|
||||
}
|
||||
rows.push({ kind: "album", album: a, key: `a:${a.album_id}` });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Covers resolve asynchronously through the shared art cache; keep the
|
||||
// resolved data URLs keyed by path so a re-render never re-requests.
|
||||
let artUrls = $state({});
|
||||
$effect(() => {
|
||||
for (const a of recentsStore.albums) {
|
||||
const p = a.art_path;
|
||||
if (!p || artUrls[p] !== undefined) continue;
|
||||
artUrls[p] = null;
|
||||
getArt(p).then((u) => (artUrls[p] = u)).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// A catalog row's cover is already a URL and needs no read; a library row's
|
||||
// is a file path that has to go through the art cache.
|
||||
function coverOf(a) {
|
||||
return a.art_url ?? (a.art_path ? artUrls[a.art_path] : null);
|
||||
}
|
||||
|
||||
// The same set of states the title bar's ring counts as "in flight".
|
||||
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
|
||||
const downloadCount = $derived(
|
||||
Object.values(downloads.items).filter((d) => DL_ACTIVE.has(d.state)).length,
|
||||
);
|
||||
|
||||
// Which record is playing, so its row can carry the equaliser. The queue
|
||||
// item carries the cover path the play was recorded against — matching on
|
||||
// artist/album NAME would be exactly the identity mistake the app forbids,
|
||||
// so a row lights up only when its art path is the one playing.
|
||||
const nowItem = $derived(player.queue?.[player.queueIndex] ?? null);
|
||||
const playingArt = $derived(nowItem?.art ?? null);
|
||||
const playingCatalogId = $derived(
|
||||
player.catalogRef?.albumId != null ? String(player.catalogRef.albumId) : null,
|
||||
);
|
||||
|
||||
// Anchored on ids, never on a name.
|
||||
function isPlaying(a) {
|
||||
if (a.kind === "catalog") {
|
||||
return !!a.catalog_album_id && a.catalog_album_id === playingCatalogId;
|
||||
}
|
||||
return !!a.art_path && a.art_path === playingArt;
|
||||
}
|
||||
|
||||
function openRecent(a) {
|
||||
if (a.kind === "catalog" && a.catalog_album_id) {
|
||||
// The catalog page, not the old library album view — streamed
|
||||
// records live in the catalog. The seed paints title/artist/cover
|
||||
// immediately instead of flashing a skeleton.
|
||||
openCatalogAlbum(a.catalog_album_id, {
|
||||
seed: {
|
||||
id: a.catalog_album_id,
|
||||
title: a.title,
|
||||
artist: a.artist,
|
||||
cover: a.art_url ?? null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
goToView("library", { albumId: a.album_id });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!recentsStore.loaded) refreshRecents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
||||
<div class="sb-top">
|
||||
<!-- One element, outside the list, so it never fades with the content. -->
|
||||
<div class="gbar">
|
||||
<button
|
||||
class="collapse-btn"
|
||||
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
|
||||
onclick={toggleSidebar}
|
||||
class="status"
|
||||
data-state={app.connection}
|
||||
onclick={statusClick}
|
||||
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
|
||||
>
|
||||
<Icon name="chevron-left" size={20} />
|
||||
<span class="avcol">
|
||||
<span class="av">
|
||||
<span class="avi">
|
||||
{#if app.avatarUrl}
|
||||
<img class="pfp" src={app.avatarUrl} alt="" draggable="false" />
|
||||
{/if}
|
||||
{initials}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tx">
|
||||
<span class="nm">
|
||||
{#if app.connection === "online"}{app.username}
|
||||
{:else if app.connection === "connecting"}Signing in
|
||||
{:else}Not connected{/if}
|
||||
</span>
|
||||
<span class="sub">
|
||||
{#if app.connection === "online"}{network.shareFiles} files shared
|
||||
{:else if app.connection === "connecting"}one moment
|
||||
{:else}click to sign in{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- right:18 puts it dead centre at 64px — (64-28)/2 = 18 — so the
|
||||
collapsed position falls out of the geometry, not a second rule. -->
|
||||
<button
|
||||
class="gicon"
|
||||
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
|
||||
onclick={toggleSidebar}
|
||||
>
|
||||
<!-- 16, not 20: measured against NowPlaying's .np-icon, whose button is
|
||||
the same 28x28 radius-6 box but carries a 16px glyph. -->
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
<!-- ONE list. Not two views cross-fading: two views means two copies of
|
||||
every icon and cover painting at once through the whole crossfade, and
|
||||
any difference between the copies reads as doubling. Here nothing fades
|
||||
but text, and no glyph can move — every x comes from padding, which
|
||||
does not depend on the panel's width. -->
|
||||
<div class="view">
|
||||
{#each items as item (item.id)}
|
||||
<li>
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={ui.view === item.id}
|
||||
title={ui.sidebarCollapsed ? item.label : null}
|
||||
onclick={() => navigate(item.id)}
|
||||
>
|
||||
<span class="icon"><Icon name={item.icon} size={22} /></span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="playlists">
|
||||
<div class="pl-head">
|
||||
<span class="label">Playlists</span>
|
||||
<button class="pl-new" title="New playlist" onclick={newPlaylist}>
|
||||
<Icon name="plus" size={16} />
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={ui.view === item.id}
|
||||
class:has-count={item.id === "downloads" && downloadCount > 0}
|
||||
title={ui.sidebarCollapsed ? item.label : null}
|
||||
onclick={() => navigate(item.id)}
|
||||
>
|
||||
<span class="ic"><Icon name={item.icon} size={22} /></span>
|
||||
<span class="lbl">{item.label}</span>
|
||||
{#if item.id === "downloads" && downloadCount > 0}
|
||||
<span class="bdg">{downloadCount > 99 ? "99+" : downloadCount}</span>
|
||||
{/if}
|
||||
{#if item.id === "playlists"}
|
||||
<span
|
||||
class="rowplus"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="New playlist"
|
||||
onclick={newPlaylist}
|
||||
onkeydown={(e) => e.key === "Enter" && newPlaylist(e)}
|
||||
>
|
||||
<Icon name="plus" size={13} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="pl-list">
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<button
|
||||
class="pl-item"
|
||||
class:active={ui.view === "playlist" && ui.playlistId === pl.id}
|
||||
onclick={() => openPlaylist(pl.id)}
|
||||
>
|
||||
<span class="pl-name">{pl.name}</span>
|
||||
<span class="pl-count">{pl.track_count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="footer">
|
||||
{#if app.connection === "online" && network.shareFiles > 0}
|
||||
<div class="share-info" title="Files you share with the network">
|
||||
sharing {network.shareFiles} files
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rule"></div>
|
||||
|
||||
{#each recentRows as row (row.key)}
|
||||
{#if row.kind === "day"}
|
||||
<div class="day"><span>{row.label}</span></div>
|
||||
{:else}
|
||||
<button
|
||||
class="rec"
|
||||
class:on={isPlaying(row.album)}
|
||||
title="{row.album.title} — {row.album.artist}"
|
||||
onclick={() => openRecent(row.album)}
|
||||
>
|
||||
{#if coverOf(row.album)}
|
||||
<img src={coverOf(row.album)} alt="" draggable="false" />
|
||||
{:else}
|
||||
<span class="art-placeholder"><Icon name="music" size={15} /></span>
|
||||
{/if}
|
||||
<span class="tx">
|
||||
<span class="n">
|
||||
{#if isPlaying(row.album)}
|
||||
<span class="eq"><i></i><i></i><i></i></span>
|
||||
{/if}
|
||||
{row.album.title}
|
||||
</span>
|
||||
<span class="s">{row.album.artist}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -94,184 +268,482 @@
|
||||
grid-area: sidebar;
|
||||
background: var(--bg-base);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sb-top {
|
||||
/* ── the glass bar ── */
|
||||
.gbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 4px 6px;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
z-index: 5;
|
||||
background: rgba(20, 20, 24, 0.28);
|
||||
backdrop-filter: blur(12px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.7);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
.gicon {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s;
|
||||
z-index: 6;
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 120ms,
|
||||
color 120ms;
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
color: var(--text-primary);
|
||||
.gicon:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Label collapse: fades and zeroes width so icons stay put. */
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 160px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease, max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ---- collapsed icon-rail state ---- */
|
||||
.sidebar.collapsed .sb-top {
|
||||
justify-content: center;
|
||||
}
|
||||
.sidebar.collapsed .collapse-btn {
|
||||
.sidebar.collapsed .gicon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.sidebar.collapsed .label {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
}
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.sidebar.collapsed .pl-head {
|
||||
justify-content: center;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.sidebar.collapsed .pl-list {
|
||||
display: none;
|
||||
}
|
||||
.sidebar.collapsed .footer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.playlists {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pl-head {
|
||||
/* ── the Soulseek status ──
|
||||
Not painted with --accent: the accent is crossfaded from the playing
|
||||
album's art, so "online" would change colour with the music and currently
|
||||
renders red. Green means connected, whatever is playing. */
|
||||
.status {
|
||||
--av: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
max-width: 175px;
|
||||
flex: 1;
|
||||
/* 11 (bar padding) + 1 = 12, the image column's left edge */
|
||||
padding: 2px 10px 2px 1px;
|
||||
border-radius: 9px;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 0.14s,
|
||||
opacity 0.26s ease;
|
||||
}
|
||||
.status:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.sidebar.collapsed .status {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pl-new {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
/* A 40px slot with its left edge at 12 — the same construction as a nav
|
||||
icon — so the avatar is centred on x=32 at ANY size, in line with every
|
||||
icon and cover below it. */
|
||||
.avcol {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.pl-new:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-press);
|
||||
.av {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: var(--av);
|
||||
height: var(--av);
|
||||
}
|
||||
|
||||
.pl-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
/* Album art is 40px at radius 4 — a ratio of exactly .1. Deriving the
|
||||
radius from the size holds that ratio at any size, so the avatar reads as
|
||||
the same object as a cover and a real photo drops straight in. */
|
||||
.avi {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: calc(var(--av) * 0.1);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(150deg, #3c3c45, #24242a);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
||||
font-size: calc(var(--av) * 0.4);
|
||||
font-weight: 700;
|
||||
color: #d8d8e0;
|
||||
}
|
||||
|
||||
.pl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
.pfp {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
/* The state dot hangs off the corner, so it rides .av (unclipped) and never
|
||||
.avi (which clips, for the photo). */
|
||||
.av::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #7c7c85;
|
||||
box-shadow: 0 0 0 2.5px #131316;
|
||||
}
|
||||
.status[data-state="online"] .av::after {
|
||||
background: #3ecf7e;
|
||||
}
|
||||
.status[data-state="connecting"] .av::after {
|
||||
background: #e8a33d;
|
||||
animation: breathe 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-item:hover {
|
||||
color: var(--text-primary);
|
||||
.status .tx {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pl-item.active {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pl-name {
|
||||
.status .nm {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pl-count {
|
||||
font-size: 11px;
|
||||
.status .sub {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status[data-state="online"] .sub {
|
||||
color: #3ecf7e;
|
||||
}
|
||||
.status[data-state="connecting"] .sub {
|
||||
color: #e8a33d;
|
||||
}
|
||||
|
||||
/* ── the list ──
|
||||
The 46px inset is the bar's height; the extra 8 stops the first row's
|
||||
highlight sitting flush against it. */
|
||||
.view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 54px 6px 12px 6px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.view::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* EVERY GLYPH SHARES ONE CENTRE, x=32, AND NONE OF THEM MOVE.
|
||||
row starts at 6 (view pad) + 6 (row pad) -> content at 12
|
||||
cover 40px at 12 -> 12..52, centre 32
|
||||
icon 22px centred in a 40px slot at 12 -> 21..43, centre 32
|
||||
At 64px the panel's centre is also 32, so the rail centring falls out of
|
||||
the same arithmetic — it is not a second rule. These x values come from
|
||||
padding, which does not depend on the panel's width, so they are
|
||||
invariant through the whole collapse. Verified at 232/200/168/136/104/80/64. */
|
||||
.nav-item,
|
||||
.rec {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
height: 42px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
transition: color 0.15s;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.nav-item .ic {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
/* Fixed width so the label never reflows or re-ellipsises as the panel
|
||||
narrows — it fades, and what is left is clipped by the panel. 118 lands
|
||||
the count and the + a clear 12-16px inside the highlight. */
|
||||
.lbl {
|
||||
width: 118px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-primary);
|
||||
/* --bg-highlight, matching NowPlaying's .row:hover exactly. The frosted
|
||||
version existed to let the album wash run underneath it; with the wash
|
||||
gone it has nothing to reveal, and an opaque highlight is what the right
|
||||
panel already uses. Active is distinguished by text colour, which is how
|
||||
this sidebar has always done it. */
|
||||
.nav-item:hover,
|
||||
.rec:hover,
|
||||
.nav-item.active,
|
||||
.rec.on {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.nav-item.active,
|
||||
.rec.on {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
/* margin-left:0, never auto — auto depends on the row's width, so the count
|
||||
and the + would slide across the panel while fading out. */
|
||||
.bdg,
|
||||
.rowplus {
|
||||
margin-left: 0;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
.bdg {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(8px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(1.4);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
/* Collapsed there is no room for a numeral, and an 18px one on the glyph
|
||||
covers it. An 8px dot at (44..52) clears the glyph, which ends at 43. */
|
||||
.nav-item.has-count .ic::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
.sidebar.collapsed .nav-item.has-count .ic::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 8px 4px;
|
||||
/* Draws nothing until you point at the row — the right weight for a
|
||||
secondary action, and it stops a grey dot floating on the wash. */
|
||||
.rowplus {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
color: transparent;
|
||||
transition:
|
||||
opacity 0.26s ease,
|
||||
background 0.13s,
|
||||
color 0.13s,
|
||||
transform 0.13s;
|
||||
}
|
||||
.nav-item:hover .rowplus {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rowplus:hover {
|
||||
background: rgba(255, 255, 255, 0.17);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.share-info {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 12px 6px;
|
||||
}
|
||||
|
||||
/* No "Recently played" eyebrow — the rule runs straight into the history.
|
||||
Collapsed, the day word leaves a hairline so the list keeps its
|
||||
structure instead of becoming an undifferentiated column. */
|
||||
.day {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0 6px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.day span {
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
.day::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
opacity: 0;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
.sidebar.collapsed .day::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.rec img,
|
||||
.art-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
transition: transform var(--art-motion);
|
||||
}
|
||||
.art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.rec:hover img {
|
||||
transform: scale(var(--art-grow));
|
||||
}
|
||||
.rec .tx {
|
||||
width: 157px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
.rec .n {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.rec:hover .n {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.rec.on .n {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.rec .s {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Only text fades. Nothing here changes a position. */
|
||||
.sidebar.collapsed .lbl,
|
||||
.sidebar.collapsed .rec .tx,
|
||||
.sidebar.collapsed .day span,
|
||||
.sidebar.collapsed .nav-item > .bdg,
|
||||
.sidebar.collapsed .rowplus {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.eq i {
|
||||
width: 2.5px;
|
||||
background: var(--accent-hover);
|
||||
border-radius: 1px;
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.eq i:nth-child(2) {
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
.eq i:nth-child(3) {
|
||||
animation-delay: -0.55s;
|
||||
}
|
||||
@keyframes eq {
|
||||
0%,
|
||||
100% {
|
||||
height: 35%;
|
||||
}
|
||||
50% {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gicon,
|
||||
.lbl,
|
||||
.rec .tx,
|
||||
.day span,
|
||||
.bdg,
|
||||
.rowplus {
|
||||
transition: none;
|
||||
}
|
||||
.eq i,
|
||||
.status[data-state="connecting"] .av::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -189,24 +189,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// onResized fires on EVERY frame of a window drag, and answering each one
|
||||
// with isMaximized() is an IPC round trip to the Rust side per frame — on
|
||||
// the main thread, while the compositor is already busy re-rasterizing every
|
||||
// backdrop-filter in the app at the new size. Maximized state cannot change
|
||||
// part-way through a drag anyway: it only changes at the end of one, or on a
|
||||
// discrete button press. So answer once, after the box settles.
|
||||
let resizeSettle = null;
|
||||
function syncMaximizedSoon() {
|
||||
clearTimeout(resizeSettle);
|
||||
resizeSettle = setTimeout(syncMaximized, 150);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
let unlisten = null;
|
||||
syncMaximized();
|
||||
api.win
|
||||
.onResized(syncMaximized)
|
||||
.onResized(syncMaximizedSoon)
|
||||
.then((f) => (unlisten = f))
|
||||
.catch(() => {});
|
||||
return () => unlisten?.();
|
||||
return () => {
|
||||
clearTimeout(resizeSettle);
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
function statusClick() {
|
||||
if (app.connection === "online") {
|
||||
api.disconnect();
|
||||
app.connection = "offline";
|
||||
} else if (app.connection === "offline") {
|
||||
app.showLogin = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
@@ -214,13 +221,8 @@
|
||||
<img class="logo" src="/logo.png" alt="" draggable="false" />
|
||||
<span class="name">SoulStream</span>
|
||||
</div>
|
||||
<button
|
||||
class="status {app.connection}"
|
||||
onclick={statusClick}
|
||||
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
|
||||
>
|
||||
● {app.connection === "online" ? app.username : app.connection}
|
||||
</button>
|
||||
<!-- The Soulseek status lives in the sidebar's glass bar now: it is an
|
||||
account presence, and it belongs next to the library it feeds. -->
|
||||
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
|
||||
<Icon name="search" size={13} />
|
||||
<input
|
||||
@@ -336,8 +338,7 @@
|
||||
/* The visible strip above the panels is the 30px bar row plus the 8px
|
||||
grid gap; nudge the left cluster down so it centers optically in that
|
||||
full strip. The window controls stay flush with the top edge. */
|
||||
.brand,
|
||||
.status {
|
||||
.brand {
|
||||
position: relative;
|
||||
top: 4px;
|
||||
}
|
||||
@@ -361,26 +362,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status.online {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.status.connecting {
|
||||
color: #e8a33d;
|
||||
}
|
||||
|
||||
/* Centered on the window regardless of the left cluster's width; the
|
||||
same +4px optical nudge as the brand. Dragging still works on the
|
||||
uncovered bar either side. */
|
||||
|
||||
+24
-1
@@ -67,7 +67,25 @@ export const playerControl = (action, value, rate) =>
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
export const recordPlay = (artist, title) => invoke("record_play", { artist, title });
|
||||
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without
|
||||
// it the row still records, but with no album — so it cannot appear in recents.
|
||||
// `cat` carries catalog identity for a STREAMED play, which never touches the
|
||||
// local library: { albumId, albumTitle, albumArtist, coverUrl }.
|
||||
export const recordPlay = (artist, title, path, cat = {}) =>
|
||||
invoke("record_play", {
|
||||
artist,
|
||||
title,
|
||||
path: path ?? null,
|
||||
catalogAlbumId: cat.albumId != null ? String(cat.albumId) : null,
|
||||
albumTitle: cat.albumTitle ?? null,
|
||||
albumArtist: cat.albumArtist ?? null,
|
||||
coverUrl: cat.coverUrl ?? null,
|
||||
});
|
||||
// Destructive, not undoable: wipes `plays` only. Returns rows removed.
|
||||
export const clearPlayHistory = () => invoke("clear_play_history");
|
||||
export const playRowCount = () => invoke("play_row_count");
|
||||
export const recentAlbums = (limit, minSecs) =>
|
||||
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
|
||||
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
|
||||
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
|
||||
// The WebView blocks target="_blank"; external links open via the backend.
|
||||
@@ -110,6 +128,11 @@ export const catalogArtistHeroSeed = (artistId) =>
|
||||
invoke("catalog_artist_hero_seed", { artistId });
|
||||
export const catalogArtistOverview = (artistId) =>
|
||||
checkedArtistPayload("catalog_artist_overview", artistId);
|
||||
// Popular's fast lane: the Last.fm-ordered rows with listener counts, waiting
|
||||
// on nothing but the linked MBID and one Last.fm call. Patched over the
|
||||
// overview's budgeted (cold: unranked) list; the full page still lands last.
|
||||
export const catalogArtistPopular = (artistId) =>
|
||||
invoke("catalog_artist_popular", { artistId });
|
||||
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
|
||||
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
|
||||
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
|
||||
|
||||
+63
-1
@@ -105,6 +105,27 @@ export async function refreshPlaylists() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- recently played (the sidebar's list) ----
|
||||
// A play row is opened after ~1s of audio, so without a floor this list is a
|
||||
// log of skips. 45s is the floor the live table was measured against.
|
||||
export const RECENTS_FLOOR_SECS = 45;
|
||||
const RECENTS_LIMIT = 20;
|
||||
|
||||
export const recentsStore = $state({
|
||||
albums: [], // [{ album_id, artist, title, art_path, last_played_at }]
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
export async function refreshRecents() {
|
||||
try {
|
||||
recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS);
|
||||
} catch {
|
||||
// backend not available
|
||||
} finally {
|
||||
recentsStore.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
function isPending(value) {
|
||||
@@ -2071,6 +2092,26 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Popular's fast lane. The overview paints the section immediately (its
|
||||
// supplements are budgeted), so a cold page's rows start in Deezer's
|
||||
// order with no listener counts. The real list — Last.fm order and
|
||||
// counts — needs none of the dates/recordings work the full page also
|
||||
// waits for, so it is asked for on its own and patched in the moment it
|
||||
// lands (~2s cold, instant warm). Last writer wins against the overview:
|
||||
// warm, the two lists are computed from identical inputs and cannot
|
||||
// differ. The full page stays authoritative once it arrives, and a
|
||||
// replayed page already shows the last visit's FINAL list — patching a
|
||||
// pool-shaped list over either would be a downgrade.
|
||||
void api.catalogArtistPopular(artistId)
|
||||
.then((tracks) => {
|
||||
if (!active() || !tracks?.length) return;
|
||||
const current = catalog.artistPage;
|
||||
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
|
||||
if (current.art_preview === false || current.replayed) return;
|
||||
catalog.artistPage = { ...current, popular_tracks: tracks };
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
try {
|
||||
// Overview, identity art, and the first hero all remain immediate.
|
||||
// Give rapid back-to-back navigation a brief chance to settle before
|
||||
@@ -4116,7 +4157,12 @@ function listenTick(positionSecs) {
|
||||
if (!title || !artist || artist.includes("…")) return;
|
||||
const key = `${artist}\u0000${title}`;
|
||||
if (listenSession?.key !== key) {
|
||||
// The outgoing track's final seconds go in here — which is the moment
|
||||
// its album can cross the floor. Re-read once that write has landed;
|
||||
// waiting only on the fixed timer below meant a track that crossed 45s
|
||||
// late never showed up until something else happened to refresh.
|
||||
listenFlush();
|
||||
setTimeout(refreshRecents, 1500);
|
||||
listenSession = { key, artist, title, pending: 0, playIdP: null };
|
||||
listenLastPos = null;
|
||||
}
|
||||
@@ -4125,8 +4171,24 @@ function listenTick(positionSecs) {
|
||||
if (delta <= 0 || delta > 3) return;
|
||||
listenSession.pending += delta;
|
||||
if (!listenSession.playIdP && listenSession.pending >= 1) {
|
||||
listenSession.playIdP = api.recordPlay(artist, title);
|
||||
// Two possible anchors, both exact ids, never a name. A local track is
|
||||
// tied to its library album through tracks.path; a STREAMED one — which
|
||||
// is most listening here — through the catalog album id, carrying its
|
||||
// own title/artist/cover because there is no local row to read them
|
||||
// back from.
|
||||
const q = player.queue?.[player.queueIndex] ?? null;
|
||||
listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, {
|
||||
albumId: player.catalogRef?.albumId ?? null,
|
||||
albumTitle: q?.album ?? null,
|
||||
albumArtist: q?.artist ?? null,
|
||||
coverUrl: q?.artUrl ?? null,
|
||||
});
|
||||
listenSession.playIdP.catch(() => {});
|
||||
// The row is opened at ~1s but only earns its place in recents once it
|
||||
// passes the floor, so re-read after the track has had time to count.
|
||||
listenSession.playIdP
|
||||
.then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
// The sidebar's Playlists row needs a destination. Playlists used to be
|
||||
// listed in the sidebar itself; with the sidebar showing listening history
|
||||
// instead, this is where they live.
|
||||
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
|
||||
async function newPlaylist() {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Playlists</h1>
|
||||
<button class="new" onclick={newPlaylist}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
<p class="empty">No playlists yet.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<li>
|
||||
<button class="row" onclick={() => open(pl)}>
|
||||
<span class="ic"><Icon name="playlist" size={18} /></span>
|
||||
<span class="name">{pl.name}</span>
|
||||
<span class="count">
|
||||
{pl.track_count}
|
||||
{pl.track_count === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-highlight);
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s;
|
||||
}
|
||||
.new:hover {
|
||||
background: var(--bg-press);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.14s;
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user