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:
KeelrLeek committed 2026-07-28 16:40:31 -05:00
1 parent ea9a040d09
commit 7a517dca59
11 files changed
+1154 -403

No files matched your search

+18 -24
View File
@@ -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}
+268
View File
@@ -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>
+1
View File
@@ -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
+92 -40
View File
@@ -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;
+8 -7
View File
@@ -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 = [