diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 7713b73..5aed63a 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -6220,20 +6220,9 @@ impl MetadataClient { }) .collect(); - let mut by_key: HashMap = HashMap::new(); - let mut ranked: Vec = Vec::new(); - for (key, title, stats) in entries { - // Last.fm's list is already listeners-descending, so an earlier - // entry wins a key collision (e.g. a remaster listed twice). - if by_key.insert(key.clone(), stats).is_some() { - continue; - } - if ranked.len() < LASTFM_RANKED_KEEP { - ranked.push(LastfmTrack { key, title, stats }); - } - } - stage_trace("lastfm.ranks", started, &format!("{} tracks", by_key.len())); - (!by_key.is_empty()).then_some(LastfmRanks { by_key, ranked }) + let ranks = fold_lastfm_duplicates(entries); + stage_trace("lastfm.ranks", started, &format!("{} tracks", ranks.by_key.len())); + (!ranks.by_key.is_empty()).then_some(ranks) } /// The `last.fm` URL MusicBrainz declares for this artist, if any. Shares @@ -7646,6 +7635,39 @@ fn title_identity_stripping(title: &str, is_marker: fn(&str) -> bool) -> String normalize(&work) } +/// Fold Last.fm's top-tracks list, which arrives listeners-descending, into +/// one entry per song. +/// +/// The FIRST entry for a title is the canonical song; every later one is a +/// re-listing of it — an "(Album Version)", a festival recording, a remaster — +/// carrying that pressing's own much smaller count. Keeping the first is the +/// whole point, and it has to be done with a vacancy check: `HashMap::insert` +/// returns the previous value but STILL overwrites, so a guard written as +/// `if map.insert(..).is_some() { continue }` lets the duplicate win the map +/// while correctly skipping the ranked list. +/// +/// That was the bug behind the album tracklist's counts. Last.fm lists Buddy +/// Holly at rank 1 with 2,351,642 listeners and again at rank 679 as +/// "(Album Version)" with 1,919 — and 1,919 is the number the album page +/// printed. Every song popular enough to be re-listed showed its most obscure +/// pressing; only songs Last.fm happens to list exactly once (Weezer's "Tired +/// of Sex", "Across the Sea") were ever right, which is why the column looked +/// plausible in places and absurd in others. +fn fold_lastfm_duplicates(entries: Vec<(String, String, TrackStats)>) -> LastfmRanks { + let mut by_key: HashMap = HashMap::new(); + let mut ranked: Vec = Vec::new(); + for (key, title, stats) in entries { + let std::collections::hash_map::Entry::Vacant(slot) = by_key.entry(key.clone()) else { + continue; + }; + slot.insert(stats); + if ranked.len() < LASTFM_RANKED_KEEP { + ranked.push(LastfmTrack { key, title, stats }); + } + } + LastfmRanks { by_key, ranked } +} + fn track_title_identity(title: &str) -> String { let mut work = title.trim().to_lowercase(); // A featured-artist clause is Deezer packaging, never part of the scrobble. @@ -12178,6 +12200,72 @@ fn urlencode(s: &str) -> String { #[cfg(test)] mod tests { + + /// Last.fm returns a popular song many times over — once as itself and + /// again for every "(Album Version)", live cut and remaster — listeners + /// descending. The canonical first entry must win. + /// + /// Real figures, `artist.gettoptracks` for Weezer: Buddy Holly at rank 1 + /// with 2,351,642 listeners, and again at rank 679 as "(Album Version)" + /// with 1,919. The album page printed 1,919 until this was fixed — the + /// map was being overwritten by every later duplicate. + #[test] + fn a_songs_canonical_entry_beats_its_re_listings() { + let stats = |listeners: u64, plays: u64| TrackStats { plays, listeners }; + let entries = vec![ + ( + track_title_identity("Buddy Holly"), + "Buddy Holly".to_string(), + stats(2_351_642, 24_083_192), + ), + ( + track_title_identity("El Scorcho"), + "El Scorcho".to_string(), + stats(654_071, 5_368_072), + ), + // …hundreds of ranks later, the same songs re-listed. + ( + track_title_identity("Buddy Holly (Album Version)"), + "Buddy Holly (Album Version)".to_string(), + stats(1_919, 7_043), + ), + ( + track_title_identity("El Scorcho (Album Version)"), + "El Scorcho (Album Version)".to_string(), + stats(636, 2_051), + ), + ]; + let folded = fold_lastfm_duplicates(entries); + + let buddy = folded.by_key[&track_title_identity("Buddy Holly")]; + assert_eq!(buddy.listeners, 2_351_642, "the re-listing won the map"); + assert_eq!(buddy.plays, 24_083_192); + let scorcho = folded.by_key[&track_title_identity("El Scorcho")]; + assert_eq!(scorcho.listeners, 654_071); + + // And a duplicate never takes a second seat in the ranked list, which + // is what Popular renders. + assert_eq!(folded.ranked.len(), 2); + assert_eq!(folded.ranked[0].title, "Buddy Holly"); + assert_eq!(folded.ranked[1].title, "El Scorcho"); + } + + /// A song Last.fm lists exactly once is unaffected — these were the only + /// rows that were ever correct. + #[test] + fn a_song_listed_once_is_unchanged() { + let entries = vec![( + track_title_identity("Tired of Sex"), + "Tired of Sex".to_string(), + TrackStats { plays: 4_511_406, listeners: 606_324 }, + )]; + let folded = fold_lastfm_duplicates(entries); + assert_eq!( + folded.by_key[&track_title_identity("Tired of Sex")].listeners, + 606_324 + ); + } + use super::*; use serde_json::json; diff --git a/src/components/ArtworkViewer.svelte b/src/components/ArtworkViewer.svelte index 8d25b24..d6ddbcf 100644 --- a/src/components/ArtworkViewer.svelte +++ b/src/components/ArtworkViewer.svelte @@ -1,14 +1,18 @@ -{#if ui.fullscreenArt && currentItem} +{#if art}
(ui.fullscreenArt = false)} + onclick={close} onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false; + if (e.key === "Enter" || e.key === " ") close(); }} > {#if ambientUrl} @@ -120,14 +114,14 @@ title="Close (Esc)" onclick={(e) => { e.stopPropagation(); - ui.fullscreenArt = false; + close(); }} > {#if artUrl} - Album artwork + {art.title {:else}
{/if} diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte new file mode 100644 index 0000000..f63b760 --- /dev/null +++ b/src/components/GlassBar.svelte @@ -0,0 +1,268 @@ + + + +
+
+ + +
+ {#if crumbs.length} + + + {/if} + {@render children?.()} + {#if resolver.active} + ● {resolver.label} — finding the best source… + {:else if resolver.error} + {resolver.error} + {/if} +
+ + diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 2f058fd..42d82e1 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -34,6 +34,7 @@ expand: '', shrink: '', "chevron-left": '', + "chevron-right": '', "chevron-down": '', // Lucide layers-2: its ink fills the viewBox symmetrically, so the // glyph centers correctly inside small pills (the 3-layer variant diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 3575a4d..ee8c6e7 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -24,6 +24,22 @@ import { floatScroll } from "../lib/scrollbar.js"; const currentItem = $derived(player.queue[player.queueIndex] ?? null); + + /** Hand the playing cover to the shared artwork lightbox. It takes an + * explicit descriptor now rather than reaching into the queue itself, so + * the same viewer serves an album's artwork anywhere else in the app. */ + function openArtwork() { + const item = currentItem; + if (!item) return; + const src = item.art ? item.art : item.artUrl ? playerArtSource(item) : null; + if (!src) return; + ui.artwork = { + src, + local: Boolean(item.art), + title: item.title ?? "", + subtitle: item.sub ?? "", + }; + } // Gate stream-only sections (quality, source, Save) on the displayed // item's own stream field — the source of truth for what's on screen. // Library tracks never carry one, so they never show "Save to Library" @@ -231,17 +247,15 @@
{#if artUrl} -
+ -
+ + {:else}
{/if} @@ -526,7 +540,74 @@ transform: scale(1.02); pointer-events: none; } - .cover-wrap { position: relative; z-index: 1; } + /* The whole cover is the control now, with the artwork-hover treatment the + album page and the artist rail use (--art-* in global.css) — one gesture + for "open this artwork" wherever it appears, instead of a corner disc + here and a lift everywhere else. */ + .cover-wrap { + position: relative; + z-index: 1; + display: block; + width: 100%; + padding: 0; + border-radius: 12px; + cursor: pointer; + transition: transform var(--art-motion); + } + .cover-wrap:hover, + .cover-wrap:focus-visible { + transform: scale(var(--art-grow)); + } + .cover-wrap::after { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + border-radius: 12px; + background: var(--art-wash); + opacity: 0; + transition: opacity var(--art-motion); + pointer-events: none; + } + .cover-wrap:hover::after, + .cover-wrap:focus-visible::after { + opacity: 1; + } + .mark { + position: absolute; + inset: 0; + margin: auto; + z-index: 3; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(12, 12, 14, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: #fff; + opacity: 0; + transform: scale(0.82); + transition: + opacity var(--art-motion), + transform var(--art-motion); + } + .cover-wrap:hover .mark, + .cover-wrap:focus-visible .mark { + opacity: 1; + transform: none; + } + @media (prefers-reduced-motion: reduce) { + .cover-wrap, + .mark { + transition-duration: 1ms; + } + .cover-wrap:hover, + .cover-wrap:focus-visible { + transform: none; + } + } .cover { position: relative; z-index: 1; @@ -538,35 +619,6 @@ box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7); display: block; } - /* Corner expand control, Spotify-style: a dark glass disc pinned inside - the art's top-right corner, revealed on hover (or keyboard focus). */ - .expand { - position: absolute; - top: 10px; - right: 10px; - z-index: 2; - width: 30px; - height: 30px; - border-radius: 50%; - display: grid; - place-items: center; - color: #fff; - background: rgba(10, 10, 14, 0.6); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - opacity: 0; - transform: translateY(-3px) scale(0.9); - transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease; - } - .cover-wrap:hover .expand, - .expand:focus-visible { - opacity: 1; - transform: none; - } - .expand:hover { background: rgba(10, 10, 14, 0.85); } - @media (prefers-reduced-motion: reduce) { - .expand { transition: opacity 0.16s ease; transform: none; } - } .cover.placeholder { display: grid; place-items: center; diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index b0c4563..5e6b13b 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -1,24 +1,25 @@
- -
- - {#if album} -
- {#if coverOk} - - {/if} - {album.title} - -
- {/if} - {#if resolver.active} - ● {resolver.label} — finding the best source… - {:else if resolver.error} - {resolver.error} - {/if} -
+ {#if catalog.albumLoading}

Loading album…

@@ -531,19 +521,26 @@
{#if coverOk} - handleCoverError(event.currentTarget)} - /> + title="View artwork" + aria-label="View artwork full screen" + onclick={openArtwork} + > + handleCoverError(event.currentTarget)} + /> + + {:else}
{/if}
{(album.record_type ?? "album").toUpperCase()} -

22} use:watchTitle>{album.title}

+

22}>{album.title}

- {#if resolver.active} - ● {resolver.label} — finding the best source… - {:else if resolver.error} - {resolver.error} - {/if} -

+ + +
-

{openAlbum?.title ?? openFamily?.baseTitle ?? ""}

+ + + +

+ +

{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length ? ` · ${openTracks.length} tracks` @@ -4773,10 +4810,10 @@ {/if} {:else} {#if openTracks.length} -

0} in:partIn={{ delay: 30 }}> +
0} in:partIn={{ delay: 30 }}> # Title - {#if playsPeak > 0}Plays{/if} + {#if countPeak > 0}Listeners{/if}
{/if} @@ -4798,8 +4835,8 @@ }}
0} - style={playsPeak > 0 ? `--t:${playHeat(t)}` : undefined} + class:has-count={countPeak > 0} + style={countPeak > 0 ? `--t:${countHeat(t)}` : undefined} in:partIn={{ delay: Math.min(ti * 16, 180) }} class:now={nowPlaying("trackId", t.id)} class:locked @@ -4815,10 +4852,10 @@ {t.title} {#if libraryTicks.has(norm(t.title || ""))}{/if} - {#if playsPeak > 0} - - - {fmtPlays(t.plays)} + {#if countPeak > 0} + + + {fmtCount(t.listeners)} {/if} {#if locked} @@ -4915,7 +4952,7 @@ --band-top: -24px; /* Where the three pins actually sit in flow, written by heroScroll(). NOT zero: the pane's top padding puts them a little down the page, - while .results-head's -24px margin pulls the bar itself back over it. + while the glass bar's own -24px margin pulls it back over it. Everything hung off a pin has to subtract this to get back into band coordinates — leaving it out is what put the banner 24px low and dropped the artwork onto the glass bar instead of behind it. */ @@ -6189,77 +6226,6 @@ max-height: 76vh; } - /* Frosted-glass header floating on the banner — the same recipe as the - Now Playing panel's .np-head, so the two headers read as siblings: a - translucent tint over a backdrop blur, with a hairline light edge. */ - .results-head { - /* Sticky (was absolute) so the bar pins to the top of the scroll pane - and stays put as the page scrolls, in parity with the Now Playing - header. The negative margins reproduce the old absolute bleed while - keeping the bar in normal flow: -24/-28 bleed it up over the pane's - 24px top padding and out to the L/R pane edges; margin-bottom -22 - makes its net flow height zero (-24 + 46 - 22 = 0) so the hero still - sits directly beneath it instead of being pushed down. `top: -24px` - pins it flush against the very top of the pane (over that padding). */ - position: sticky; - top: -24px; - margin: -24px -28px -22px; - /* Above the identity (30) and the hero (20): the bar keeps the top of - the pane to itself and the parked wordmark slides under it. */ - z-index: 40; - height: 46px; - display: flex; - align-items: center; - gap: 12px; - padding: 0 28px; - background: rgba(20, 20, 24, 0.28); - /* 12px, down from 20: pixel-identical over every real captured - state (2026-07-24 backdrop evidence pack, two passes) — the - backdrop re-sample, not the kernel, is the GPU cost, but the - smaller radius still shaves ~0.4 CPU-s/6s of scroll. 8px was - one visible step too tight over a sharp hero. */ - 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); - } - - /* Matches .np-title: white label legible on the glass, with a soft shadow - for bright banners. */ - .results-head .back { - display: inline-flex; - align-items: center; - gap: 5px; - color: #fff; - font-size: 13px; - font-weight: 600; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); - } - - .results-head .back:hover { - color: var(--text-secondary); - } - - /* Resolver status rides the right side of the glass bar (was a separate - line above the hero before the header became the glass strip). */ - .rstatus { - margin-left: auto; - max-width: 55%; - font-size: 12px; - color: rgba(255, 255, 255, 0.85); - text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .rstatus .pulse { - color: var(--accent); - } - - .rstatus.error { - color: #ff8a94; - } - /* The sharp artwork: native aspect ratio (no crop), offset down so it begins right at the bottom of the glass bar. max-height caps the very-wide / non-16:9 case so the hero can't swallow @@ -7540,7 +7506,7 @@ transition: background 0.15s ease; content-visibility: auto; contain-intrinsic-size: 150px 210px; - /* Anchor for the open-expander scroll: clears the sticky results-head + /* Anchor for the open-expander scroll: clears the sticky glass bar AND the parked banner beneath it plus breathing room, so a clicked row lands just below the banner instead of underneath it. Tracks --banner-h rather than a constant, because the banner now scales @@ -7692,9 +7658,85 @@ to { opacity: 0; } } + /* Same artwork-hover treatment as the album page's hero (--art-* tokens in + global.css): the whole element lifts under a light wash with the expand + mark centred. The rail's own crossfade lives on the images inside, and + is untouched by a transform on their container. */ .rail-art-stage { + position: relative; + display: block; width: 100%; aspect-ratio: 1; + padding: 0; + border-radius: 8px; + cursor: pointer; + transition: transform var(--art-motion); + } + .rail-art-stage:hover, + .rail-art-stage:focus-visible { + transform: scale(var(--art-grow)); + } + .rail-art-stage::after { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + border-radius: 8px; + background: var(--art-wash); + opacity: 0; + transition: opacity var(--art-motion); + pointer-events: none; + } + .rail-art-stage:hover::after, + .rail-art-stage:focus-visible::after { + opacity: 1; + } + .rail-art-mark { + position: absolute; + inset: 0; + margin: auto; + z-index: 3; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(12, 12, 14, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: #fff; + opacity: 0; + transform: scale(0.82); + transition: + opacity var(--art-motion), + transform var(--art-motion); + } + .rail-art-stage:hover .rail-art-mark, + .rail-art-stage:focus-visible .rail-art-mark { + opacity: 1; + transform: none; + } + @media (prefers-reduced-motion: reduce) { + .rail-art-stage, + .rail-art-mark { + transition-duration: 1ms; + } + .rail-art-stage:hover, + .rail-art-stage:focus-visible { + transform: none; + } + } + + /* The name is the link to the record's own page. It inherits the heading's + type so only the pointer and the underline mark it as one. */ + .rail-title-link { + font: inherit; + color: inherit; + text-align: left; + padding: 0; + } + .rail-title-link:hover { + text-decoration: underline; } .rail-art.placeholder { @@ -8070,8 +8112,8 @@ /* The header and the rows share one template, so the columns line up whether or not Last.fm knew this record. */ - .trow.has-plays, - .tl-cols.has-plays { + .trow.has-count, + .tl-cols.has-count { grid-template-columns: 26px minmax(0, 1fr) 168px 48px; } @@ -8099,7 +8141,7 @@ and cool while the record's biggest song burns the album's own accent. The floor keeps the quietest track legible instead of letting it sink into the row. */ - .tplays { + .tcount { position: relative; display: flex; align-items: center; @@ -8110,7 +8152,7 @@ font-variant-numeric: tabular-nums; } - .tplays-fill { + .tcount-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; @@ -8122,7 +8164,7 @@ ); } - .tplays-val { + .tcount-val { position: relative; z-index: 1; padding: 0 8px; @@ -8131,8 +8173,8 @@ /* The hover reveal slides the play/save buttons over the right end of the row, so the duration gets out of the way — the count stays put. */ - .trow:hover .tplays, - .trow:focus-within .tplays { + .trow:hover .tcount, + .trow:focus-within .tcount { opacity: 1; } @@ -8256,10 +8298,6 @@ width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px) * 0.75), 92%); } - .results-head { - padding: 0 20px; - } - /* Tighter one-line floor here. It must be set as --row-min on the page, NOT as a height on the spacer: the spacer mirrors the row's measured height now, and a hard height would pin the reserve to one