Files
SoulStream/src/components/Sidebar.svelte
T
KeelrLeekandClaude Opus 5 b173f60085 search: the results page, the typeahead, and a session that survives a reload
The page is the signed-off prototype built out: floating rounded hero, inline
top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all
seven states — results, searching, no results, one-kind-only, library-empty,
provider-unreachable, first visit. MusicBrainz records are merged in silently
behind the Deezer rows.

The top result is ranked by POPULARITY, not by kind: each kind puts up its most
popular exact match and the winner is whichever holds the largest share of its
own kind's leader. A raw fans-vs-rank comparison was tried first and handed
"panda bear" to an Owl City song.

Two provenance traps are paid for here. Deezer's /search/album returns no
release_date at all, so sorting newest-first ordered by PROVENANCE rather than
by date; dates are backfilled from the artist overview, resolved PER ALBUM
ARTIST because Deezer's artist search answers nothing for "weezer blue album".
And the typeahead holds its keyboard selection as the row's KEY rather than its
index, because the MusicBrainz supplement lands ~600ms later and re-orders the
album group under the cursor.

The hero's middle third is hidden on the width of the PANE, not the window. The
sidebar and the now-playing rail take ~490px between them, so a 1504px window
can leave the hero 1012px — a window-width media query cannot see that, and the
"latest release" card ended up sheared down to 26px of itself. The budget is
written out at the @container rule.

Session restore rounds it off: reload or restart returns to the same place with
the same track, everything but the transport coming from localStorage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:03:45 -05:00

740 lines
23 KiB
Svelte

