750 lines
23 KiB
Svelte
750 lines
23 KiB
Svelte
<script>
|
|
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";
|
|
|
|
// 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}` });
|
|
}
|
|
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}>
|
|
<!-- 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={() => 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>
|
|
|
|
<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>
|