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

+4
View File
@@ -15,6 +15,7 @@
import Downloads from "./views/Downloads.svelte";
import Settings from "./views/Settings.svelte";
import Playlist from "./views/Playlist.svelte";
import Playlists from "./views/Playlists.svelte";
import CatalogSearch from "./views/CatalogSearch.svelte";
import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte";
@@ -24,6 +25,7 @@
autoConnect,
refreshLibrary,
refreshPlaylists,
refreshRecents,
} from "./lib/state.svelte.js";
import { floatScroll } from "./lib/scrollbar.js";
@@ -34,6 +36,7 @@
downloads: Downloads,
settings: Settings,
playlist: Playlist,
playlists: Playlists,
catalog: CatalogSearch,
catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum,
@@ -56,6 +59,7 @@
autoConnect();
refreshLibrary();
refreshPlaylists();
refreshRecents();
</script>
+1
View File
@@ -13,6 +13,7 @@
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
playlist: '<path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/>',
minus: '<path d="M5 12h14"/>',
square: '<rect width="16" height="16" x="4" y="4" rx="2"/>',
restore: '<rect width="12" height="12" x="4" y="8" rx="2"/><path d="M8 4h10a2 2 0 0 1 2 2v10"/>',
+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>
+20 -39
View File
@@ -189,24 +189,31 @@
}
}
// onResized fires on EVERY frame of a window drag, and answering each one
// with isMaximized() is an IPC round trip to the Rust side per frame — on
// the main thread, while the compositor is already busy re-rasterizing every
// backdrop-filter in the app at the new size. Maximized state cannot change
// part-way through a drag anyway: it only changes at the end of one, or on a
// discrete button press. So answer once, after the box settles.
let resizeSettle = null;
function syncMaximizedSoon() {
clearTimeout(resizeSettle);
resizeSettle = setTimeout(syncMaximized, 150);
}
$effect(() => {
let unlisten = null;
syncMaximized();
api.win
.onResized(syncMaximized)
.onResized(syncMaximizedSoon)
.then((f) => (unlisten = f))
.catch(() => {});
return () => unlisten?.();
return () => {
clearTimeout(resizeSettle);
unlisten?.();
};
});
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
</script>
<header class="titlebar" data-tauri-drag-region>
@@ -214,13 +221,8 @@
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<button
class="status {app.connection}"
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
● {app.connection === "online" ? app.username : app.connection}
</button>
<!-- The Soulseek status lives in the sidebar's glass bar now: it is an
account presence, and it belongs next to the library it feeds. -->
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
@@ -336,8 +338,7 @@
/* The visible strip above the panels is the 30px bar row plus the 8px
grid gap; nudge the left cluster down so it centers optically in that
full strip. The window controls stay flush with the top edge. */
.brand,
.status {
.brand {
position: relative;
top: 4px;
}
@@ -361,26 +362,6 @@
font-size: 13px;
}
.status {
font-size: 12px;
color: var(--text-muted);
padding: 3px 9px;
border-radius: 10px;
}
.status:hover {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.status.online {
color: var(--accent-hover);
}
.status.connecting {
color: #e8a33d;
}
/* Centered on the window regardless of the left cluster's width; the
same +4px optical nudge as the brand. Dragging still works on the
uncovered bar either side. */
+24 -1
View File
@@ -67,7 +67,25 @@ export const playerControl = (action, value, rate) =>
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums");
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
export const recordPlay = (artist, title) => invoke("record_play", { artist, title });
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without
// it the row still records, but with no album — so it cannot appear in recents.
// `cat` carries catalog identity for a STREAMED play, which never touches the
// local library: { albumId, albumTitle, albumArtist, coverUrl }.
export const recordPlay = (artist, title, path, cat = {}) =>
invoke("record_play", {
artist,
title,
path: path ?? null,
catalogAlbumId: cat.albumId != null ? String(cat.albumId) : null,
albumTitle: cat.albumTitle ?? null,
albumArtist: cat.albumArtist ?? null,
coverUrl: cat.coverUrl ?? null,
});
// Destructive, not undoable: wipes `plays` only. Returns rows removed.
export const clearPlayHistory = () => invoke("clear_play_history");
export const playRowCount = () => invoke("play_row_count");
export const recentAlbums = (limit, minSecs) =>
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// The WebView blocks target="_blank"; external links open via the backend.
@@ -110,6 +128,11 @@ export const catalogArtistHeroSeed = (artistId) =>
invoke("catalog_artist_hero_seed", { artistId });
export const catalogArtistOverview = (artistId) =>
checkedArtistPayload("catalog_artist_overview", artistId);
// Popular's fast lane: the Last.fm-ordered rows with listener counts, waiting
// on nothing but the linked MBID and one Last.fm call. Patched over the
// overview's budgeted (cold: unranked) list; the full page still lands last.
export const catalogArtistPopular = (artistId) =>
invoke("catalog_artist_popular", { artistId });
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
+63 -1
View File
@@ -105,6 +105,27 @@ export async function refreshPlaylists() {
}
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
export const RECENTS_FLOOR_SECS = 45;
const RECENTS_LIMIT = 20;
export const recentsStore = $state({
albums: [], // [{ album_id, artist, title, art_path, last_played_at }]
loaded: false,
});
export async function refreshRecents() {
try {
recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS);
} catch {
// backend not available
} finally {
recentsStore.loaded = true;
}
}
const MIB = 1024 * 1024;
function isPending(value) {
@@ -2071,6 +2092,26 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
})
.catch(() => {});
// Popular's fast lane. The overview paints the section immediately (its
// supplements are budgeted), so a cold page's rows start in Deezer's
// order with no listener counts. The real list — Last.fm order and
// counts — needs none of the dates/recordings work the full page also
// waits for, so it is asked for on its own and patched in the moment it
// lands (~2s cold, instant warm). Last writer wins against the overview:
// warm, the two lists are computed from identical inputs and cannot
// differ. The full page stays authoritative once it arrives, and a
// replayed page already shows the last visit's FINAL list — patching a
// pool-shaped list over either would be a downgrade.
void api.catalogArtistPopular(artistId)
.then((tracks) => {
if (!active() || !tracks?.length) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
if (current.art_preview === false || current.replayed) return;
catalog.artistPage = { ...current, popular_tracks: tracks };
})
.catch(() => {});
try {
// Overview, identity art, and the first hero all remain immediate.
// Give rapid back-to-back navigation a brief chance to settle before
@@ -4116,7 +4157,12 @@ function listenTick(positionSecs) {
if (!title || !artist || artist.includes("…")) return;
const key = `${artist}\u0000${title}`;
if (listenSession?.key !== key) {
// The outgoing track's final seconds go in here — which is the moment
// its album can cross the floor. Re-read once that write has landed;
// waiting only on the fixed timer below meant a track that crossed 45s
// late never showed up until something else happened to refresh.
listenFlush();
setTimeout(refreshRecents, 1500);
listenSession = { key, artist, title, pending: 0, playIdP: null };
listenLastPos = null;
}
@@ -4125,8 +4171,24 @@ function listenTick(positionSecs) {
if (delta <= 0 || delta > 3) return;
listenSession.pending += delta;
if (!listenSession.playIdP && listenSession.pending >= 1) {
listenSession.playIdP = api.recordPlay(artist, title);
// Two possible anchors, both exact ids, never a name. A local track is
// tied to its library album through tracks.path; a STREAMED one — which
// is most listening here — through the catalog album id, carrying its
// own title/artist/cover because there is no local row to read them
// back from.
const q = player.queue?.[player.queueIndex] ?? null;
listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, {
albumId: player.catalogRef?.albumId ?? null,
albumTitle: q?.album ?? null,
albumArtist: q?.artist ?? null,
coverUrl: q?.artUrl ?? null,
});
listenSession.playIdP.catch(() => {});
// The row is opened at ~1s but only earns its place in recents once it
// passes the floor, so re-read after the track has had time to count.
listenSession.playIdP
.then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000))
.catch(() => {});
}
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
}
+132
View File
@@ -0,0 +1,132 @@
<script>
// The sidebar's Playlists row needs a destination. Playlists used to be
// listed in the sidebar itself; with the sidebar showing listening history
// instead, this is where they live.
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./../components/Icon.svelte";
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
</script>
<div class="page">
<header>
<h1>Playlists</h1>
<button class="new" onclick={newPlaylist}>
<Icon name="plus" size={15} /> New playlist
</button>
</header>
{#if playlistStore.playlists.length === 0}
<p class="empty">No playlists yet.</p>
{:else}
<ul>
{#each playlistStore.playlists as pl (pl.id)}
<li>
<button class="row" onclick={() => open(pl)}>
<span class="ic"><Icon name="playlist" size={18} /></span>
<span class="name">{pl.name}</span>
<span class="count">
{pl.track_count}
{pl.track_count === 1 ? "track" : "tracks"}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.new {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-highlight);
transition:
background 0.14s,
color 0.14s;
}
.new:hover {
background: var(--bg-press);
color: var(--text-primary);
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.row {
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 11px 12px;
border-radius: 7px;
text-align: left;
color: var(--text-secondary);
transition: background 0.14s;
}
.row:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.ic {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 5px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 12px;
color: var(--text-muted);
flex: none;
}
.empty {
color: var(--text-muted);
font-size: 13px;
}
</style>