<script>
import {
app,
ui,
network,
player,
downloads,
playlistStore,
recentsStore,
refreshPlaylists,
refreshRecents,
toggleSidebar,
goToView,
openRecentAlbum,
getArt,
} 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) {
goToView(id, id !== "library" ? { albumId: null } : {});
}
async function newPlaylist(e) {
e.stopPropagation(); // the + rides the Playlists row; don't also navigate
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
const items = [
{ id: "home", label: "Home", icon: "home" },
{ id: "search", label: "Search", icon: "search" },
{ id: "library", label: "Your Library", icon: "library" },
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "settings", label: "Settings", icon: "settings" },
{ id: "playlists", label: "Playlists", icon: "playlist" },
];
// ---- the Soulseek status, moved here out of the title bar ----
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
// Two letters of the username, so the tile reads as an identity before any
// portrait exists. `app.avatarUrl` is the hook the PFP feature will fill;
// until then the <img> has no src and the initials show through.
const initials = $derived(
(app.username || "").replace(/[^a-z0-9]/gi, "").slice(0, 2).toLowerCase() || "··",
);
// ---- recently played ----
// Day buckets are computed from local calendar dates, not from elapsed
// seconds: something played at 00:30 is "Today", not "8 hours ago".
function dayKey(unixSecs) {
const d = new Date(unixSecs * 1000);
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const t = d.getTime();
if (t >= midnight) return "Today";
if (t >= midnight - 86400000) return "Yesterday";
return "Earlier";
}
// One flat list with day markers folded in, so the markup is a single pass
// and there is no nesting to keep aligned.
const recentRows = $derived.by(() => {
const rows = [];
let day = null;
for (const a of recentsStore.albums) {
const k = dayKey(a.last_played_at);
if (k !== day) {
day = k;
rows.push({ kind: "day", label: k, key: `d:${k}` });
}
// Two id realms, so the key must carry the realm: catalog rows
// have album_id NULL, and keying them all `a:null` was a
// duplicate-key crash that silently killed this whole list the
// moment TWO streamed albums entered history (2026-08-01).
rows.push({
kind: "album",
album: a,
key: a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`,
});
}
return rows;
});
// Covers resolve asynchronously through the shared art cache; keep the
// resolved data URLs keyed by path so a re-render never re-requests.
let artUrls = $state({});
$effect(() => {
for (const a of recentsStore.albums) {
const p = a.art_path;
if (!p || artUrls[p] !== undefined) continue;
artUrls[p] = null;
getArt(p).then((u) => (artUrls[p] = u)).catch(() => {});
}
});
// A catalog row's cover is already a URL and needs no read; a library row's
// is a file path that has to go through the art cache.
function coverOf(a) {
return a.art_url ?? (a.art_path ? artUrls[a.art_path] : null);
}
// The same set of states the title bar's ring counts as "in flight".
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const downloadCount = $derived(
Object.values(downloads.items).filter((d) => DL_ACTIVE.has(d.state)).length,
);
// Which record is playing, so its row can carry the equaliser. The queue
// item carries the cover path the play was recorded against — matching on
// artist/album NAME would be exactly the identity mistake the app forbids,
// so a row lights up only when its art path is the one playing.
const nowItem = $derived(player.queue?.[player.queueIndex] ?? null);
const playingArt = $derived(nowItem?.art ?? null);
const playingCatalogId = $derived(
player.catalogRef?.albumId != null ? String(player.catalogRef.albumId) : null,
);
// Anchored on ids, never on a name.
function isPlaying(a) {
if (a.kind === "catalog") {
return !!a.catalog_album_id && a.catalog_album_id === playingCatalogId;
}
return !!a.art_path && a.art_path === playingArt;
}
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<!-- One element, outside the list, so it never fades with the content. -->
<div class="gbar">
<button
class="status"
data-state={app.connection}
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
<span class="avcol">
<span class="av">
<span class="avi">
{#if app.avatarUrl}
<img class="pfp" src={app.avatarUrl} alt="" draggable="false" />
{/if}
{initials}
</span>
</span>
</span>
<span class="tx">
<span class="nm">
{#if app.connection === "online"}{app.username}
{:else if app.connection === "connecting"}Signing in
{:else}Not connected{/if}
</span>
<span class="sub">
{#if app.connection === "online"}{network.shareFiles} files shared
{:else if app.connection === "connecting"}one moment
{:else}click to sign in{/if}
</span>
</span>
</button>
</div>
<!-- right:18 puts it dead centre at 64px — (64-28)/2 = 18 — so the
collapsed position falls out of the geometry, not a second rule. -->
<button
class="gicon"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
>
<!-- 16, not 20: measured against NowPlaying's .np-icon, whose button is
the same 28x28 radius-6 box but carries a 16px glyph. -->
<Icon name="chevron-left" size={16} />
</button>
<!-- ONE list. Not two views cross-fading: two views means two copies of
every icon and cover painting at once through the whole crossfade, and
any difference between the copies reads as doubling. Here nothing fades
but text, and no glyph can move — every x comes from padding, which
does not depend on the panel's width. -->
<div class="view">
{#each items as item (item.id)}
<button
class="nav-item"
class:active={ui.view === item.id}
class:has-count={item.id === "downloads" && downloadCount > 0}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="ic"><Icon name={item.icon} size={22} /></span>
<span class="lbl">{item.label}</span>
{#if item.id === "downloads" && downloadCount > 0}
<span class="bdg">{downloadCount > 99 ? "99+" : downloadCount}</span>
{/if}
{#if item.id === "playlists"}
<span
class="rowplus"
role="button"
tabindex="0"
title="New playlist"
onclick={newPlaylist}
onkeydown={(e) => e.key === "Enter" && newPlaylist(e)}
>
<Icon name="plus" size={13} />
</span>
{/if}
</button>
{/each}
<div class="rule"></div>
{#each recentRows as row (row.key)}
{#if row.kind === "day"}
<div class="day"><span>{row.label}</span></div>
{:else}
<button
class="rec"
class:on={isPlaying(row.album)}
title="{row.album.title} — {row.album.artist}"
onclick={() => openRecentAlbum(row.album)}
>
{#if coverOf(row.album)}
<img src={coverOf(row.album)} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="music" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
{#if isPlaying(row.album)}
<span class="eq"><i></i><i></i><i></i></span>
{/if}
{row.album.title}
</span>
<span class="s">{row.album.artist}</span>
</span>
</button>
{/if}
{/each}
</div>
</nav>
<style>
.sidebar {
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
position: relative;
overflow: hidden;
}
/* ── the glass bar ── */
.gbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
align-items: center;
padding: 0 11px;
z-index: 5;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(12px) saturate(1.7);
-webkit-backdrop-filter: blur(12px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
.gicon {
position: absolute;
right: 18px;
top: 9px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
z-index: 6;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 120ms,
color 120ms;
}
.gicon:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.sidebar.collapsed .gicon {
transform: rotate(180deg);
}
/* ── the Soulseek status ──
Not painted with --accent: the accent is crossfaded from the playing
album's art, so "online" would change colour with the music and currently
renders red. Green means connected, whatever is playing. */
.status {
--av: 26px;
display: flex;
align-items: center;
gap: 11px;
min-width: 0;
max-width: 175px;
flex: 1;
/* 11 (bar padding) + 1 = 12, the image column's left edge */
padding: 2px 10px 2px 1px;
border-radius: 9px;
text-align: left;
transition:
background 0.14s,
opacity 0.26s ease;
}
.status:hover {
background: rgba(255, 255, 255, 0.07);
}
.sidebar.collapsed .status {
opacity: 0;
pointer-events: none;
}
/* A 40px slot with its left edge at 12 — the same construction as a nav
icon — so the avatar is centred on x=32 at ANY size, in line with every
icon and cover below it. */
.avcol {
width: 40px;
flex: none;
display: grid;
place-items: center;
}
.av {
position: relative;
flex: none;
width: var(--av);
height: var(--av);
}
/* Album art is 40px at radius 4 — a ratio of exactly .1. Deriving the
radius from the size holds that ratio at any size, so the avatar reads as
the same object as a cover and a real photo drops straight in. */
.avi {
position: absolute;
inset: 0;
border-radius: calc(var(--av) * 0.1);
overflow: hidden;
display: grid;
place-items: center;
background: linear-gradient(150deg, #3c3c45, #24242a);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
font-size: calc(var(--av) * 0.4);
font-weight: 700;
color: #d8d8e0;
}
.pfp {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* The state dot hangs off the corner, so it rides .av (unclipped) and never
.avi (which clips, for the photo). */
.av::after {
content: "";
position: absolute;
right: -2px;
bottom: -2px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #7c7c85;
box-shadow: 0 0 0 2.5px #131316;
}
.status[data-state="online"] .av::after {
background: #3ecf7e;
}
.status[data-state="connecting"] .av::after {
background: #e8a33d;
animation: breathe 1.15s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.status .tx {
min-width: 0;
display: flex;
flex-direction: column;
}
.status .nm {
font-size: 12.5px;
font-weight: 600;
color: #fff;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status .sub {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
line-height: 1.2;
white-space: nowrap;
}
.status[data-state="online"] .sub {
color: #3ecf7e;
}
.status[data-state="connecting"] .sub {
color: #e8a33d;
}
/* ── the list ──
The 46px inset is the bar's height; the extra 8 stops the first row's
highlight sitting flush against it. */
.view {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 54px 6px 12px 6px;
scrollbar-width: none;
}
.view::-webkit-scrollbar {
width: 0;
display: none;
}
/* EVERY GLYPH SHARES ONE CENTRE, x=32, AND NONE OF THEM MOVE.
row starts at 6 (view pad) + 6 (row pad) -> content at 12
cover 40px at 12 -> 12..52, centre 32
icon 22px centred in a 40px slot at 12 -> 21..43, centre 32
At 64px the panel's centre is also 32, so the rail centring falls out of
the same arithmetic — it is not a second rule. These x values come from
padding, which does not depend on the panel's width, so they are
invariant through the whole collapse. Verified at 232/200/168/136/104/80/64. */
.nav-item,
.rec {
margin-bottom: 5px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 42px;
padding: 0 6px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
font-size: 14px;
white-space: nowrap;
transition:
color 0.15s,
background 0.15s;
}
.nav-item .ic {
width: 40px;
flex: none;
display: grid;
place-items: center;
position: relative;
}
/* Fixed width so the label never reflows or re-ellipsises as the panel
narrows — it fades, and what is left is clipped by the panel. 118 lands
the count and the + a clear 12-16px inside the highlight. */
.lbl {
width: 118px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.26s ease;
}
.nav-item:hover {
color: var(--text-primary);
}
/* --bg-highlight, matching NowPlaying's .row:hover exactly. The frosted
version existed to let the album wash run underneath it; with the wash
gone it has nothing to reveal, and an opaque highlight is what the right
panel already uses. Active is distinguished by text colour, which is how
this sidebar has always done it. */
.nav-item:hover,
.rec:hover,
.nav-item.active,
.rec.on {
background: var(--bg-highlight);
}
.nav-item.active,
.rec.on {
color: var(--text-primary);
}
/* margin-left:0, never auto — auto depends on the row's width, so the count
and the + would slide across the panel while fading out. */
.bdg,
.rowplus {
margin-left: 0;
transition: opacity 0.26s ease;
}
.bdg {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
flex: none;
font-variant-numeric: tabular-nums;
background: rgba(255, 255, 255, 0.15);
color: #fff;
backdrop-filter: blur(8px) saturate(1.4);
-webkit-backdrop-filter: blur(8px) saturate(1.4);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
}
/* Collapsed there is no room for a numeral, and an 18px one on the glyph
covers it. An 8px dot at (44..52) clears the glyph, which ends at 43. */
.nav-item.has-count .ic::after {
content: "";
position: absolute;
top: 5px;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .nav-item.has-count .ic::after {
opacity: 1;
}
/* Draws nothing until you point at the row — the right weight for a
secondary action, and it stops a grey dot floating on the wash. */
.rowplus {
width: 22px;
height: 22px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
background: none;
color: transparent;
transition:
opacity 0.26s ease,
background 0.13s,
color 0.13s,
transform 0.13s;
}
.nav-item:hover .rowplus {
color: var(--text-secondary);
}
.rowplus:hover {
background: rgba(255, 255, 255, 0.17);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
transform: scale(1.08);
}
.rule {
height: 1px;
background: var(--border);
margin: 12px 6px;
}
/* No "Recently played" eyebrow — the rule runs straight into the history.
Collapsed, the day word leaves a hairline so the list keeps its
structure instead of becoming an undifferentiated column. */
.day {
position: relative;
height: 28px;
display: flex;
align-items: flex-end;
padding: 0 6px 5px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
white-space: nowrap;
}
.day span {
transition: opacity 0.26s ease;
}
.day::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
height: 1px;
background: rgba(255, 255, 255, 0.09);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .day::after {
opacity: 1;
}
.rec {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 52px;
padding: 0 6px;
border-radius: 6px;
text-align: left;
transition: background 0.15s;
}
.rec img,
.art-placeholder {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex: none;
transition: transform var(--art-motion);
}
.art-placeholder {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.rec:hover img {
transform: scale(var(--art-grow));
}
.rec .tx {
width: 157px;
flex: none;
overflow: hidden;
transition: opacity 0.26s ease;
}
.rec .n {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.rec:hover .n {
color: var(--text-primary);
}
.rec.on .n {
color: var(--accent-hover);
}
.rec .s {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
/* Only text fades. Nothing here changes a position. */
.sidebar.collapsed .lbl,
.sidebar.collapsed .rec .tx,
.sidebar.collapsed .day span,
.sidebar.collapsed .nav-item > .bdg,
.sidebar.collapsed .rowplus {
opacity: 0;
}
.eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 10px;
flex: none;
}
.eq i {
width: 2.5px;
background: var(--accent-hover);
border-radius: 1px;
animation: eq 0.9s ease-in-out infinite;
}
.eq i:nth-child(2) {
animation-delay: -0.2s;
}
.eq i:nth-child(3) {
animation-delay: -0.55s;
}
@keyframes eq {
0%,
100% {
height: 35%;
}
50% {
height: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.gicon,
.lbl,
.rec .tx,
.day span,
.bdg,
.rowplus {
transition: none;
}
.eq i,
.status[data-state="connecting"] .av::after {
animation: none;
}
}
</style>