1020 lines
36 KiB
Svelte
1020 lines
36 KiB
Svelte
<script>
|
|
import {
|
|
app,
|
|
ui,
|
|
network,
|
|
player,
|
|
downloads,
|
|
playlistStore,
|
|
recentsStore,
|
|
refreshPlaylists,
|
|
refreshRecents,
|
|
toggleSidebar,
|
|
goToView,
|
|
openRecentAlbum,
|
|
getArt,
|
|
playlistCoverFor,
|
|
} from "../lib/state.svelte.js";
|
|
import * as api from "../lib/api.js";
|
|
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
|
|
import Icon from "./Icon.svelte";
|
|
|
|
// ── the periscope (dark glass only) ─────────────────────────────────
|
|
// The bar's backdrop-filter black-flattens over frost (see the CSS note),
|
|
// so under glass the bar blurs a CLONE of the list instead: the original
|
|
// rows are masked out of the bar band, and a cloneNode copy — filtered
|
|
// with a normal blur, which composites fine over frost — paints them
|
|
// back as fog, scroll-synced. Approved from the side-by-side test page
|
|
// (research/glass-periscope) 2026-08-26.
|
|
let glassy = $state(document.documentElement.classList.contains("glass"));
|
|
let viewEl = $state(null);
|
|
let periEl = $state(null);
|
|
|
|
$effect(() => {
|
|
const mo = new MutationObserver(() => {
|
|
glassy = document.documentElement.classList.contains("glass");
|
|
});
|
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
return () => mo.disconnect();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!glassy || !viewEl || !periEl) return;
|
|
const view = viewEl;
|
|
const peri = periEl;
|
|
let pending = 0;
|
|
let recloneGen = 0;
|
|
let lastRecloneMs = 4;
|
|
// Consecutive idle callbacks refused for a too-thin slice — see the
|
|
// bounded gate in queueReclone.
|
|
let thinSlices = 0;
|
|
const sync = () => {
|
|
markAppScroll();
|
|
const c = peri.firstElementChild?.firstElementChild;
|
|
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
|
};
|
|
const reclone = () => {
|
|
// Blur on an OVERSCAN wrapper, 48px past the clip on every side:
|
|
// a gaussian fades toward transparent at its element's bounds,
|
|
// and with the bounds out there the falloff is clipped away
|
|
// instead of vignetting the band edges (found on the np head —
|
|
// same physics here). The clone keeps the scoping class (every
|
|
// scoped rule, collapsed mode included, styles it identically)
|
|
// and is pinned inside by margins at its measured width.
|
|
// height 142 = overscan + band + overscan: the filter rasters
|
|
// this box, never the full cloned list.
|
|
// BUILD, detached, measured — the duration feeds the next
|
|
// debounce delay (see GlassBar; the old node census existed
|
|
// only to guess it).
|
|
const t0 = performance.now();
|
|
const gen = ++recloneGen;
|
|
const wrap = document.createElement("div");
|
|
wrap.style.cssText =
|
|
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
|
|
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
|
|
"pointer-events:none;";
|
|
const c = view.cloneNode(true);
|
|
c.style.position = "relative";
|
|
c.style.margin = "48px 0 0 48px";
|
|
c.style.width = view.clientWidth + "px";
|
|
c.style.height = "auto";
|
|
c.style.overflow = "visible";
|
|
c.querySelectorAll("canvas").forEach((n) => n.remove());
|
|
wrap.appendChild(c);
|
|
lastRecloneMs = performance.now() - t0;
|
|
// ATTACH in its own frame (see GlassBar): the style+layout
|
|
// flush of the whole clone never shares a task with its
|
|
// construction; stale builds and resumed scrolling requeue.
|
|
requestAnimationFrame(() => {
|
|
if (gen !== recloneGen) return;
|
|
if (!appScrollIdleFor(1000)) return queueReclone();
|
|
const fresh = !peri.firstElementChild;
|
|
if (fresh) {
|
|
wrap.style.opacity = "0";
|
|
wrap.style.transition = "opacity 160ms ease";
|
|
}
|
|
peri.replaceChildren(wrap);
|
|
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
|
|
sync();
|
|
});
|
|
};
|
|
// Trailing debounce; no `class` in the filter — hover flips were
|
|
// re-cloning per mouse move, and the fog doesn't read hover states.
|
|
// Delay scales with list size and the clone runs at idle (see the
|
|
// GlassBar note: big pages were janking frames with reclone tasks).
|
|
const queueReclone = () => {
|
|
clearTimeout(pending);
|
|
const delay = Math.min(600, 120 + lastRecloneMs * 3);
|
|
pending = setTimeout(() => {
|
|
// Never clone mid-scroll, never while hidden (see GlassBar).
|
|
// APP-WIDE scroll recency (scrollbar.js note), a full second
|
|
// of it, re-checked inside the idle callback with no forcing
|
|
// timeout, and the callback yields on a thin idle slice:
|
|
// this pane's own churn must not clone while the CONTENT
|
|
// pane scrolls.
|
|
if (document.hidden) return;
|
|
if (!appScrollIdleFor(1000)) return queueReclone();
|
|
if ("requestIdleCallback" in window) {
|
|
requestIdleCallback((deadline) => {
|
|
if (!appScrollIdleFor(1000)) return queueReclone();
|
|
// Bounded, same fix as GlassBar (2026-08-30): with a
|
|
// frame continuously pending, this machine's idle
|
|
// slices cap at ~13.5ms and a hard <15 never passes —
|
|
// this pane's fog died at the first content churn
|
|
// (every track change) and never rebuilt.
|
|
if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) {
|
|
return queueReclone();
|
|
}
|
|
thinSlices = 0;
|
|
reclone();
|
|
});
|
|
} else {
|
|
reclone();
|
|
}
|
|
}, delay);
|
|
};
|
|
const mo = new MutationObserver((records) => {
|
|
for (const r of records) {
|
|
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
|
// Invalidate any built-but-unattached clone (GlassBar note).
|
|
recloneGen++;
|
|
peri.replaceChildren();
|
|
break;
|
|
}
|
|
}
|
|
queueReclone();
|
|
});
|
|
mo.observe(view, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["src"],
|
|
});
|
|
const ro = new ResizeObserver(queueReclone);
|
|
ro.observe(view);
|
|
const onVis = () => {
|
|
if (!document.hidden) queueReclone();
|
|
};
|
|
document.addEventListener("visibilitychange", onVis);
|
|
view.addEventListener("scroll", sync, { passive: true });
|
|
reclone();
|
|
return () => {
|
|
recloneGen++;
|
|
mo.disconnect();
|
|
ro.disconnect();
|
|
document.removeEventListener("visibilitychange", onVis);
|
|
view.removeEventListener("scroll", sync);
|
|
clearTimeout(pending);
|
|
peri.replaceChildren();
|
|
};
|
|
});
|
|
|
|
// Through goToView, not a bare `ui.view = …`: the glass bar's history and
|
|
// trail are fed by recorded visits, and a destination that skips the
|
|
// recorder is one the back arrow can never return to.
|
|
function navigate(id) {
|
|
goToView(id, id !== "library" ? { albumId: null } : {});
|
|
}
|
|
|
|
async function newPlaylist(e) {
|
|
e.stopPropagation(); // the + rides the Playlists row; don't also navigate
|
|
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
|
const id = await api.createPlaylist(name);
|
|
await refreshPlaylists();
|
|
goToView("playlist", { playlistId: id, label: name });
|
|
}
|
|
|
|
// Search and Downloads left the sidebar 2026-08-31: both live inside
|
|
// the Soulseek hub now, like the Qt app's own tabs. Their old view ids
|
|
// still route (App.svelte aliases them to the hub), so the hub entry
|
|
// lights up for them too.
|
|
const items = [
|
|
{ id: "home", label: "Home", icon: "home" },
|
|
{ id: "soulseek", label: "Soulseek", icon: "globe" },
|
|
{ id: "community", label: "Community", icon: "users" },
|
|
{ id: "library", label: "Your Library", icon: "library" },
|
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
{ id: "playlists", label: "Playlists", icon: "playlist" },
|
|
];
|
|
const HUB_ALIASES = new Set(["soulseek", "search", "downloads"]);
|
|
const itemActive = (item) =>
|
|
item.id === "soulseek" ? HUB_ALIASES.has(ui.view) : ui.view === item.id;
|
|
|
|
// ---- the Soulseek status, moved here out of the title bar ----
|
|
function statusClick() {
|
|
if (app.connection === "online") {
|
|
api.disconnect();
|
|
app.connection = "offline";
|
|
} else if (app.connection === "offline") {
|
|
app.showLogin = true;
|
|
}
|
|
}
|
|
|
|
// Two letters of the username, so the tile reads as an identity before any
|
|
// portrait exists. `app.avatarUrl` is the hook the PFP feature will fill;
|
|
// until then the <img> has no src and the initials show through.
|
|
const initials = $derived(
|
|
(app.username || "").replace(/[^a-z0-9]/gi, "").slice(0, 2).toLowerCase() || "··",
|
|
);
|
|
|
|
// ---- recently played ----
|
|
// Day buckets are computed from local calendar dates, not from elapsed
|
|
// seconds: something played at 00:30 is "Today", not "8 hours ago".
|
|
function dayKey(unixSecs) {
|
|
const d = new Date(unixSecs * 1000);
|
|
const now = new Date();
|
|
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const t = d.getTime();
|
|
if (t >= midnight) return "Today";
|
|
if (t >= midnight - 86400000) return "Yesterday";
|
|
return "Earlier";
|
|
}
|
|
|
|
// One flat list with day markers folded in, so the markup is a single pass
|
|
// and there is no nesting to keep aligned.
|
|
const recentRows = $derived.by(() => {
|
|
const rows = [];
|
|
let day = null;
|
|
for (const a of recentsStore.albums) {
|
|
const k = dayKey(a.last_played_at);
|
|
if (k !== day) {
|
|
day = k;
|
|
rows.push({ kind: "day", label: k, key: `d:${k}` });
|
|
}
|
|
// Two id realms, so the key must carry the realm: catalog rows
|
|
// have album_id NULL, and keying them all `a:null` was a
|
|
// duplicate-key crash that silently killed this whole list the
|
|
// moment TWO streamed albums entered history (2026-08-01).
|
|
rows.push({
|
|
kind: "album",
|
|
album: a,
|
|
key: a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`,
|
|
});
|
|
}
|
|
return rows;
|
|
});
|
|
|
|
// Covers resolve asynchronously through the shared art cache; keep the
|
|
// resolved data URLs keyed by path so a re-render never re-requests.
|
|
//
|
|
// These are the smallest tiles in the app — 40px — so they take a smaller
|
|
// bound than the app-wide default rather than carrying a 512px cover to
|
|
// draw a thumbnail. A couple of kilobytes per row.
|
|
const RECENT_EDGE = 128;
|
|
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, RECENT_EDGE).then((u) => (artUrls[p] = u)).catch(() => {});
|
|
}
|
|
});
|
|
|
|
// A catalog row's cover is already a URL and needs no read; a library row's
|
|
// is a file path that has to go through the art cache.
|
|
function coverOf(a) {
|
|
return a.art_url ?? (a.art_path ? artUrls[a.art_path] : null);
|
|
}
|
|
|
|
// The same set of states the title bar's ring counts as "in flight".
|
|
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
|
|
const downloadCount = $derived(
|
|
Object.values(downloads.items).filter((d) => DL_ACTIVE.has(d.state)).length,
|
|
);
|
|
|
|
// Which record is playing, so its row can carry the equaliser. The queue
|
|
// item carries the cover path the play was recorded against — matching on
|
|
// artist/album NAME would be exactly the identity mistake the app forbids,
|
|
// so a row lights up only when its art path is the one playing.
|
|
const nowItem = $derived(player.queue?.[player.queueIndex] ?? null);
|
|
const playingArt = $derived(nowItem?.art ?? null);
|
|
const playingCatalogId = $derived(
|
|
player.catalogRef?.albumId != null ? String(player.catalogRef.albumId) : null,
|
|
);
|
|
|
|
// Anchored on ids, never on a name.
|
|
function isPlaying(a) {
|
|
if (a.kind === "catalog") {
|
|
return !!a.catalog_album_id && a.catalog_album_id === playingCatalogId;
|
|
}
|
|
return !!a.art_path && a.art_path === playingArt;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!recentsStore.loaded) refreshRecents();
|
|
});
|
|
|
|
// Pinned playlists ride between the nav and the history — quick access
|
|
// to the lists the user chose to keep close, with their covers.
|
|
const pinnedPlaylists = $derived(
|
|
playlistStore.playlists.filter((p) => p.pinned).slice(0, 6),
|
|
);
|
|
$effect(() => {
|
|
for (const pl of pinnedPlaylists) void playlistCoverFor(pl);
|
|
});
|
|
</script>
|
|
|
|
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
|
|
<!-- One element, outside the list, so it never fades with the content. -->
|
|
<div class="gbar">
|
|
{#if glassy}
|
|
<div class="peri" bind:this={periEl} aria-hidden="true"></div>
|
|
{/if}
|
|
<button
|
|
class="status"
|
|
data-state={app.connection}
|
|
onclick={statusClick}
|
|
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
|
|
>
|
|
<span class="avcol">
|
|
<span class="av">
|
|
<span class="avi">
|
|
{#if app.avatarUrl}
|
|
<img class="pfp" src={app.avatarUrl} alt="" draggable="false" />
|
|
{/if}
|
|
{initials}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<span class="tx">
|
|
<span class="nm">
|
|
{#if app.connection === "online"}{app.username}
|
|
{:else if app.connection === "connecting"}Signing in
|
|
{:else}Not connected{/if}
|
|
</span>
|
|
<span class="sub">
|
|
{#if app.connection === "online"}{network.shareFiles} files shared
|
|
{:else if app.connection === "connecting"}one moment
|
|
{:else}click to sign in{/if}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<!-- right:18 puts it dead centre at 64px — (64-28)/2 = 18 — so the
|
|
collapsed position falls out of the geometry, not a second rule. -->
|
|
<button
|
|
class="gicon"
|
|
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
|
|
onclick={toggleSidebar}
|
|
>
|
|
<!-- 16: the panel-top chrome spec — every 28px disc carries a 16px
|
|
glyph (glass bar arrows, NowPlaying header, this). -->
|
|
<Icon name="chevron-left" size={16} />
|
|
</button>
|
|
|
|
<!-- ONE list. Not two views cross-fading: two views means two copies of
|
|
every icon and cover painting at once through the whole crossfade, and
|
|
any difference between the copies reads as doubling. Here nothing fades
|
|
but text, and no glyph can move — every x comes from padding, which
|
|
does not depend on the panel's width. -->
|
|
<div class="view" bind:this={viewEl}>
|
|
{#each items as item (item.id)}
|
|
<button
|
|
class="nav-item"
|
|
class:active={itemActive(item)}
|
|
class:has-count={item.id === "downloads" && downloadCount > 0}
|
|
title={ui.sidebarCollapsed ? item.label : null}
|
|
onclick={() => navigate(item.id)}
|
|
>
|
|
<span class="ic"><Icon name={item.icon} size={22} /></span>
|
|
<span class="lbl">{item.label}</span>
|
|
{#if item.id === "downloads" && downloadCount > 0}
|
|
<span class="bdg">{downloadCount > 99 ? "99+" : downloadCount}</span>
|
|
{/if}
|
|
{#if item.id === "playlists"}
|
|
<span
|
|
class="rowplus"
|
|
role="button"
|
|
tabindex="0"
|
|
title="New playlist"
|
|
onclick={newPlaylist}
|
|
onkeydown={(e) => e.key === "Enter" && newPlaylist(e)}
|
|
>
|
|
<Icon name="plus" size={13} />
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
|
|
{#each pinnedPlaylists as pl (pl.id)}
|
|
<button
|
|
class="rec"
|
|
class:on={ui.view === "playlist" && ui.playlistId === pl.id}
|
|
title="{pl.name} — pinned playlist"
|
|
onclick={() => goToView("playlist", { playlistId: pl.id, label: pl.name })}
|
|
>
|
|
{#if playlistStore.covers[pl.id]}
|
|
<img src={playlistStore.covers[pl.id]} alt="" draggable="false" />
|
|
{:else}
|
|
<span class="art-placeholder"><Icon name="playlist" size={15} /></span>
|
|
{/if}
|
|
<span class="tx">
|
|
<span class="n">
|
|
<span class="pin-mini" aria-hidden="true"><Icon name="pin" size={10} /></span>
|
|
{pl.name}
|
|
</span>
|
|
<span class="s">{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
|
|
<div class="rule"></div>
|
|
|
|
{#each recentRows as row (row.key)}
|
|
{#if row.kind === "day"}
|
|
<div class="day"><span>{row.label}</span></div>
|
|
{:else}
|
|
<button
|
|
class="rec"
|
|
class:on={isPlaying(row.album)}
|
|
title="{row.album.title} — {row.album.artist}"
|
|
onclick={() => openRecentAlbum(row.album)}
|
|
>
|
|
{#if coverOf(row.album)}
|
|
<img src={coverOf(row.album)} alt="" draggable="false" />
|
|
{:else}
|
|
<span class="art-placeholder"><Icon name="music" size={15} /></span>
|
|
{/if}
|
|
<span class="tx">
|
|
<span class="n">
|
|
{#if isPlaying(row.album)}
|
|
<span class="eq"><i></i><i></i><i></i></span>
|
|
{/if}
|
|
{row.album.title}
|
|
</span>
|
|
<span class="s">{row.album.artist}</span>
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</nav>
|
|
|
|
<style>
|
|
.sidebar {
|
|
grid-area: sidebar;
|
|
background: var(--bg-base);
|
|
border-radius: var(--radius);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── dark glass: frost + the periscope bar ──────────────────────────
|
|
The pane frosts (0.35 over the .app canvas tint). The bar CANNOT use
|
|
backdrop-filter here: over see-through frost the compositor flattens
|
|
the sampled backdrop against opaque black and the bar renders as a
|
|
slab (measured 2026-08-26: band 3 vs pane 40). Instead, under glass:
|
|
the original rows are masked out of the bar band, and the .peri box
|
|
inside the bar shows a scroll-synced CLONE of the list under a
|
|
normal filter blur — normal filters composite correctly over frost
|
|
(the np wash proves it daily). Numbers approved on the test page
|
|
(research/glass-periscope): frost 0.35, tint 0.28, blur 17, sat 1.3.
|
|
Solid mode: every rule below is inert, original recipe throughout. */
|
|
:global(:root.glass) .sidebar {
|
|
background: rgba(0, 0, 0, 0.35);
|
|
}
|
|
:global(:root.glass) .gbar {
|
|
backdrop-filter: none;
|
|
-webkit-backdrop-filter: none;
|
|
}
|
|
/* Direct child only — the periscope clone is inside .peri and must
|
|
never inherit this band cut (it would blank the fog). clip-path,
|
|
not mask: rectangle clips ride the compositor's fast path (see
|
|
App.svelte's band-cut note). */
|
|
:global(:root.glass) .sidebar > .view {
|
|
clip-path: inset(46px 0 0 0);
|
|
}
|
|
/* The rows ON the frost. --bg-highlight is opaque #242424, so under glass
|
|
every nav row and every pinned/recent row painted a slab on the pane —
|
|
the one Kyle photographed first. The white veil makes the same +18 step
|
|
against the frost that #242424 makes against #121212 (see the ladder in
|
|
global.css). Hover and active deliberately share one value: this sidebar
|
|
has always distinguished active by TEXT COLOUR (the note above the base
|
|
rule), and that is left exactly as it is. */
|
|
:global(:root.glass) .nav-item:hover,
|
|
:global(:root.glass) .rec:hover,
|
|
:global(:root.glass) .nav-item.active,
|
|
:global(:root.glass) .rec.on {
|
|
background: var(--glass-hover);
|
|
}
|
|
/* (.gicon keeps its 0.12 chrome veil under glass — no override needed.) */
|
|
/* The artwork well behind a recent with no cover yet. */
|
|
:global(:root.glass) .art-placeholder {
|
|
background: var(--glass-surface);
|
|
}
|
|
/* --border is opaque #2a2a2a: a 1px divider on the frost read as a solid
|
|
scratch. The hairline token is the same weight against glass. */
|
|
:global(:root.glass) .rule {
|
|
background: var(--glass-hairline);
|
|
}
|
|
/* The count badge's backdrop is the frosted pane, so its blur flattens the
|
|
sample to black exactly like the bar's did — the fill already carries
|
|
the look on its own. */
|
|
:global(:root.glass) .bdg {
|
|
backdrop-filter: none;
|
|
-webkit-backdrop-filter: none;
|
|
}
|
|
.peri {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
}
|
|
/* The clone's filter and geometry are inline on its overscan wrapper —
|
|
built in the reclone() above; the band mask can't reach the clone
|
|
(direct-child selector), so no cancel rule is needed here. */
|
|
.gbar .status {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
|
|
/* ── the glass bar ── */
|
|
.gbar {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 46px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 11px;
|
|
z-index: 5;
|
|
background: rgba(20, 20, 24, 0.28);
|
|
backdrop-filter: blur(12px) saturate(1.7);
|
|
-webkit-backdrop-filter: blur(12px) saturate(1.7);
|
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
|
|
|
|
/* Panel-top chrome spec (2026-08-31): 28px circle, white glyph, 0.12
|
|
white-veil hover in both modes — the glass bar's .arrow language,
|
|
shared with the Now Playing header. */
|
|
.gicon {
|
|
position: absolute;
|
|
right: 18px;
|
|
top: 9px;
|
|
display: grid;
|
|
place-items: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
color: #fff;
|
|
z-index: 6;
|
|
transition:
|
|
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
background 120ms;
|
|
}
|
|
.gicon:hover {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
}
|
|
.sidebar.collapsed .gicon {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
/* ── the Soulseek status ──
|
|
Not painted with --accent: the accent is crossfaded from the playing
|
|
album's art, so "online" would change colour with the music and currently
|
|
renders red. Green means connected, whatever is playing. */
|
|
.status {
|
|
--av: 26px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
min-width: 0;
|
|
max-width: 175px;
|
|
flex: 1;
|
|
/* 11 (bar padding) + 1 = 12, the image column's left edge */
|
|
padding: 2px 10px 2px 1px;
|
|
border-radius: 9px;
|
|
text-align: left;
|
|
transition:
|
|
background 0.14s,
|
|
opacity 0.26s ease;
|
|
}
|
|
.status:hover {
|
|
background: rgba(255, 255, 255, 0.07);
|
|
}
|
|
.sidebar.collapsed .status {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* A 40px slot with its left edge at 12 — the same construction as a nav
|
|
icon — so the avatar is centred on x=32 at ANY size, in line with every
|
|
icon and cover below it. */
|
|
.avcol {
|
|
width: 40px;
|
|
flex: none;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
.av {
|
|
position: relative;
|
|
flex: none;
|
|
width: var(--av);
|
|
height: var(--av);
|
|
}
|
|
/* Album art is 40px at radius 4 — a ratio of exactly .1. Deriving the
|
|
radius from the size holds that ratio at any size, so the avatar reads as
|
|
the same object as a cover and a real photo drops straight in. */
|
|
.avi {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: calc(var(--av) * 0.1);
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(150deg, #3c3c45, #24242a);
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
|
|
font-size: calc(var(--av) * 0.4);
|
|
font-weight: 700;
|
|
color: #d8d8e0;
|
|
}
|
|
.pfp {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
/* The state dot hangs off the corner, so it rides .av (unclipped) and never
|
|
.avi (which clips, for the photo). */
|
|
.av::after {
|
|
content: "";
|
|
position: absolute;
|
|
right: -2px;
|
|
bottom: -2px;
|
|
width: 9px;
|
|
height: 9px;
|
|
border-radius: 50%;
|
|
background: #7c7c85;
|
|
box-shadow: 0 0 0 2.5px #131316;
|
|
}
|
|
.status[data-state="online"] .av::after {
|
|
background: #3ecf7e;
|
|
}
|
|
.status[data-state="connecting"] .av::after {
|
|
background: #e8a33d;
|
|
animation: breathe 1.15s ease-in-out infinite;
|
|
}
|
|
@keyframes breathe {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.3;
|
|
}
|
|
}
|
|
|
|
.status .tx {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.status .nm {
|
|
font-size: 12.5px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.status .sub {
|
|
font-size: 9.5px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
color: var(--text-muted);
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}
|
|
.status[data-state="online"] .sub {
|
|
color: #3ecf7e;
|
|
}
|
|
.status[data-state="connecting"] .sub {
|
|
color: #e8a33d;
|
|
}
|
|
|
|
/* ── the list ──
|
|
The 46px inset is the bar's height; the extra 8 stops the first row's
|
|
highlight sitting flush against it. */
|
|
.view {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 1;
|
|
width: 100%;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 54px 6px 12px 6px;
|
|
scrollbar-width: none;
|
|
}
|
|
.view::-webkit-scrollbar {
|
|
width: 0;
|
|
display: none;
|
|
}
|
|
|
|
/* EVERY GLYPH SHARES ONE CENTRE, x=32, AND NONE OF THEM MOVE.
|
|
row starts at 6 (view pad) + 6 (row pad) -> content at 12
|
|
cover 40px at 12 -> 12..52, centre 32
|
|
icon 22px centred in a 40px slot at 12 -> 21..43, centre 32
|
|
At 64px the panel's centre is also 32, so the rail centring falls out of
|
|
the same arithmetic — it is not a second rule. These x values come from
|
|
padding, which does not depend on the panel's width, so they are
|
|
invariant through the whole collapse. Verified at 232/200/168/136/104/80/64. */
|
|
.nav-item,
|
|
.rec {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
width: 100%;
|
|
height: 42px;
|
|
padding: 0 6px;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
transition:
|
|
color 0.15s,
|
|
background 0.15s;
|
|
}
|
|
.nav-item .ic {
|
|
width: 40px;
|
|
flex: none;
|
|
display: grid;
|
|
place-items: center;
|
|
position: relative;
|
|
}
|
|
/* Fixed width so the label never reflows or re-ellipsises as the panel
|
|
narrows — it fades, and what is left is clipped by the panel. 118 lands
|
|
the count and the + a clear 12-16px inside the highlight. */
|
|
.lbl {
|
|
width: 118px;
|
|
flex: none;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.nav-item:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* --bg-highlight, matching NowPlaying's .row:hover exactly. The frosted
|
|
version existed to let the album wash run underneath it; with the wash
|
|
gone it has nothing to reveal, and an opaque highlight is what the right
|
|
panel already uses. Active is distinguished by text colour, which is how
|
|
this sidebar has always done it. */
|
|
.nav-item:hover,
|
|
.rec:hover,
|
|
.nav-item.active,
|
|
.rec.on {
|
|
background: var(--bg-highlight);
|
|
}
|
|
.nav-item.active,
|
|
.rec.on {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* margin-left:0, never auto — auto depends on the row's width, so the count
|
|
and the + would slide across the panel while fading out. */
|
|
.bdg,
|
|
.rowplus {
|
|
margin-left: 0;
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.bdg {
|
|
min-width: 18px;
|
|
height: 18px;
|
|
padding: 0 5px;
|
|
border-radius: 9px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
font-variant-numeric: tabular-nums;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: #fff;
|
|
backdrop-filter: blur(8px) saturate(1.4);
|
|
-webkit-backdrop-filter: blur(8px) saturate(1.4);
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
|
|
}
|
|
/* Collapsed there is no room for a numeral, and an 18px one on the glyph
|
|
covers it. An 8px dot at (44..52) clears the glyph, which ends at 43. */
|
|
.nav-item.has-count .ic::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: 5px;
|
|
right: 0;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
|
|
opacity: 0;
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.sidebar.collapsed .nav-item.has-count .ic::after {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Draws nothing until you point at the row — the right weight for a
|
|
secondary action, and it stops a grey dot floating on the wash. */
|
|
.rowplus {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
flex: none;
|
|
display: grid;
|
|
place-items: center;
|
|
background: none;
|
|
color: transparent;
|
|
transition:
|
|
opacity 0.26s ease,
|
|
background 0.13s,
|
|
color 0.13s,
|
|
transform 0.13s;
|
|
}
|
|
.nav-item:hover .rowplus {
|
|
color: var(--text-secondary);
|
|
}
|
|
.rowplus:hover {
|
|
background: rgba(255, 255, 255, 0.17);
|
|
color: #fff;
|
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
|
transform: scale(1.08);
|
|
}
|
|
|
|
.rule {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 12px 6px;
|
|
}
|
|
|
|
/* No "Recently played" eyebrow — the rule runs straight into the history.
|
|
Collapsed, the day word leaves a hairline so the list keeps its
|
|
structure instead of becoming an undifferentiated column. */
|
|
.day {
|
|
position: relative;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
padding: 0 6px 5px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.09em;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.day span {
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.day::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 8px;
|
|
right: 8px;
|
|
bottom: 8px;
|
|
height: 1px;
|
|
background: rgba(255, 255, 255, 0.09);
|
|
opacity: 0;
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.sidebar.collapsed .day::after {
|
|
opacity: 1;
|
|
}
|
|
|
|
.rec {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 11px;
|
|
width: 100%;
|
|
height: 52px;
|
|
padding: 0 6px;
|
|
border-radius: 6px;
|
|
text-align: left;
|
|
transition: background 0.15s;
|
|
}
|
|
.rec img,
|
|
.art-placeholder {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 4px;
|
|
object-fit: cover;
|
|
flex: none;
|
|
transition: transform var(--art-motion);
|
|
}
|
|
.art-placeholder {
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--bg-highlight);
|
|
color: var(--text-muted);
|
|
}
|
|
.rec:hover img {
|
|
transform: scale(var(--art-grow));
|
|
}
|
|
.rec .tx {
|
|
width: 157px;
|
|
flex: none;
|
|
overflow: hidden;
|
|
transition: opacity 0.26s ease;
|
|
}
|
|
.rec .n {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.rec:hover .n {
|
|
color: var(--text-primary);
|
|
}
|
|
.rec.on .n {
|
|
color: var(--accent-hover);
|
|
}
|
|
.rec .s {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
/* Only text fades. Nothing here changes a position. */
|
|
.sidebar.collapsed .lbl,
|
|
.sidebar.collapsed .rec .tx,
|
|
.sidebar.collapsed .day span,
|
|
.sidebar.collapsed .nav-item > .bdg,
|
|
.sidebar.collapsed .rowplus {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Pinned playlist rows share the recents' whole construction; the only
|
|
addition is the tiny pin riding the name, which fades with the text. */
|
|
.pin-mini {
|
|
display: inline-flex;
|
|
color: var(--text-muted);
|
|
flex: none;
|
|
}
|
|
|
|
.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>
|