sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane

This commit is contained in:
KeelrLeek committed 2026-07-31 18:21:04 -05:00
1 parent 606250c361
commit eba29824c9
12 files changed
+1595 -281

No files matched your search

+646 -174
View File
@@ -1,5 +1,19 @@
<script>
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar, goToView } from "../lib/state.svelte.js";
import {
app,
ui,
network,
player,
downloads,
playlistStore,
recentsStore,
refreshPlaylists,
refreshRecents,
toggleSidebar,
goToView,
openCatalogAlbum,
getArt,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -10,82 +24,242 @@
goToView(id, id !== "library" ? { albumId: null } : {});
}
async function newPlaylist() {
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 });
}
function openPlaylist(id) {
const name = playlistStore.playlists.find((p) => p.id === id)?.name;
goToView("playlist", { playlistId: id, label: name ?? "Playlist" });
}
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}` });
}
rows.push({ kind: "album", album: a, key: `a:${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;
}
function openRecent(a) {
if (a.kind === "catalog" && a.catalog_album_id) {
// The catalog page, not the old library album view — streamed
// records live in the catalog. The seed paints title/artist/cover
// immediately instead of flashing a skeleton.
openCatalogAlbum(a.catalog_album_id, {
seed: {
id: a.catalog_album_id,
title: a.title,
artist: a.artist,
cover: a.art_url ?? null,
},
});
return;
}
goToView("library", { albumId: a.album_id });
}
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<div class="sb-top">
<!-- One element, outside the list, so it never fades with the content. -->
<div class="gbar">
<button
class="collapse-btn"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
class="status"
data-state={app.connection}
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
<Icon name="chevron-left" size={20} />
<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>
<ul>
<!-- 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)}
<li>
<button
class="nav-item"
class:active={ui.view === item.id}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="icon"><Icon name={item.icon} size={22} /></span>
<span class="label">{item.label}</span>
</button>
</li>
{/each}
</ul>
<div class="playlists">
<div class="pl-head">
<span class="label">Playlists</span>
<button class="pl-new" title="New playlist" onclick={newPlaylist}>
<Icon name="plus" size={16} />
<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>
</div>
<div class="pl-list">
{#each playlistStore.playlists as pl (pl.id)}
<button
class="pl-item"
class:active={ui.view === "playlist" && ui.playlistId === pl.id}
onclick={() => openPlaylist(pl.id)}
>
<span class="pl-name">{pl.name}</span>
<span class="pl-count">{pl.track_count}</span>
</button>
{/each}
</div>
</div>
{/each}
<div class="footer">
{#if app.connection === "online" && network.shareFiles > 0}
<div class="share-info" title="Files you share with the network">
sharing {network.shareFiles} files
</div>
{/if}
<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={() => openRecent(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>
@@ -94,184 +268,482 @@
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
display: flex;
flex-direction: column;
padding: 12px 8px;
position: relative;
overflow: hidden;
}
ul {
list-style: none;
}
.sb-top {
/* ── the glass bar ── */
.gbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
justify-content: flex-end;
padding: 0 4px 6px;
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);
}
.collapse-btn {
width: 30px;
height: 30px;
border-radius: 6px;
.gicon {
position: absolute;
right: 18px;
top: 9px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
flex: 0 0 auto;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s;
z-index: 6;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 120ms,
color 120ms;
}
.collapse-btn:hover {
color: var(--text-primary);
.gicon:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
/* Label collapse: fades and zeroes width so icons stay put. */
.label {
white-space: nowrap;
overflow: hidden;
max-width: 160px;
opacity: 1;
transition: opacity 0.15s ease, max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---- collapsed icon-rail state ---- */
.sidebar.collapsed .sb-top {
justify-content: center;
}
.sidebar.collapsed .collapse-btn {
.sidebar.collapsed .gicon {
transform: rotate(180deg);
}
.sidebar.collapsed .label {
opacity: 0;
max-width: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
.sidebar.collapsed .pl-head {
justify-content: center;
padding: 0 0 8px;
}
.sidebar.collapsed .pl-list {
display: none;
}
.sidebar.collapsed .footer {
opacity: 0;
}
.playlists {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.pl-head {
/* ── 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;
justify-content: space-between;
padding: 0 12px 8px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
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;
}
.pl-new {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 16px;
/* 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;
}
.pl-new:hover {
color: var(--text-primary);
background: var(--bg-press);
.av {
position: relative;
flex: none;
width: var(--av);
height: var(--av);
}
.pl-list {
overflow-y: auto;
flex: 1;
/* 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;
}
.pl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.pfp {
position: absolute;
inset: 0;
width: 100%;
padding: 7px 12px;
border-radius: 5px;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
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;
}
}
.pl-item:hover {
color: var(--text-primary);
.status .tx {
min-width: 0;
display: flex;
flex-direction: column;
}
.pl-item.active {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pl-name {
.status .nm {
font-size: 12.5px;
font-weight: 600;
color: #fff;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-count {
font-size: 11px;
.status .sub {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
flex-shrink: 0;
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: 14px;
gap: 11px;
width: 100%;
padding: 10px 12px;
height: 42px;
padding: 0 6px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
transition: color 0.15s;
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);
}
.nav-item.active {
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);
}
.icon {
width: 22px;
/* 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;
}
.footer {
padding: 8px 4px;
/* 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);
}
.share-info {
padding: 2px 8px;
font-size: 11px;
.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>