diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 636d2d4..78be81a 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -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, + pub catalog_album_id: Option, + pub artist: String, + pub title: String, + /// Local cover, a file path (library rows). + pub art_path: Option, + /// Remote cover URL (catalog rows). + pub art_url: Option, + /// 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, } @@ -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 { + /// `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 { let conn = self.conn.lock().unwrap(); + let album_id: Option = 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 { + 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 { + let conn = self.conn.lock().unwrap(); + Ok(conn.execute("DELETE FROM plays", [])?) + } + + pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result> { + 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>(3)?.unwrap_or_default(), + title: r.get::<_, Option>(4)?.unwrap_or_default(), + art_path: r.get(5)?, + art_url: r.get(6)?, + last_played_at: r.get(7)?, + }) + })?; + rows.collect::>>().map_err(Into::into) + } + pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( diff --git a/crates/metadata/examples/cold_audit.rs b/crates/metadata/examples/cold_audit.rs new file mode 100644 index 0000000..c12cfe5 --- /dev/null +++ b/crates/metadata/examples/cold_audit.rs @@ -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 = 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 = 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() +} diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index b42f7e5..82898e9 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -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>, + /// 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>, /// 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 { + pub async fn catalog_artist_overview( + self: &Arc, + artist_id: u64, + ) -> Option { 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, + artist_id: u64, + ) -> Option> { + 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 = 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, artist_id: u64) -> Option { 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::>() - .await; - stage_trace( - "page.details", - details_started, - &format!("count={detail_count}"), + .collect::>(); + // 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 = 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 { - 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, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7de60d5..1776e59 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, 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, + catalog_album_id: Option, + album_title: Option, + album_artist: Option, + cover_url: Option, ) -> Result { 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 { + 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 { + 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, + min_secs: Option, +) -> Result, 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>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ca8aa5..d2f63a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index eac10f5..ab4de62 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(); diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 42d82e1..90f6dfb 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -13,6 +13,7 @@ download: '', settings: '', plus: '', + playlist: '', minus: '', square: '', restore: '', diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index 5e6b13b..312bead 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -1,5 +1,19 @@ @@ -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; + } + } diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte index 4e8544f..756248e 100644 --- a/src/components/TitleBar.svelte +++ b/src/components/TitleBar.svelte @@ -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; - } - }
@@ -214,13 +221,8 @@ SoulStream - +