forked from Keelrleek/SoulStream
Three things were wrong with opening an album, and only one of them was the one that got reported. The reported half: clicking a local record in the sidebar's history went to the old in-library album view. Local albums carry no catalog identity of any kind -- `albums.mbid` is null and is not even in the DTO -- so there was nothing to route with. openAlbumByName already existed for exactly this (the now-playing links use it, and it requires BOTH artist and title to match normalized, which is the namesake guard); it now reports whether it resolved and caches settled answers, so a second click is instant. The unreported half, and the reason "half of them are not working": every STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a string, and `catalog_album` takes a u64 -- the invoke was rejected outright with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all failed silently on click. The id is coerced in checkedAlbumPayload now, where both other string sources (page seeds) pass through too. And the part that made the first half unfixable: a record no catalog carries had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount of resolving would ever open it. Local albums now render on the SAME page, composed from the library's own data -- its tracks, durations, bitrates and the cover already on disk -- under a namespaced `lib:` id that can never collide with a Deezer one. Playback stays local: Play and any row queue the files you already have rather than sending a record you own back through the resolver. Save is not offered (it is already saved), previews are absent (there is nothing to preview), and the attribution footer credits your library rather than claiming Deezer + MusicBrainz. With that, the old view has no callers and is deleted: the album-page block, its CSS, and the members that only it used. Nothing anywhere routes to it. Verified live: all six history rows open the new page, a Home card opens it, Play queues 14 local FLACs without streaming, and the back arrow returns.
315 lines
11 KiB
Svelte
315 lines
11 KiB
Svelte
<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.
|
|
//
|
|
// MOUNTED ONCE, by App.svelte, as the first child of the scroll pane —
|
|
// never by a page. A per-view `<GlassBar />` was a different component
|
|
// instance on either side of every navigation, which cost three things:
|
|
// the entrance animation below could not tell an arriving crumb from one
|
|
// that was already there (see `seen`), the backdrop-filter layer was
|
|
// recomposited on every page change, and nothing in the bar could ever
|
|
// transition between pages. Pages it should not appear on hide it rather
|
|
// than unmount it, so its memory survives the round trip.
|
|
//
|
|
// 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";
|
|
|
|
/** `hidden` hides the bar without unmounting it — see the note above. */
|
|
let { children, hidden = false } = $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)";
|
|
|
|
// Survives navigation only because this component does: a per-page bar
|
|
// remounted with an EMPTY set, so every crumb looked new and the whole
|
|
// trail re-flew in on every page change — including on Back, where nothing
|
|
// had arrived at all.
|
|
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" class:hidden 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 {
|
|
/* --bar-h is INHERITED from the scroll pane (App.svelte), not declared
|
|
here: pages need the same number to decide whether to clear the bar
|
|
or run their artwork under it, and two copies would drift. Safe
|
|
because this component is only ever mounted inside that pane — see
|
|
the note at the top of the file.
|
|
Sticky rather than absolute, so the bar pins to the top of the pane
|
|
and stays there as the page scrolls, and `top` pins it flush against
|
|
the very top of the pane over that padding.
|
|
|
|
The negative margins reproduce an absolute bleed while keeping the
|
|
bar in normal flow, and they do TWO jobs:
|
|
|
|
margin-top -pane-pad : bleed up over the pane's top padding
|
|
(and -28 out to its left/right edges)
|
|
margin-bottom -bar-h : consume no flow height of its own, and
|
|
cancel the pane's top padding for what follows
|
|
|
|
so the net flow contribution is -pane-pad, and the PAGE's box after
|
|
it starts flush with the top of the pane — which is what lets a hero
|
|
run underneath the bar rather than beginning below it. Each page
|
|
under the bar restores its own content inset with
|
|
`padding-top: var(--pane-pad)`; see the note by BAR_VIEWS in
|
|
App.svelte.
|
|
|
|
This used to come from margin collapsing instead: the bar was each
|
|
page's first child, and its negative top margin collapsed through the
|
|
page root and pulled it up. Hoisting the bar out ended the collapse
|
|
and dropped every artist-page hero layer 24px (the `.hero-bg` stack
|
|
is absolutely positioned against `.artist-page`), so the geometry is
|
|
stated here rather than emerging from where the bar happens to sit.
|
|
|
|
All of these are tied to the pane's own padding — change one and the
|
|
seam opens. */
|
|
position: sticky;
|
|
top: calc(-1 * var(--pane-pad));
|
|
margin: calc(-1 * var(--pane-pad)) -28px calc(-1 * var(--bar-h));
|
|
/* 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: var(--bar-h);
|
|
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;
|
|
}
|
|
|
|
/* `display: none` rather than an {#if}: the element leaves the layout and
|
|
measures zero (scrollbar.js reads this bar's height to start the thumb
|
|
below it, and gets 0 here — correct for a page with no bar), while the
|
|
COMPONENT stays alive and keeps its crumb memory. */
|
|
.glassbar.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.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>
|