navigation: one glass bar for every page, with a history and a path in it
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.
**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.
Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.
Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.
Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.
**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.
**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.
Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.
On the artist page the expander's album NAME now opens that record's page.
Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.
**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.
`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.
Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".
The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.
152 Rust tests and 85 JS tests pass.
This commit is contained in:
1 parent
ea9a040d09
commit
7a517dca59
11 files changed
+1154
-403
No files matched your search
+102
-14
@@ -6220,20 +6220,9 @@ impl MetadataClient {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut by_key: HashMap<String, TrackStats> = HashMap::new();
|
||||
let mut ranked: Vec<LastfmTrack> = 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<String, TrackStats> = HashMap::new();
|
||||
let mut ranked: Vec<LastfmTrack> = 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;
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script>
|
||||
// Full-window artwork viewer — a lightbox for the playing album's cover.
|
||||
// Opened from the corner expand button on the Now Playing panel's art.
|
||||
// Full-window artwork viewer — a lightbox for any album cover.
|
||||
// Opened from the Now Playing panel's art, and from an album's artwork
|
||||
// wherever it is large enough to be worth looking at.
|
||||
// No player chrome: the artwork itself, as large as the window allows,
|
||||
// at the full resolution of the canonical cover (3000px-class for
|
||||
// library albums). Click anywhere, Escape, or the corner button closes.
|
||||
import { fade } from "svelte/transition";
|
||||
import { player, ui, getArt, getCatalogArt, playerArtSource } from "../lib/state.svelte.js";
|
||||
import { ui, getArt, getCatalogArt } from "../lib/state.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
// { src, local, title, subtitle } — the caller says which cover and how to
|
||||
// fetch it; the viewer no longer reaches into the queue for it.
|
||||
const art = $derived(ui.artwork);
|
||||
const close = () => (ui.artwork = null);
|
||||
|
||||
// Album art (local canonical file or catalog CDN) → data URL. The original
|
||||
// stays untouched for the foreground; the ambient backdrop is baked once
|
||||
@@ -54,17 +58,12 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const open = ui.fullscreenArt;
|
||||
const item = currentItem;
|
||||
const source = art;
|
||||
artUrl = null;
|
||||
ambientUrl = null;
|
||||
if (!open || !item) return;
|
||||
if (!source?.src) return;
|
||||
let active = true;
|
||||
const pending = item.art
|
||||
? getArt(item.art)
|
||||
: item.artUrl
|
||||
? getCatalogArt(playerArtSource(item))
|
||||
: Promise.resolve(null);
|
||||
const pending = source.local ? getArt(source.src) : getCatalogArt(source.src);
|
||||
pending
|
||||
.then(async (url) => {
|
||||
if (!active || !url) return;
|
||||
@@ -81,23 +80,18 @@
|
||||
return () => (active = false);
|
||||
});
|
||||
|
||||
// Nothing playing → nothing to view.
|
||||
$effect(() => {
|
||||
if (ui.fullscreenArt && !currentItem) ui.fullscreenArt = false;
|
||||
});
|
||||
|
||||
function onKey(e) {
|
||||
if (!ui.fullscreenArt) return;
|
||||
if (!art) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
ui.fullscreenArt = false;
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if ui.fullscreenArt && currentItem}
|
||||
{#if art}
|
||||
<!-- The whole surface is one dismiss target, lightbox-style. -->
|
||||
<div
|
||||
class="viewer"
|
||||
@@ -105,9 +99,9 @@
|
||||
tabindex="-1"
|
||||
aria-label="Close artwork viewer"
|
||||
transition:fade={{ duration: 160 }}
|
||||
onclick={() => (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();
|
||||
}}
|
||||
>
|
||||
<Icon name="shrink" size={17} />
|
||||
</button>
|
||||
|
||||
{#if artUrl}
|
||||
<img class="art" src={artUrl} alt="Album artwork" draggable="false" />
|
||||
<img class="art" src={artUrl} alt={art.title ? `${art.title} artwork` : "Album artwork"} draggable="false" />
|
||||
{:else}
|
||||
<div class="art placeholder"><Icon name="disc" size={72} /></div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<script>
|
||||
// The header every catalog page navigates by — a frosted strip pinned to
|
||||
// the top of the scroll pane, floating over whatever artwork is behind it.
|
||||
//
|
||||
// It began as the artist page's `.results-head`, in parity with the Now
|
||||
// Playing panel's `.np-head`. Geometry, tint, blur radius and the hairline
|
||||
// edge are all load-bearing; see the notes on the rules below.
|
||||
//
|
||||
// It carries the app's navigation: history arrows, and the trail to where
|
||||
// you are. Those are two different things — see src/lib/navigation.svelte.js
|
||||
// for why the trail is derived from the current place rather than from the
|
||||
// history the arrows walk.
|
||||
import {
|
||||
nav,
|
||||
navBack,
|
||||
navForward,
|
||||
navToCrumb,
|
||||
resolver,
|
||||
} from "../lib/state.svelte.js";
|
||||
import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const crumbs = $derived(nav.stack.length ? trail() : []);
|
||||
const back = $derived(canGoBack());
|
||||
const forward = $derived(canGoForward());
|
||||
|
||||
const reducedMotion = () =>
|
||||
typeof matchMedia === "function"
|
||||
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// Arriving crumbs slide in from the right and leaving ones slide out the
|
||||
// same way, so the direction of travel is legible: deeper comes from the
|
||||
// right, back leaves to it. The trail itself never moves, so an ancestor
|
||||
// stays where the eye left it.
|
||||
const SLIDE = 14;
|
||||
const DUR = 300;
|
||||
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
let seen = new Set();
|
||||
function slideIn(node, key) {
|
||||
// Only genuinely new crumbs animate. Without this every crumb re-runs
|
||||
// its entrance whenever any part of the trail changes.
|
||||
if (seen.has(key) || reducedMotion()) {
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
node.animate(
|
||||
[
|
||||
{ opacity: 0, transform: `translateX(${SLIDE}px)` },
|
||||
{ opacity: 1, transform: "none" },
|
||||
],
|
||||
{ duration: DUR, easing: EASE, fill: "backwards" },
|
||||
);
|
||||
}
|
||||
|
||||
// Crumbs no longer in the trail are forgotten, so revisiting a place
|
||||
// animates again rather than appearing instantly.
|
||||
$effect(() => {
|
||||
const live = new Set(crumbs.map((c) => c.key));
|
||||
for (const key of seen) if (!live.has(key)) seen.delete(key);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- data-scrollhead keeps the overlay scrollbar's thumb below this bar rather
|
||||
than running under it (see src/lib/scrollbar.js). -->
|
||||
<div class="glassbar" data-scrollhead>
|
||||
<div class="hist">
|
||||
<button class="arrow" disabled={!back} title="Back" aria-label="Back" onclick={navBack}>
|
||||
<Icon name="chevron-left" size={17} />
|
||||
</button>
|
||||
<button class="arrow" disabled={!forward} title="Forward" aria-label="Forward" onclick={navForward}>
|
||||
<Icon name="chevron-right" size={17} />
|
||||
</button>
|
||||
</div>
|
||||
{#if crumbs.length}
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<nav class="trail" aria-label="Breadcrumb">
|
||||
{#each crumbs as crumb, i (crumb.key)}
|
||||
<span class="node" use:slideIn={crumb.key}>
|
||||
{#if i > 0}
|
||||
<span class="chev" aria-hidden="true"><Icon name="chevron-right" size={12} /></span>
|
||||
{/if}
|
||||
<button
|
||||
class="crumb"
|
||||
class:here={i === crumbs.length - 1}
|
||||
title={crumb.label}
|
||||
aria-current={i === crumbs.length - 1 ? "page" : undefined}
|
||||
onclick={() => i < crumbs.length - 1 && navToCrumb(crumb)}
|
||||
>
|
||||
{#if crumb.art}
|
||||
<img class="thumb" class:round={crumb.round} src={crumb.art} alt="" draggable="false" />
|
||||
{:else}
|
||||
<span class="thumb glyph"><Icon name={crumb.glyph ?? "disc"} size={13} /></span>
|
||||
{/if}
|
||||
<span class="label">{crumb.label}</span>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if resolver.active}
|
||||
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
|
||||
{:else if resolver.error}
|
||||
<span class="rstatus error">{resolver.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.glassbar {
|
||||
/* Sticky rather than absolute, so the bar pins to the top of the pane
|
||||
and stays there as the page scrolls. The negative margins reproduce
|
||||
an absolute bleed while keeping the bar in normal flow: -24/-28 bleed
|
||||
it up over the pane's 24px top padding and out to its left/right
|
||||
edges, and margin-bottom -22 makes its net flow height ZERO
|
||||
(-24 + 46 - 22 = 0) so the hero beneath it is not pushed down.
|
||||
`top: -24px` pins it flush against the very top of the pane, over
|
||||
that padding. All four numbers are tied to the pane's own 24/28px
|
||||
padding — change one and the seam opens. */
|
||||
position: sticky;
|
||||
top: -24px;
|
||||
margin: -24px -28px -22px;
|
||||
/* Above the artist page's pinned identity (30) and 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);
|
||||
/* A crumb sliding in starts 14px right of its place; without this it
|
||||
would widen the bar for the length of the animation. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hist {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
.arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
}
|
||||
.arrow:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.arrow:disabled {
|
||||
color: rgba(255, 255, 255, 0.26);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The tail keeps its full name; ancestors give up width first, so a long
|
||||
record title never squeezes out the place you are actually on. */
|
||||
.node:not(:last-child) {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.node:last-child {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
.crumb:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.crumb.here {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: default;
|
||||
}
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
}
|
||||
.thumb.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.thumb.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.chev {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Resolver status rides the right side of the bar. */
|
||||
.rstatus {
|
||||
margin-left: auto;
|
||||
max-width: 40%;
|
||||
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;
|
||||
flex: none;
|
||||
}
|
||||
.rstatus .pulse {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rstatus.error {
|
||||
color: #ff8a94;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.glassbar {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@
|
||||
expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>',
|
||||
shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
|
||||
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
||||
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
// Lucide layers-2: its ink fills the viewBox symmetrically, so the
|
||||
// glyph centers correctly inside small pills (the 3-layer variant
|
||||
|
||||
@@ -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 @@
|
||||
<div class="hero">
|
||||
{#if artUrl}
|
||||
<img class="glow" src={artUrl} alt="" aria-hidden="true" draggable="false" />
|
||||
<div class="cover-wrap">
|
||||
<button
|
||||
class="cover-wrap"
|
||||
title="View artwork"
|
||||
aria-label="View album artwork full screen"
|
||||
onclick={() => openArtwork()}
|
||||
>
|
||||
<img class="cover" src={artUrl} alt="" draggable="false" />
|
||||
<button
|
||||
class="expand"
|
||||
title="View artwork"
|
||||
aria-label="View album artwork full screen"
|
||||
onclick={() => (ui.fullscreenArt = true)}
|
||||
>
|
||||
<Icon name="expand" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<span class="mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="music" size={40} /></div>
|
||||
{/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;
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<script>
|
||||
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar } from "../lib/state.svelte.js";
|
||||
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar, goToView } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
// Through goToView, not a bare `ui.view = …`: the glass bar's history and
|
||||
// trail are fed by recorded visits, and a destination that skips the
|
||||
// recorder is one the back arrow can never return to.
|
||||
function navigate(id) {
|
||||
ui.view = id;
|
||||
if (id !== "library") ui.albumId = null;
|
||||
goToView(id, id !== "library" ? { albumId: null } : {});
|
||||
}
|
||||
|
||||
async function newPlaylist() {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
ui.view = "playlist";
|
||||
ui.playlistId = id;
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
}
|
||||
|
||||
function openPlaylist(id) {
|
||||
ui.view = "playlist";
|
||||
ui.playlistId = id;
|
||||
const name = playlistStore.playlists.find((p) => p.id === id)?.name;
|
||||
goToView("playlist", { playlistId: id, label: name ?? "Playlist" });
|
||||
}
|
||||
|
||||
const items = [
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/** Where you are, how you got here, and how to get back.
|
||||
*
|
||||
* Two different things live in this file, and keeping them apart is the whole
|
||||
* design:
|
||||
*
|
||||
* HISTORY is a sequence — every place you visited, in order. It drives the
|
||||
* back and forward arrows, and it does not care about structure: going from
|
||||
* one album to a completely unrelated one is a step like any other.
|
||||
*
|
||||
* The TRAIL is a hierarchy — the path to where you are NOW. It is derived
|
||||
* from the current place, never from history, which is why switching from
|
||||
* one edition of a record to another leaves the trail the same shape while
|
||||
* still adding a history step. A breadcrumb built out of history would read
|
||||
* `Search › Weezer › Blue Album › Deluxe Edition`, implying the deluxe sits
|
||||
* inside the original, which it does not.
|
||||
*
|
||||
* Every place is an ENTRY: enough to re-open it, plus what a crumb needs to
|
||||
* draw itself.
|
||||
*/
|
||||
|
||||
const MAX_HISTORY = 60;
|
||||
|
||||
export const nav = $state({
|
||||
/** Visited places, oldest first; the last one is where you are. */
|
||||
stack: [],
|
||||
/** Places ahead of you, nearest first — filled by going back. */
|
||||
future: [],
|
||||
/** The root the current catalog journey began from, so a record reached by
|
||||
* search does not claim it was reached from the library. */
|
||||
origin: null,
|
||||
});
|
||||
|
||||
/** Identity for an entry — two entries with the same key are the same place. */
|
||||
function entryKey(e) {
|
||||
switch (e.view) {
|
||||
case "catalogArtist":
|
||||
return `artist:${e.artistId}`;
|
||||
case "catalogAlbum":
|
||||
return `album:${e.albumId}`;
|
||||
case "catalog":
|
||||
return `search:${e.label}`;
|
||||
case "playlist":
|
||||
return `playlist:${e.playlistId}`;
|
||||
case "library":
|
||||
return e.albumId != null ? `libalbum:${e.albumId}` : "library";
|
||||
default:
|
||||
return e.view;
|
||||
}
|
||||
}
|
||||
|
||||
/** Roots start a journey rather than extending one. A results page counts:
|
||||
* it is where a browse begins, and it is the crumb you actually want to get
|
||||
* back to — "Search" as a bare destination is a box you have already left. */
|
||||
const ROOTS = new Set([
|
||||
"home",
|
||||
"search",
|
||||
"library",
|
||||
"downloads",
|
||||
"settings",
|
||||
"catalog",
|
||||
]);
|
||||
|
||||
/** True while the arrows are driving, so replaying a place does not record it
|
||||
* as a new visit and wipe the future you are walking back into. */
|
||||
let restoring = false;
|
||||
export const isRestoring = () => restoring;
|
||||
|
||||
/**
|
||||
* Record a visit. Callers pass what they know; everything else is inferred.
|
||||
* @param {{view: string, label: string, art?: string|null, round?: boolean,
|
||||
* glyph?: string|null, artistId?: number|null, albumId?: number|null,
|
||||
* playlistId?: number|null, artist?: string|null}} entry
|
||||
*/
|
||||
export function recordVisit(entry) {
|
||||
if (restoring) return;
|
||||
const e = { ...entry, key: entryKey(entry) };
|
||||
const top = nav.stack[nav.stack.length - 1];
|
||||
// Re-entering the place you are already on is not a visit. It happens on
|
||||
// every refresh of a page that reloads its own payload, and without this
|
||||
// each one would push a duplicate the back arrow then has to walk through.
|
||||
if (top && top.key === e.key) {
|
||||
nav.stack[nav.stack.length - 1] = e;
|
||||
return;
|
||||
}
|
||||
if (ROOTS.has(e.view)) {
|
||||
// A root is a fresh journey: the trail starts here.
|
||||
nav.origin = e;
|
||||
} else if (!nav.origin) {
|
||||
nav.origin = { view: "search", label: "Search", key: "search" };
|
||||
}
|
||||
nav.stack.push(e);
|
||||
if (nav.stack.length > MAX_HISTORY) nav.stack.shift();
|
||||
nav.future = [];
|
||||
}
|
||||
|
||||
/** Update the crumb for the place you are already on, and only that place.
|
||||
* Never pushes. Identity arrives after navigation — an artist page opens on an
|
||||
* id and learns its name and portrait a moment later — so the crumb has to be
|
||||
* able to improve in place without becoming a second history step. */
|
||||
export function refreshVisit(entry) {
|
||||
const e = { ...entry, key: entryKey(entry) };
|
||||
const top = nav.stack[nav.stack.length - 1];
|
||||
if (top && top.key === e.key) nav.stack[nav.stack.length - 1] = e;
|
||||
if (nav.origin && nav.origin.key === e.key) nav.origin = e;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- scroll memory ----
|
||||
* Where the reader was on each place they visited. Going back should return
|
||||
* you to the paragraph you left, not to the top of a page you have already
|
||||
* read; arriving somewhere NEW should start at its top. The difference is
|
||||
* exactly whether the move came from the arrows/trail or from a fresh click.
|
||||
*/
|
||||
|
||||
const paneEl = () =>
|
||||
typeof document === "undefined" ? null : document.querySelector(".content");
|
||||
|
||||
/** Remember the reader's position on the place being left. Called at the START
|
||||
* of every navigation, before anything swaps — by the time a view has changed,
|
||||
* the pane's scrollTop belongs to the page that replaced it. */
|
||||
export function captureScroll() {
|
||||
const pane = paneEl();
|
||||
const top = nav.stack[nav.stack.length - 1];
|
||||
if (pane && top) top.scroll = pane.scrollTop;
|
||||
}
|
||||
|
||||
/** Set by a history move, read once by whichever view lands. `null` means a
|
||||
* fresh arrival, which starts at the top. */
|
||||
let pendingScroll = null;
|
||||
export function consumeScroll() {
|
||||
const v = pendingScroll;
|
||||
pendingScroll = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Put the reader back. A page that loads in stages is SHORT when it first
|
||||
* appears — the artist page paints a skeleton, then an overview, then the full
|
||||
* discography — so a single assignment clamps to whatever height exists in
|
||||
* that frame and silently lands near the top. Keep re-applying until the
|
||||
* target is actually reachable, and give up the moment the reader takes over. */
|
||||
export function restoreScroll(pane, target, budgetMs = 1500) {
|
||||
if (!pane) return;
|
||||
const started = performance.now();
|
||||
let cancelled = false;
|
||||
const release = () => {
|
||||
cancelled = true;
|
||||
for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"]) {
|
||||
pane.removeEventListener(ev, release);
|
||||
window.removeEventListener(ev, release);
|
||||
}
|
||||
};
|
||||
for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"]) {
|
||||
pane.addEventListener(ev, release, { passive: true, once: true });
|
||||
window.addEventListener(ev, release, { passive: true, once: true });
|
||||
}
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
pane.scrollTop = target;
|
||||
const arrived = Math.abs(pane.scrollTop - target) < 1;
|
||||
if (arrived || performance.now() - started > budgetMs) return release();
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
export const canGoBack = () => nav.stack.length > 1;
|
||||
export const canGoForward = () => nav.future.length > 0;
|
||||
|
||||
/** Walk to an entry without recording it — the caller has already decided
|
||||
* which way the stacks move. */
|
||||
async function replay(entry, open) {
|
||||
restoring = true;
|
||||
try {
|
||||
await open(entry);
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function goBack(open) {
|
||||
if (!canGoBack()) return;
|
||||
captureScroll();
|
||||
const leaving = nav.stack.pop();
|
||||
nav.future = [leaving, ...nav.future];
|
||||
const target = nav.stack[nav.stack.length - 1];
|
||||
pendingScroll = target.scroll ?? 0;
|
||||
await replay(target, open);
|
||||
}
|
||||
|
||||
export async function goForward(open) {
|
||||
if (!canGoForward()) return;
|
||||
captureScroll();
|
||||
const [next, ...rest] = nav.future;
|
||||
nav.future = rest;
|
||||
nav.stack.push(next);
|
||||
pendingScroll = next.scroll ?? 0;
|
||||
await replay(next, open);
|
||||
}
|
||||
|
||||
/** Jump to a crumb. Everything after it becomes the future, so the forward
|
||||
* arrow walks back down the path you just climbed out of. */
|
||||
export async function goToCrumb(entry, open) {
|
||||
const at = nav.stack.findIndex((e) => e.key === entry.key);
|
||||
if (at === -1 || at === nav.stack.length - 1) {
|
||||
// A trail step you never actually stood on (the origin of a journey
|
||||
// resumed from elsewhere): go there fresh rather than rewriting history.
|
||||
return open(entry);
|
||||
}
|
||||
captureScroll();
|
||||
const dropped = nav.stack.slice(at + 1);
|
||||
nav.stack = nav.stack.slice(0, at + 1);
|
||||
nav.future = [...dropped.reverse(), ...nav.future];
|
||||
pendingScroll = nav.stack[nav.stack.length - 1].scroll ?? 0;
|
||||
await replay(entry, open);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path to where you are, as crumbs. Derived from the current place and the
|
||||
* journey's origin — NOT from history.
|
||||
*/
|
||||
export function trail() {
|
||||
const here = nav.stack[nav.stack.length - 1];
|
||||
if (!here) return [];
|
||||
const crumbs = [];
|
||||
if (nav.origin && nav.origin.key !== here.key) crumbs.push(nav.origin);
|
||||
|
||||
// An album sits under its artist; an artist sits under the origin. The
|
||||
// artist crumb is taken from history when it is there, so it carries the
|
||||
// portrait and id already resolved, and is synthesised from the record's
|
||||
// own artist name when the album was reached directly.
|
||||
if (here.view === "catalogAlbum" && here.artist) {
|
||||
const known = [...nav.stack]
|
||||
.reverse()
|
||||
.find((e) => e.view === "catalogArtist" && e.label === here.artist);
|
||||
crumbs.push(
|
||||
known ?? {
|
||||
view: "catalogArtist",
|
||||
label: here.artist,
|
||||
artistId: here.artistId ?? null,
|
||||
art: null,
|
||||
round: true,
|
||||
key: `artist:${here.artistId ?? here.artist}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
crumbs.push(here);
|
||||
return crumbs;
|
||||
}
|
||||
+92
-1
@@ -1,6 +1,15 @@
|
||||
// Shared reactive app state (Svelte 5 runes module).
|
||||
import * as api from "./api.js";
|
||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||
import {
|
||||
nav,
|
||||
recordVisit,
|
||||
refreshVisit,
|
||||
captureScroll,
|
||||
goBack,
|
||||
goForward,
|
||||
goToCrumb,
|
||||
} from "./navigation.svelte.js";
|
||||
import {
|
||||
initPreviewBridge,
|
||||
bumpMainRevision,
|
||||
@@ -21,7 +30,10 @@ export const ui = $state({
|
||||
playlistId: null, // selected playlist in Playlist view
|
||||
nowPlaying: true, // right-side Now Playing panel open
|
||||
sidebarCollapsed: false, // left nav collapsed to an icon rail
|
||||
fullscreenArt: false, // Spotify-style full-window now-playing view
|
||||
// Full-window artwork lightbox. `null` when closed; otherwise a descriptor
|
||||
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||||
// surface can open it, not only the playing track.
|
||||
artwork: null,
|
||||
// WebView2 keeps document.visibilityState="visible" while the native
|
||||
// window is minimized. This is the authoritative shared activity gate for
|
||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||
@@ -881,6 +893,16 @@ function rememberArtistSeed(seed) {
|
||||
remembered,
|
||||
ARTIST_IDENTITY_CACHE_MAX,
|
||||
);
|
||||
// If this IS the artist being viewed, its crumb gains the real name and
|
||||
// portrait it was opened without. Guarded inside refreshVisit, so seeds
|
||||
// remembered for rows in a list cannot rewrite where you are.
|
||||
refreshVisit({
|
||||
view: "catalogArtist",
|
||||
label: remembered.name,
|
||||
artistId: remembered.id,
|
||||
art: remembered.picture ?? null,
|
||||
round: true,
|
||||
});
|
||||
const norm = remembered.name.trim().toLowerCase();
|
||||
if (norm) {
|
||||
const existing = artistSeedCache.get(norm);
|
||||
@@ -1150,14 +1172,72 @@ export function warmCatalogArtist(seed) {
|
||||
}
|
||||
const identityWarmupDone = new Set();
|
||||
|
||||
/** Move to a top-level view (the sidebar's destinations). Routed through here
|
||||
* rather than assigning `ui.view` directly so history and the trail see it —
|
||||
* a root also STARTS a journey, which is what the first crumb reports. */
|
||||
export function goToView(view, opts = {}) {
|
||||
captureScroll();
|
||||
ui.view = view;
|
||||
if (opts.albumId !== undefined) ui.albumId = opts.albumId;
|
||||
if (opts.playlistId !== undefined) ui.playlistId = opts.playlistId;
|
||||
recordVisit({ view, label: opts.label ?? VIEW_LABELS[view] ?? view, glyph: VIEW_GLYPHS[view] ?? null, ...opts });
|
||||
}
|
||||
|
||||
const VIEW_LABELS = {
|
||||
home: "Home",
|
||||
search: "Search",
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
settings: "Settings",
|
||||
playlist: "Playlist",
|
||||
};
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
search: "search",
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
settings: "settings",
|
||||
playlist: "music",
|
||||
};
|
||||
|
||||
/** Re-open a recorded place. The one function the history arrows and the trail
|
||||
* both drive; every case must land on exactly the view the entry describes. */
|
||||
export async function openEntry(entry) {
|
||||
switch (entry.view) {
|
||||
case "catalogArtist":
|
||||
return openCatalogArtist(entry.artistId);
|
||||
case "catalogAlbum":
|
||||
return openCatalogAlbum(entry.albumId);
|
||||
case "catalog":
|
||||
return catalogGo(entry.query ?? entry.label);
|
||||
case "playlist":
|
||||
ui.playlistId = entry.playlistId ?? null;
|
||||
ui.view = "playlist";
|
||||
return;
|
||||
case "library":
|
||||
ui.albumId = entry.albumId ?? null;
|
||||
ui.view = "library";
|
||||
return;
|
||||
default:
|
||||
ui.view = entry.view;
|
||||
}
|
||||
}
|
||||
|
||||
export const navBack = () => goBack(openEntry);
|
||||
export const navForward = () => goForward(openEntry);
|
||||
export const navToCrumb = (entry) => goToCrumb(entry, openEntry);
|
||||
export { nav };
|
||||
|
||||
/** Run a catalog search and show the results view. */
|
||||
export async function catalogGo(query) {
|
||||
captureScroll();
|
||||
cancelCatalogArtistWarmup();
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
const request = ++catalogSearchRequest;
|
||||
ui.view = "catalog";
|
||||
ui.albumId = null;
|
||||
recordVisit({ view: "catalog", label: `“${q}”`, query: q, glyph: "search" });
|
||||
catalog.query = q;
|
||||
catalog.results = null;
|
||||
catalog.loading = true;
|
||||
@@ -1716,6 +1796,7 @@ async function applyArtistSocials(artistId, active) {
|
||||
|
||||
export async function openCatalogArtist(artistId, seedArtist = null) {
|
||||
cancelCatalogArtistWarmup();
|
||||
captureScroll();
|
||||
catalogSearchRequest += 1;
|
||||
const request = ++artistPageRequest;
|
||||
const artistKey = artistCacheKey(artistId);
|
||||
@@ -1725,6 +1806,7 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
|
||||
catalog.artistOpenSeq += 1;
|
||||
activateCoverUpgradeScope(artistId);
|
||||
ui.view = "catalogArtist";
|
||||
recordVisit({ view: "catalogArtist", label: seedArtist?.name ?? "Artist", artistId, art: seedArtist?.picture ?? null, round: true });
|
||||
const active = () =>
|
||||
request === artistPageRequest
|
||||
&& ui.view === "catalogArtist"
|
||||
@@ -1998,6 +2080,7 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
|
||||
export async function openCatalogAlbum(albumId, opts = {}) {
|
||||
const albumKey = albumCacheKey(albumId);
|
||||
if (!albumKey) return;
|
||||
captureScroll();
|
||||
// A verified-id navigation supersedes any still-running name search.
|
||||
albumResolveRequest += 1;
|
||||
const request = ++albumPageRequest;
|
||||
@@ -2021,6 +2104,13 @@ export async function openCatalogAlbum(albumId, opts = {}) {
|
||||
}
|
||||
if (!active()) return;
|
||||
catalog.albumPage = page;
|
||||
recordVisit({
|
||||
view: "catalogAlbum",
|
||||
label: page.album.title,
|
||||
albumId: page.album.id,
|
||||
art: page.album.cover ?? null,
|
||||
artist: page.album.artist ?? null,
|
||||
});
|
||||
// No catalog trackId (e.g. local playback) — match the song by title.
|
||||
if (catalog.pendingTrackId == null && opts.trackTitle) {
|
||||
const t = page.tracks.find(
|
||||
@@ -4614,6 +4704,7 @@ if (import.meta.env.DEV && typeof window !== "undefined") {
|
||||
playAlbum, jumpToQueueIndex, toggleNowPlaying,
|
||||
artBroken, markArtBroken,
|
||||
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
|
||||
catalogGo, goToView, openEntry, navBack, navForward, navToCrumb, nav,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
--border: #2a2a2a;
|
||||
--radius: 8px;
|
||||
/* Artwork that opens the full-window viewer, wherever it appears — the
|
||||
album hero, the artist page's expander rail, the Now Playing cover.
|
||||
The whole element scales rather than the picture inside it, because
|
||||
`transform` does not reflow: the artwork grows and nothing beside it
|
||||
moves. Scaling the image within a fixed frame reads as the picture
|
||||
filling its box, not as the album lifting off the page.
|
||||
Tuned on the round-2 hover mock, 2026-07-28. */
|
||||
--art-grow: 1.035;
|
||||
--art-motion: 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--art-wash: rgba(0, 0, 0, 0.18);
|
||||
--sidebar-width: 232px;
|
||||
--player-height: 88px;
|
||||
--nowplaying-width: 340px;
|
||||
|
||||
+160
-199
@@ -18,13 +18,16 @@
|
||||
getCatalogArtistPage,
|
||||
openCatalogAlbum,
|
||||
toggleShuffle,
|
||||
ui,
|
||||
} from "../lib/state.svelte.js";
|
||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||
import { buildAlbumManifest } from "../lib/preview/manifest.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import { editionLabel, releaseFamilies } from "../lib/editions.js";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import GlassBar from "../components/GlassBar.svelte";
|
||||
|
||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||
const album = $derived(page?.album ?? null);
|
||||
@@ -168,17 +171,25 @@
|
||||
const albumListeners = $derived(fmtCompact(page?.album_listeners ?? null));
|
||||
const totalsSummed = $derived(Boolean(page?.totals_summed));
|
||||
|
||||
// The album's own most-played track is the yardstick. A bar is a
|
||||
// comparison, so a one- or two-track release gets the plain list instead:
|
||||
// the longest bar there is only "the bigger of these two", which reads as
|
||||
// a rating it is not.
|
||||
const playsPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0));
|
||||
const showPlays = $derived(playsPeak > 0 && tracks.length >= 3);
|
||||
const playShare = (t) =>
|
||||
!t.plays || !playsPeak ? 0 : Math.max(2, Math.round((t.plays / playsPeak) * 100));
|
||||
const playHeat = (t) => (!t.plays || !playsPeak ? 0 : t.plays / playsPeak).toFixed(3);
|
||||
// The album's most-heard track is the yardstick. A bar is a comparison, so
|
||||
// a one- or two-track release gets the plain list instead: the longest bar
|
||||
// there is only "the bigger of these two", which reads as a rating it is
|
||||
// not.
|
||||
//
|
||||
// LISTENERS, not scrobbles. Both come from the same Last.fm table, but the
|
||||
// play counts are the noisier of the two — distinct listeners is the figure
|
||||
// the Popular list is already ordered by, so the album and the artist page
|
||||
// now rank a tracklist the same way.
|
||||
const countPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0));
|
||||
const showCount = $derived(countPeak > 0 && tracks.length >= 3);
|
||||
const countShare = (t) =>
|
||||
!t.listeners || !countPeak
|
||||
? 0
|
||||
: Math.max(2, Math.round((t.listeners / countPeak) * 100));
|
||||
const countHeat = (t) =>
|
||||
(!t.listeners || !countPeak ? 0 : t.listeners / countPeak).toFixed(3);
|
||||
/** Unknown is a dash, never a zero — same rule as the artist page. */
|
||||
const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString());
|
||||
const fmtCount = (n) => (n == null ? "—" : n.toLocaleString());
|
||||
|
||||
// ---- editions ---------------------------------------------------------
|
||||
// The album payload has no siblings, so the family is rebuilt from the
|
||||
@@ -251,6 +262,20 @@
|
||||
return [y, n ? `${n} ${n === 1 ? "track" : "tracks"}` : null].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
/** This record's artwork in the shared full-window viewer — the same one
|
||||
* the Now Playing cover and the artist page's expander open. The UPGRADED
|
||||
* cover, not the sized variant the hero paints: the point of the viewer is
|
||||
* the master resolution. */
|
||||
function openArtwork() {
|
||||
if (!coverOk || !album) return;
|
||||
ui.artwork = {
|
||||
src: cover,
|
||||
local: false,
|
||||
title: album.title ?? "",
|
||||
subtitle: album.artist ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- shuffle -----------------------------------------------------------
|
||||
/** Play the record with shuffle on. Not a toggle: the button is an action
|
||||
* on THIS album, so it always turns shuffle on rather than flipping a
|
||||
@@ -443,28 +468,23 @@
|
||||
tracks.length ? `${tracks.length} ${tracks.length === 1 ? "track" : "tracks"}` : "",
|
||||
);
|
||||
|
||||
// ---- the glass bar's title takeover -----------------------------------
|
||||
// The record's name rides in the bar once its own title has gone under it,
|
||||
// so the bar always says where you are. An observer against the scroll
|
||||
// pane — no per-frame work, and nothing to keep in sync with a scroll
|
||||
// position.
|
||||
//
|
||||
// It watches the TITLE, not the whole identity block: the bar is standing
|
||||
// in for the title, so it should arrive as the title leaves. Watching the
|
||||
// block meant waiting for the artwork to clear too, which on a ten-track
|
||||
// record is further than the page can even scroll.
|
||||
let titlePassed = $state(false);
|
||||
function watchTitle(node) {
|
||||
const root = node.closest(".content");
|
||||
if (!root) return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => (titlePassed = !entry.isIntersecting),
|
||||
// The bar's stuck bottom sits 22px into the pane's content box.
|
||||
{ root, rootMargin: "-22px 0px 0px 0px", threshold: 0 },
|
||||
);
|
||||
io.observe(node);
|
||||
return { destroy: () => io.disconnect() };
|
||||
}
|
||||
// A new record starts at its top; coming BACK to one returns the reader to
|
||||
// where they were. The pane outlives the view swap, so without this a
|
||||
// record opened from half-way down an artist page began half-way down.
|
||||
let scrolledFor = null;
|
||||
$effect(() => {
|
||||
const id = album?.id ?? null;
|
||||
if (id == null || id === scrolledFor) return;
|
||||
scrolledFor = id;
|
||||
const pane = document.querySelector(".content");
|
||||
if (!pane) return;
|
||||
const target = consumeScroll();
|
||||
if (target == null) {
|
||||
pane.scrollTop = 0;
|
||||
} else {
|
||||
restoreScroll(pane, target);
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to (and briefly flash) the track the now-playing song link targets.
|
||||
let flashId = $state(null);
|
||||
@@ -482,37 +502,7 @@
|
||||
</script>
|
||||
|
||||
<div class="album-page" style={accentStyle} bind:this={pageEl}>
|
||||
<!-- Frosted-glass header, in parity with the artist page's .results-head
|
||||
and the Now Playing panel: the same tint over the same backdrop blur,
|
||||
pinned to the top of the pane. data-scrollhead keeps the overlay
|
||||
scrollbar below it. -->
|
||||
<div class="glassbar" class:passed={titlePassed} data-scrollhead>
|
||||
<button class="back" onclick={() => openArtistByName(album?.artist)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
{album?.artist ?? "Back"}
|
||||
</button>
|
||||
{#if album}
|
||||
<div class="where">
|
||||
{#if coverOk}
|
||||
<img src={cover} alt="" draggable="false" />
|
||||
{/if}
|
||||
<span>{album.title}</span>
|
||||
<button
|
||||
class="mini-play"
|
||||
disabled={hasUnreleased}
|
||||
title={hasUnreleased ? "Not released yet" : "Play"}
|
||||
onclick={() => streamCatalogAlbum(album)}
|
||||
>
|
||||
<Icon name="play" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if resolver.active}
|
||||
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
|
||||
{:else if resolver.error}
|
||||
<span class="rstatus error">{resolver.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<GlassBar />
|
||||
|
||||
{#if catalog.albumLoading}
|
||||
<p class="hint">Loading album…</p>
|
||||
@@ -531,19 +521,26 @@
|
||||
</div>
|
||||
<div class="identity">
|
||||
{#if coverOk}
|
||||
<img
|
||||
<button
|
||||
class="cover"
|
||||
src={cover}
|
||||
alt=""
|
||||
draggable="false"
|
||||
onerror={(event) => handleCoverError(event.currentTarget)}
|
||||
/>
|
||||
title="View artwork"
|
||||
aria-label="View artwork full screen"
|
||||
onclick={openArtwork}
|
||||
>
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
draggable="false"
|
||||
onerror={(event) => handleCoverError(event.currentTarget)}
|
||||
/>
|
||||
<span class="mark" aria-hidden="true"><Icon name="expand" size={22} /></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="disc" size={44} /></div>
|
||||
{/if}
|
||||
<div class="copy">
|
||||
<span class="kind">{(album.record_type ?? "album").toUpperCase()}</span>
|
||||
<h1 class:long={album.title.length > 22} use:watchTitle>{album.title}</h1>
|
||||
<h1 class:long={album.title.length > 22}>{album.title}</h1>
|
||||
<p class="meta">
|
||||
<button class="who" onclick={() => openArtistByName(album.artist)}>
|
||||
{#if artistPicture}
|
||||
@@ -656,10 +653,10 @@
|
||||
|
||||
<div class="tracklist">
|
||||
{#if tracks.length}
|
||||
<div class="tcols" class:has-plays={showPlays}>
|
||||
<div class="tcols" class:has-count={showCount}>
|
||||
<span class="tno">#</span>
|
||||
<span>Title</span>
|
||||
{#if showPlays}<span>Plays</span>{/if}
|
||||
{#if showCount}<span>Listeners</span>{/if}
|
||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -674,11 +671,11 @@
|
||||
<div
|
||||
id="catrack-{t.id}"
|
||||
class="trow"
|
||||
class:has-plays={showPlays}
|
||||
class:has-count={showCount}
|
||||
class:now={nowPlaying("trackId", t.id)}
|
||||
class:flash={flashId === t.id}
|
||||
class:locked
|
||||
style={showPlays ? `--t:${playHeat(t)}` : undefined}
|
||||
style={showCount ? `--t:${countHeat(t)}` : undefined}
|
||||
role="button"
|
||||
aria-disabled={locked}
|
||||
tabindex={locked ? -1 : 0}
|
||||
@@ -693,15 +690,15 @@
|
||||
<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showPlays}
|
||||
{#if showCount}
|
||||
<span
|
||||
class="tplays"
|
||||
title={t.plays == null
|
||||
? "No Last.fm play count for this track"
|
||||
: `${t.plays.toLocaleString()} Last.fm plays`}
|
||||
class="tcount"
|
||||
title={t.listeners == null
|
||||
? "No Last.fm listener count for this track"
|
||||
: `${t.listeners.toLocaleString()} Last.fm listeners`}
|
||||
>
|
||||
<span class="tplays-fill" style={`width:${playShare(t)}%`}></span>
|
||||
<span class="tplays-val">{fmtPlays(t.plays)}</span>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if locked}
|
||||
@@ -761,116 +758,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ glass bar ---- */
|
||||
/* The artist page's .results-head, unchanged: sticky at the top of the
|
||||
pane, bled -24/-28 so it spans the pane's full width and takes NO flow
|
||||
height (-24 + 46 - 22 = 0), a translucent tint over a 12px backdrop
|
||||
blur with a hairline light edge. The two headers must read as one
|
||||
object, so any change here belongs in both. */
|
||||
.glassbar {
|
||||
position: sticky;
|
||||
top: -24px;
|
||||
margin: -24px -28px -22px;
|
||||
z-index: 40;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 28px;
|
||||
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);
|
||||
}
|
||||
|
||||
.glassbar .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);
|
||||
flex: none;
|
||||
}
|
||||
.glassbar .back:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The record's name takes over the bar once its own title has gone under
|
||||
it. Hidden — not just transparent — while the title is on screen, so it
|
||||
never eats clicks meant for the back button. */
|
||||
.where {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease,
|
||||
visibility 0s linear 180ms;
|
||||
}
|
||||
.glassbar.passed .where {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
.where img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
}
|
||||
.where span {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mini-play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #08080a;
|
||||
flex: none;
|
||||
}
|
||||
.mini-play:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.mini-play:disabled {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Resolver status rides the right side, as on the artist page. */
|
||||
.rstatus {
|
||||
margin-left: auto;
|
||||
max-width: 45%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- hero banner ---- */
|
||||
.hero {
|
||||
/* The one number to tune. 1 = a 240px cover; everything in the hero is
|
||||
@@ -934,13 +821,83 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Hovering the artwork offers the full-window viewer: the whole element
|
||||
lifts (see --art-grow in global.css) under a light wash, with the expand
|
||||
mark in the middle. `transform` does not reflow, so the copy column
|
||||
beside it — which is sized to end level with this artwork — cannot be
|
||||
nudged by the hover. */
|
||||
.cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: var(--cover);
|
||||
height: var(--cover);
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
|
||||
transition:
|
||||
transform var(--art-motion),
|
||||
box-shadow var(--art-motion);
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.cover:hover,
|
||||
.cover:focus-visible {
|
||||
transform: scale(var(--art-grow));
|
||||
box-shadow: 0 30px 62px -20px rgba(0, 0, 0, 0.86);
|
||||
}
|
||||
.cover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--art-wash);
|
||||
opacity: 0;
|
||||
transition: opacity var(--art-motion);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cover:hover::after,
|
||||
.cover:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
.mark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(54px * var(--cover-scale));
|
||||
height: calc(54px * var(--cover-scale));
|
||||
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:hover .mark,
|
||||
.cover:focus-visible .mark {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cover,
|
||||
.mark {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
.cover:hover,
|
||||
.cover:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
@@ -1074,12 +1031,16 @@
|
||||
align-items: center;
|
||||
gap: 0.714em;
|
||||
}
|
||||
/* 2em is the Play/Save pill's own height (0.571em of padding either side
|
||||
of its line box). Matching it keeps the action row one height — a taller
|
||||
icon button both sat proud of the pills and pushed the copy column past
|
||||
the artwork it is supposed to end level with. */
|
||||
.iconbtn {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.429em;
|
||||
height: 2.429em;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
@@ -1270,8 +1231,8 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.tcols.has-plays,
|
||||
.trow.has-plays {
|
||||
.tcols.has-count,
|
||||
.trow.has-count {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 168px 48px;
|
||||
}
|
||||
|
||||
@@ -1374,7 +1335,7 @@
|
||||
|
||||
/* Plays: length AND colour both carry the count, so a deep cut reads short
|
||||
and cool while the record's biggest song burns the album's own accent. */
|
||||
.tplays {
|
||||
.tcount {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1384,7 +1345,7 @@
|
||||
font-size: 12.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tplays-fill {
|
||||
.tcount-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 4px;
|
||||
@@ -1395,7 +1356,7 @@
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.tplays-val {
|
||||
.tcount-val {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 8px;
|
||||
|
||||
+156
-118
@@ -28,6 +28,7 @@
|
||||
gridCover,
|
||||
tinyCover,
|
||||
albumDisambiguator,
|
||||
openCatalogAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
@@ -41,6 +42,8 @@
|
||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import GlassBar from "../components/GlassBar.svelte";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||||
|
||||
const nowPlaying = (kind, id) =>
|
||||
@@ -157,10 +160,20 @@
|
||||
// reader back to the top mid-read. The seq bumps once per open and never
|
||||
// per stage. Ask for the pane BY NAME — the same rule scrollPaneFor()
|
||||
// follows, and for the same reason.
|
||||
// …UNLESS the reader is coming BACK to this page, in which case they are
|
||||
// returning to something they had already found and the top is the one
|
||||
// place they do not want. A history move leaves its remembered offset for
|
||||
// exactly one landing; a fresh open leaves none and still starts at zero.
|
||||
$effect(() => {
|
||||
void catalog.artistOpenSeq;
|
||||
const pane = document.querySelector(".content");
|
||||
if (pane) pane.scrollTop = 0;
|
||||
if (!pane) return;
|
||||
const target = consumeScroll();
|
||||
if (target == null) {
|
||||
pane.scrollTop = 0;
|
||||
} else {
|
||||
restoreScroll(pane, target);
|
||||
}
|
||||
});
|
||||
|
||||
// THE HERO PIPELINE (Kyle, 2026-07-24 — the approved configuration,
|
||||
@@ -1156,7 +1169,7 @@
|
||||
// slot in CSS.
|
||||
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
||||
|
||||
// Where the pins sit in flow. .results-head's net flow height is
|
||||
// Where the pins sit in flow. the glass bar's net flow height is
|
||||
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
||||
// same offset the pins do — and it is `position: relative`, so
|
||||
// asking it cannot feed a sticky offset back into the measurement
|
||||
@@ -2807,6 +2820,21 @@
|
||||
openInfo = null;
|
||||
}
|
||||
|
||||
/** The open edition's artwork, in the shared full-window viewer. Uses the
|
||||
* UPGRADED cover, not the 500px grid variant the rail paints: the point of
|
||||
* the viewer is the master resolution. */
|
||||
function openRailArtwork() {
|
||||
const album = railArtAlbum ?? openAlbum ?? openFamily;
|
||||
const cover = album?.cover;
|
||||
if (!cover || artBroken[cover]) return;
|
||||
ui.artwork = {
|
||||
src: coverUpgrades[cover] ?? cover,
|
||||
local: false,
|
||||
title: album.title ?? "",
|
||||
subtitle: page?.artist?.name ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toggleExpander(family) {
|
||||
if (openFamilyKey === family.familyKey) {
|
||||
closeExpander();
|
||||
@@ -3166,7 +3194,7 @@
|
||||
// mid-flight (measured settling ~500px short). Recomputing the target
|
||||
// every frame and setting scrollTop directly is immune to both, and
|
||||
// also tracks an old expander collapsing above during row switches.
|
||||
/** The line the reveal lands a row on: clears the sticky results-head AND
|
||||
/** The line the reveal lands a row on: clears the sticky glass bar AND
|
||||
* the parked banner under it, plus breathing room. READ, not hard-coded:
|
||||
* --banner-h is a fraction of the hero now, so a constant tuned to the old
|
||||
* 124px banner would drop a clicked row underneath a taller one. Matches
|
||||
@@ -3758,17 +3786,19 @@
|
||||
// artist's biggest hit would leave every track on a quiet record flat. Zero
|
||||
// means Last.fm knew none of this tracklist, and the whole column (header
|
||||
// included) stays out of the layout rather than printing a row of dashes.
|
||||
const playsPeak = $derived(
|
||||
openTracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0),
|
||||
const countPeak = $derived(
|
||||
openTracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0),
|
||||
);
|
||||
/** Share of the album's peak, as a bar width. */
|
||||
const playShare = (track) =>
|
||||
!track.plays || !playsPeak ? 0 : Math.max(2, Math.round((track.plays / playsPeak) * 100));
|
||||
const countShare = (track) =>
|
||||
!track.listeners || !countPeak
|
||||
? 0
|
||||
: Math.max(2, Math.round((track.listeners / countPeak) * 100));
|
||||
/** Same share as a 0..1 temperature, driving the fill's colour. */
|
||||
const playHeat = (track) =>
|
||||
(!track.plays || !playsPeak ? 0 : track.plays / playsPeak).toFixed(3);
|
||||
const countHeat = (track) =>
|
||||
(!track.listeners || !countPeak ? 0 : track.listeners / countPeak).toFixed(3);
|
||||
/** Unknown is a dash, never a zero — same rule as the Popular column. */
|
||||
const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString());
|
||||
const fmtCount = (n) => (n == null ? "—" : n.toLocaleString());
|
||||
|
||||
const openAlbumPlays = $derived(openTotals?.plays ?? null);
|
||||
const openAlbumListeners = $derived(openTotals?.listeners ?? null);
|
||||
@@ -4428,19 +4458,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frosted-glass header in parity with the Now Playing panel's
|
||||
.np-head; it floats over the seam tail's mirror at the top.
|
||||
data-scrollhead keeps the overlay scrollbar below this bar. -->
|
||||
<div class="results-head" data-scrollhead>
|
||||
<button class="back" onclick={() => (ui.view = "catalog")}>
|
||||
<Icon name="chevron-left" size={16} /> Results
|
||||
</button>
|
||||
{#if resolver.active}
|
||||
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
|
||||
{:else if resolver.error}
|
||||
<span class="rstatus error">{resolver.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- The shared frosted header (src/components/GlassBar.svelte); it
|
||||
floats over the seam tail's mirror at the top. Its -24px margin is
|
||||
what the pin geometry below is measured against. -->
|
||||
<GlassBar />
|
||||
|
||||
<!-- Pure spacer now: it still reserves the room the artwork needs and
|
||||
still sets where the content begins (both from its aspect-ratio,
|
||||
@@ -4657,7 +4678,15 @@
|
||||
{/if}
|
||||
{/key}
|
||||
<div class="rail-shade"></div>
|
||||
<div class="rail-art-stage">
|
||||
<!-- The artwork is a button: it opens the
|
||||
shared full-window viewer, the same one the
|
||||
Now Playing cover opens. -->
|
||||
<button
|
||||
class="rail-art-stage"
|
||||
title="View artwork"
|
||||
aria-label="View artwork full screen"
|
||||
onclick={() => openRailArtwork()}
|
||||
>
|
||||
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
|
||||
<img class="rail-art rail-art-outgoing" src={railCover(previousRailArtAlbum.cover)} alt="" draggable="false" />
|
||||
{/if}
|
||||
@@ -4675,8 +4704,16 @@
|
||||
<div class="rail-art placeholder" class:rail-art-incoming={previousRailArtAlbumId != null}><Icon name="disc" size={40} /></div>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<h3 class="rail-title">{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</h3>
|
||||
<span class="rail-art-mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
|
||||
</button>
|
||||
<!-- and the name opens the record's own page -->
|
||||
<h3 class="rail-title">
|
||||
<button
|
||||
class="rail-title-link"
|
||||
title="Open album"
|
||||
onclick={() => openAlbumId != null && openCatalogAlbum(openAlbumId)}
|
||||
>{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</button>
|
||||
</h3>
|
||||
<p class="rail-meta">
|
||||
{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length
|
||||
? ` · ${openTracks.length} tracks`
|
||||
@@ -4773,10 +4810,10 @@
|
||||
{/if}
|
||||
{:else}
|
||||
{#if openTracks.length}
|
||||
<div class="tl-cols" class:has-plays={playsPeak > 0} in:partIn={{ delay: 30 }}>
|
||||
<div class="tl-cols" class:has-count={countPeak > 0} in:partIn={{ delay: 30 }}>
|
||||
<span class="tno">#</span>
|
||||
<span>Title</span>
|
||||
{#if playsPeak > 0}<span class="tplays-head">Plays</span>{/if}
|
||||
{#if countPeak > 0}<span class="tcount-head">Listeners</span>{/if}
|
||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -4798,8 +4835,8 @@
|
||||
}}
|
||||
<div
|
||||
class="trow"
|
||||
class:has-plays={playsPeak > 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 || ""))}<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>{/if}
|
||||
</span>
|
||||
{#if playsPeak > 0}
|
||||
<span class="tplays" title={t.plays == null ? "No Last.fm play count for this track" : `${t.plays.toLocaleString()} Last.fm plays`}>
|
||||
<span class="tplays-fill" style={`width:${playShare(t)}%`}></span>
|
||||
<span class="tplays-val">{fmtPlays(t.plays)}</span>
|
||||
{#if countPeak > 0}
|
||||
<span class="tcount" title={t.listeners == null ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`}>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
{/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
|
||||
|
||||
Reference in new issue
Block a user