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