forked from Keelrleek/SoulStream
sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane
This commit is contained in:
1 parent
606250c361
commit
eba29824c9
12 files changed
+1595
-281
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user