Files
SoulStream/src/components/TitleBar.svelte
T

1116 lines
39 KiB
Svelte

<script>
import {
app,
ui,
downloads,
catalog,
recentsStore,
catalogGo,
catalogSearchIdentityAlbums,
catalogTrackOwnership,
recentSearches,
searchNetworkFor,
openCatalogArtist,
openCatalogAlbum,
openRecentAlbum,
warmCatalogArtist,
cancelCatalogArtistWarmup,
streamSong,
streamCatalogAlbum,
getArt,
gridCover,
} from "../lib/state.svelte.js";
import { floatScroll } from "../lib/scrollbar.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let maximized = $state(false);
let searchText = $state("");
// ---- download status ring (aggregate progress + active count) ----
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const RING_C = 2 * Math.PI * 9; // circumference of the r=9 progress circle
// The ring aggregates over the whole download burst, not just the
// currently-active transfers — otherwise each finished file drops out
// of the fraction and the ring jumps backward instead of filling.
// A burst spans from the first active download (ids are monotonic, so
// members are `id >= session start`) until the queue drains.
let dlSession = $state({ min: null });
const dl = $derived.by(() => {
let active = 0;
let received = 0;
let total = 0;
for (const d of Object.values(downloads.items)) {
const isActive = DL_ACTIVE.has(d.state);
if (isActive) active += 1;
if (!d.total) continue;
if (isActive) {
received += d.received;
total += d.total;
} else if (
d.state === "complete" &&
dlSession.min != null &&
d.id >= dlSession.min
) {
received += d.total;
total += d.total;
}
}
return { active, frac: total > 0 ? Math.min(1, received / total) : 0 };
});
// When the queue drains, flash a full ring + check before hiding, then
// close out the burst.
let dlFlash = $state(false);
let dlHadActive = false;
$effect(() => {
if (dl.active > 0) {
dlHadActive = true;
dlFlash = false;
if (dlSession.min == null) {
let min = Infinity;
for (const d of Object.values(downloads.items)) {
if (DL_ACTIVE.has(d.state)) min = Math.min(min, d.id);
}
if (min < Infinity) dlSession.min = min;
}
} else if (dlHadActive) {
dlHadActive = false;
dlFlash = true;
const t = setTimeout(() => {
dlFlash = false;
dlSession.min = null;
}, 2500);
return () => clearTimeout(t);
}
});
// ---- live search suggestions ------------------------------------------
// The typeahead has three faces, all of them designed (see the prototype at
// research/search-redesign/PROTOTYPE-search.html):
//
// typing grouped Artists / Albums / Songs, each section under a
// hairline rule with its total; the keyboard selection
// carries a 3px accent edge so arrowing through it reads.
// focused, empty your recent searches and what you were last playing.
// no matches it does NOT vanish — a catalogue miss can still succeed
// on the Soulseek network, and it offers that.
//
// It is deliberately WIDER than the field (left/right: -70px, ~500px): at
// the field's own width real record titles truncated in every group.
let sugg = $state(null); // last catalogSearch results for the dropdown
let suggOpen = $state(false);
let focused = $state(false);
// The keyboard selection is held as the row's KEY, not its index. The
// MusicBrainz supplement lands ~600ms after the rest and re-orders the album
// group; an index would then point at a different record than the one the
// accent edge is drawn on, and Enter would open that one. `null` = the
// footer, which is what Enter does by default.
let hiKey = $state(null);
let suggSeq = 0;
let debounceTimer = null;
let mbTimer = null;
let mbExtras = $state([]);
let owned = $state({});
// Keyed by artist AND query: the extras are deduped against the album list
// they were computed for, and a different query has a different one. The
// MusicBrainz traffic behind this is cached backend-side per artist, so a
// repeat costs nothing.
const mbCache = new Map();
const MB_CACHE_MAX = 32;
const recentQueries = $derived(recentSearches(catalog.query, 4));
const jumpBackIn = $derived(recentsStore.albums.slice(0, 3));
let recentArt = $state({});
$effect(() => {
for (const a of jumpBackIn) {
const p = a.art_path;
if (!p || recentArt[p] !== undefined) continue;
recentArt[p] = null;
getArt(p)
.then((u) => (recentArt[p] = u))
.catch(() => {});
}
});
const recentCover = (a) => a.art_url ?? (a.art_path ? recentArt[a.art_path] : null);
/** Records only MusicBrainz carries are merged into Albums with no badge and
* no section of their own, exactly as on the results page — and the merged
* list is sorted newest-first, or a four-row slice would never reach them. */
const suggAlbums = $derived(
[...(sugg?.albums ?? []), ...mbExtras].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
),
);
const suggGroups = $derived.by(() => {
if (!sugg) return [];
const groups = [];
if (sugg.artists.length) {
groups.push({
label: "Artists",
count: sugg.artists.length,
rows: sugg.artists.slice(0, 2).map((a) => ({
kind: "artist",
key: `artist:${a.id}`,
label: a.name,
sub: a.fans ? `Artist · ${fmtFans(a.fans)} fans` : "Artist",
img: a.picture,
round: true,
data: a,
})),
});
}
if (suggAlbums.length) {
groups.push({
label: "Albums",
count: suggAlbums.length,
rows: suggAlbums.slice(0, 4).map((al) => ({
kind: "album",
key: `album:${al.id}`,
label: al.title,
sub: [al.release_date?.slice(0, 4), al.artist].filter(Boolean).join(" · "),
img: al.cover,
round: false,
data: al,
})),
});
}
if (sugg.tracks.length) {
groups.push({
label: "Songs",
count: sugg.tracks.length,
rows: sugg.tracks.slice(0, 3).map((t) => ({
kind: "track",
key: `track:${t.id}`,
label: t.title,
sub: `${t.artist} · ${t.album}`,
dur: fmtDuration(t.duration_secs),
img: t.cover,
round: false,
data: t,
})),
});
}
return groups;
});
/** Flat, in visual order — what the arrow keys walk. */
const suggRows = $derived(suggGroups.flatMap((g) => g.rows));
/** Which of the three panels is on screen. */
const suggMode = $derived.by(() => {
if (!suggOpen) return null;
if (!searchText.trim()) {
return recentQueries.length || jumpBackIn.length ? "empty" : null;
}
if (!sugg) return null;
return suggRows.length ? "list" : "none";
});
const fmtFans = (n) => {
if (n == null) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
};
const fmtDuration = (s) =>
s == null ? "" : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function onSearchInput() {
clearTimeout(debounceTimer);
clearTimeout(mbTimer);
cancelCatalogArtistWarmup();
const q = searchText.trim();
if (!q) {
// An emptied field falls back to the recent panel rather than
// closing: the reader is still in the field, still searching.
suggSeq += 1;
sugg = null;
mbExtras = [];
hiKey = null;
suggOpen = focused;
return;
}
if (q.length < 2) {
suggOpen = false;
sugg = null;
mbExtras = [];
return;
}
debounceTimer = setTimeout(async () => {
const seq = ++suggSeq;
try {
const res = await api.catalogSearch(q);
// Ignore stale responses and results for superseded queries.
if (seq !== suggSeq || searchText.trim() !== q) return;
sugg = res;
mbExtras = [];
hiKey = null;
suggOpen = true;
// The first artist is overwhelmingly the next click. Warm its
// one-round-trip overview and art resolver while the user is
// still reading the dropdown; keyed caches share the work.
if (res.artists?.[0]) warmCatalogArtist(res.artists[0]);
void markOwned(res.tracks, seq);
scheduleMbSupplement(res, q, seq);
} catch {
// backend unavailable — no suggestions
}
}, 250);
}
async function markOwned(tracks, seq) {
try {
const map = await catalogTrackOwnership(tracks);
if (seq === suggSeq) owned = map;
} catch {
owned = {};
}
}
/** The MusicBrainz supplement is NOT fetched per keystroke. It waits for the
* same 600ms of the artist staying the top hit that the artist warm-up
* waits for, so live-search churn can never pace-probe MusicBrainz. */
function scheduleMbSupplement(res, query, seq) {
const seed = res?.artists?.[0];
if (seed?.id == null) return;
const key = `${seed.id}|${query}`;
mbTimer = setTimeout(() => {
if (seq !== suggSeq) return;
if (!mbCache.has(key)) {
mbCache.set(
key,
catalogSearchIdentityAlbums(seed, res.albums).catch(() => []),
);
// A long typing session would otherwise grow this without end.
while (mbCache.size > MB_CACHE_MAX) {
mbCache.delete(mbCache.keys().next().value);
}
}
mbCache.get(key).then((extras) => {
if (seq === suggSeq && extras.length) mbExtras = extras;
});
}, 600);
}
function closeSuggest() {
suggOpen = false;
hiKey = null;
}
function pickRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "artist") openCatalogArtist(row.data.id, row.data);
else if (row.kind === "album") {
// Clicking a row GOES there; the play button on the row plays it.
const al = row.data;
openCatalogAlbum(al.id, {
seed: { id: al.id, title: al.title, artist: al.artist, cover: al.cover ?? null },
});
} else playTrack(row.data);
}
function playTrack(t) {
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
}
function playRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "album") streamCatalogAlbum(row.data);
else if (row.kind === "track") playTrack(row.data);
}
function pickQuery(q) {
closeSuggest();
searchText = q;
catalogGo(q);
}
function searchKeydown(e) {
if (!suggOpen || suggRows.length === 0) {
if (e.key === "Escape") closeSuggest();
return;
}
// A row that has since left the list reads as "no selection", so the
// arrows resume from the top rather than from a place that is gone.
const at = suggRows.findIndex((r) => r.key === hiKey);
if (e.key === "ArrowDown") {
e.preventDefault();
hiKey = suggRows[(at + 1) % suggRows.length].key;
} else if (e.key === "ArrowUp") {
e.preventDefault();
hiKey = suggRows[at <= 0 ? suggRows.length - 1 : at - 1].key;
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
pickRow(suggRows[at]);
} else if (e.key === "Escape") {
closeSuggest();
}
}
function searchFocusOut(e) {
// Keep the panel open while focus moves within the search form.
if (!e.currentTarget.contains(e.relatedTarget)) {
focused = false;
closeSuggest();
}
}
function searchFocus() {
focused = true;
// Returning to the field re-opens what it last had to say.
if (!searchText.trim()) suggOpen = recentQueries.length > 0 || jumpBackIn.length > 0;
else if (sugg && searchText.trim().length >= 2) suggOpen = true;
}
function submitSearch(e) {
e?.preventDefault();
clearTimeout(debounceTimer);
clearTimeout(mbTimer);
suggSeq += 1; // cancel any in-flight suggestion fetch
closeSuggest();
catalogGo(searchText);
}
function networkSearch() {
closeSuggest();
searchNetworkFor(searchText);
}
async function syncMaximized() {
try {
maximized = await api.win.isMaximized();
} catch {
// not running inside Tauri (browser preview)
}
}
// 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(syncMaximizedSoon)
.then((f) => (unlisten = f))
.catch(() => {});
return () => {
clearTimeout(resizeSettle);
unlisten?.();
};
});
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand">
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<!-- 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. -->
<!-- The dropdown is a CHILD of the field, not of the titlebar: it is
positioned against the field's own box (left/right: -70px). Parented to
the bar it rendered 1223px wide. -->
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
type="text"
placeholder="Search artists, albums, songs…"
bind:value={searchText}
oninput={onSearchInput}
onkeydown={searchKeydown}
onfocus={searchFocus}
spellcheck="false"
/>
{#if focused}
<span class="kb" aria-hidden="true">↵</span>
{/if}
{#if suggMode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="suggest" use:floatScroll onmousedown={(e) => e.preventDefault()}>
{#if suggMode === "empty"}
{#if recentQueries.length}
<div class="sg-h">Recent<span class="line"></span></div>
<div class="chiprow">
{#each recentQueries as q (q)}
<button type="button" class="rc" onclick={() => pickQuery(q)}>
<Icon name="search" size={11} />{q}
</button>
{/each}
</div>
{/if}
{#if jumpBackIn.length}
<div class="sg-h">Jump back in<span class="line"></span></div>
{#each jumpBackIn as a (a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`)}
<button
type="button"
class="sg"
onclick={() => {
closeSuggest();
openRecentAlbum(a);
}}
>
{#if recentCover(a)}
<img src={recentCover(a)} alt="" draggable="false" />
{:else}
<span class="sg-img placeholder"><Icon name="disc" size={13} /></span>
{/if}
<span class="sg-text">
<span class="t">{a.title}</span>
<span class="s">{a.artist}</span>
</span>
</button>
{/each}
{/if}
{:else if suggMode === "none"}
<!-- Never a vanishing panel: the network is still an answer. -->
<div class="nomatch">
<div class="nomatch-h">No quick matches</div>
<div class="nomatch-s">
Nothing in the catalogue starts with “{searchText.trim()}”.
</div>
</div>
<button type="button" class="sg-ft" onclick={networkSearch}>
<Icon name="globe" size={13} strokeWidth={1.8} />
<span>Search the network for files</span>
<span class="kb">↵</span>
</button>
{:else}
{#each suggGroups as group (group.label)}
<div class="sg-h">
{group.label}<span class="c">{group.count}</span><span class="line"
></span>
</div>
{#each group.rows as row (row.key)}
<button
type="button"
class="sg"
class:on={row.key === hiKey}
onmouseenter={() => (hiKey = row.key)}
onclick={() => pickRow(row)}
>
{#if row.img}
<img
class:round={row.round}
src={gridCover(row.img)}
alt=""
draggable="false"
/>
{:else}
<span class="sg-img placeholder" class:round={row.round}>
<Icon name="music" size={13} />
</span>
{/if}
<span class="sg-text">
<span class="t">
{row.label}
{#if row.kind === "track" && owned[row.data.id]}
<span class="own">SAVED</span>
{/if}
</span>
<span class="s">{row.sub}</span>
</span>
{#if row.dur}
<span class="d">{row.dur}</span>
{/if}
{#if row.kind !== "artist"}
<!-- Hover swaps the duration for this. -->
<span
class="play"
role="button"
tabindex="-1"
aria-hidden="true"
onclick={(e) => {
e.stopPropagation();
playRow(row);
}}
onkeydown={() => {}}
>
<Icon name="play" size={11} />
</span>
{/if}
</button>
{/each}
{/each}
<button
type="button"
class="sg-ft"
class:on={hiKey === null}
onclick={() => submitSearch()}
>
<Icon name="search" size={13} />
<span>See all results for “{searchText.trim()}”</span>
<span class="kb">↵</span>
</button>
{/if}
</div>
{/if}
</form>
<div class="controls">
{#if dl.active > 0 || dlFlash}
<button
class="dl-ring"
title={dlFlash
? "Downloads finished"
: `${dl.active} downloading — open Downloads`}
onclick={() => { ui.view = "downloads"; ui.hubJump += 1; }}
>
<svg width="26" height="26" viewBox="0 0 26 26">
<circle class="track" cx="13" cy="13" r="9" />
<circle
class="prog"
cx="13"
cy="13"
r="9"
stroke-dasharray={RING_C}
stroke-dashoffset={dlFlash ? 0 : RING_C * (1 - dl.frac)}
transform="rotate(-90 13 13)"
/>
{#if dlFlash}
<path class="tick" d="M9 13.4l2.7 2.7 5.2-5.6" />
{:else}
<text x="13" y="16.5" text-anchor="middle">
{dl.active > 9 ? "9+" : dl.active}
</text>
{/if}
</svg>
</button>
{/if}
<button class="win-btn" title="Minimize" onclick={() => api.win.minimize()}>
<Icon name="minus" size={16} />
</button>
<button
class="win-btn"
title={maximized ? "Restore" : "Maximize"}
onclick={() => api.win.toggleMaximize().then(syncMaximized)}
>
<Icon name={maximized ? "restore" : "square"} size={13} />
</button>
<button class="win-btn close" title="Close" onclick={() => api.win.close()}>
<Icon name="x" size={16} />
</button>
</div>
</header>
<style>
/* Lines the brand up with the sidebar's content column below: the bar
already sits inside the 8px window frame, so 20px more reaches the
8px sidebar padding + 12px item padding. */
.titlebar {
grid-area: titlebar;
position: relative;
/* Above the sibling content panels, so the search suggestions
dropdown paints over album grids instead of interleaving. */
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding-left: 20px;
user-select: none;
-webkit-user-select: none;
}
/* 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 {
position: relative;
top: 4px;
}
/* Decorative: let mouse events fall through to the drag region. */
.brand {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.logo {
width: 20px;
height: 20px;
border-radius: 5px;
}
.name {
font-weight: 700;
font-size: 13px;
}
/* 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. */
.bar-search {
position: absolute;
left: 50%;
top: 7px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 30vw);
height: 24px;
padding: 0 12px;
border-radius: 12px;
background: var(--bg-elevated);
color: var(--text-muted);
}
/* Dark glass: the pill goes translucent so the acrylic reads through it
too — its alpha COMPOSITES over the .app canvas tint (0.55), so 0.35
here nets ~0.71 black where the pill sits: darker than the canvas
around it, still glass. Solid mode above stays untouched. */
:global(:root.glass) .bar-search {
background: rgba(0, 0, 0, 0.35);
}
/* The ONE change to the field: the focus ring was `--text-muted`, which
read as a dead grey border rather than as the field being live. It is the
accent now, and a ↵ hint appears beside the caret to say what Enter does.
Every other measurement above is untouched. */
.bar-search {
outline: 1px solid transparent;
transition:
outline-color 0.18s,
color 0.18s;
}
.bar-search:focus-within {
outline: 1px solid var(--accent);
color: var(--text-secondary);
}
.bar-search input {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
.bar-search input::placeholder {
color: var(--text-muted);
}
.kb {
font-size: 9px;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
}
/* ---- live suggestions dropdown ---- */
/* Wider than the field on both sides: at the field's own 360px, real record
titles ("Superman (Garage Practice) / The BBC Tracks / …") truncated in
every group. This is the one place the old -8px rule is deliberately
broken. Capped in height because the full grouped list is taller than the
window; the overflow is the app's own floating overlay bar
(`use:floatScroll`), which is invisible at rest. */
.suggest {
position: absolute;
/* Detached from the pill on purpose (Kyle, 2026-08-31): the pill's
box ends at 31px, so this leaves a 15px seam of bar showing between
field and panel instead of the two reading as one welded block —
and, the actual complaint, the panel's top edge lands ~9px BELOW
the content pane's top line instead of riding the same rule (38px
put the two edges within 4px and read as accidental alignment;
tuned live over the album page, 38/42/46, OS shots). */
top: 46px;
left: -70px;
right: -70px;
display: flex;
flex-direction: column;
padding: 6px;
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
z-index: 50;
max-height: 420px;
overflow-y: auto;
animation: ddin 0.17s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ddin {
from {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.suggest {
animation: none;
}
}
/* A section header: name, its total, and a hairline running to the edge. */
.sg-h {
display: flex;
align-items: center;
gap: 7px;
font-size: 9px;
font-weight: 900;
letter-spacing: 0.13em;
color: var(--text-muted);
text-transform: uppercase;
padding: 7px 8px 4px;
}
.sg-h .c {
background: var(--bg-highlight);
border-radius: 99px;
padding: 1px 6px;
font-size: 8.5px;
letter-spacing: 0.04em;
}
.sg-h .line {
flex: 1;
height: 1px;
background: var(--border);
}
.sg {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 5px 8px;
border-radius: 6px;
position: relative;
text-align: left;
color: var(--text-primary);
}
.sg:hover,
.sg.on {
background: var(--bg-highlight);
}
/* The keyboard selection carries an accent edge, so arrowing down the list
is legible as a *selection* rather than as another hover. */
.sg.on::before {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 2px;
background: var(--accent);
}
.sg img,
.sg-img {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex: none;
background: var(--bg-highlight);
}
.sg img.round,
.sg-img.round {
border-radius: 50%;
}
.sg-img.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.sg-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.sg .t {
font-size: 12.5px;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sg .s {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sg .d {
font-size: 10.5px;
color: var(--text-muted);
margin-left: auto;
flex: none;
}
/* Hover swaps the duration for the thing you actually want at that moment. */
.sg .play {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
color: #000;
display: none;
place-items: center;
flex: none;
margin-left: auto;
cursor: pointer;
}
.sg:hover .play,
.sg.on .play {
display: grid;
}
.sg:hover .d,
.sg.on .d {
display: none;
}
.own {
font-size: 8px;
font-weight: 900;
letter-spacing: 0.06em;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-press);
color: var(--text-secondary);
flex: none;
}
.sg-ft {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 8px;
margin-top: 4px;
border-top: 1px solid var(--border);
font-size: 11.5px;
font-weight: 800;
color: var(--text-secondary);
}
.sg-ft:hover,
.sg-ft.on {
color: #fff;
}
.sg-ft .kb {
margin-left: auto;
}
.chiprow {
display: flex;
flex-wrap: wrap;
padding: 2px 6px 6px;
}
.rc {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 99px;
background: var(--bg-highlight);
font-size: 11px;
font-weight: 700;
margin: 2px;
}
.rc:hover {
background: var(--bg-press);
}
.nomatch {
padding: 18px 10px;
text-align: center;
}
.nomatch-h {
font-size: 12px;
font-weight: 800;
}
.nomatch-s {
font-size: 11px;
color: var(--text-muted);
margin-top: 3px;
}
.controls {
margin-left: auto;
display: flex;
align-self: stretch;
}
/* download status ring */
.dl-ring {
align-self: center;
display: grid;
place-items: center;
width: 34px;
height: 30px;
margin-right: 4px;
border-radius: 7px;
}
.dl-ring:hover {
background: var(--bg-highlight);
}
.dl-ring .track {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 2.5;
}
.dl-ring .prog {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.dl-ring text {
fill: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.dl-ring .tick {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.win-btn {
width: 46px;
display: grid;
place-items: center;
color: var(--text-secondary);
border-radius: 0;
}
.win-btn:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.win-btn.close:hover {
background: #c42b1c;
color: #fff;
}
/* ── dark glass ────────────────────────────────────────────────────────
The window buttons are the one surface in this file that stands on the
CANVAS rather than on a pane, and the math is different there: the
canvas is .app's 0.55, not a pane's 0.7075, so the ground behind them
is lighter and a hover needs a stronger veil to make the same step.
0.12 is the value GlassBar's own .arrow:hover already uses for chrome
in this position. --glass-hover (0.08) is tuned for the 0.7075 pane and
would read as almost nothing up here. */
:global(:root.glass) .win-btn:hover,
:global(:root.glass) .dl-ring:hover {
background: rgba(255, 255, 255, 0.12);
}
/* The ⏎ keycap inside the search pill; the pill is already glass. */
:global(:root.glass) .kb {
background: var(--glass-hover);
}
/* The suggestions dropdown under glass (Kyle, 2026-08-31 — overriding
the earlier keep-menus-opaque call for THIS menu; Playlists/Playlist
keep theirs): dark FROSTED glass — real backdrop-filter plus a lighter
smoke. Plain smoke alone shipped first and Kyle bounced it ("full
transparent, not acrylic/blurred"): the page heading ghosted through
razor-sharp.
Why this surface may use backdrop-filter when the bars could not (the
black-flatten trap, see NowPlaying's head-periscope note): the filter
black-flattens only the FROST fraction of its backdrop, and this panel
is deliberately dark. Where page content sits behind it — the usual
case at the top of a page — the content blurs properly into fog;
where bare frost shows, the flatten just lands within a couple points
of the plain-smoke look this replaces (~0.92 black net), i.e. the
failure mode IS the previous design, not a visible slab. The bars
failed because they wanted to SHOW the frost; this panel wants to
bury it. */
/* blur(40): the first cut shipped blur(18) and Kyle could still READ the
album page's display-size heading through it — a blur only defeats
text once its radius reaches the glyph scale, and page headings here
are ~40px tall. contrast(0.85) compresses what survives (real acrylic
flattens contrast too; that is half of why you cannot read through
it). Tuned live over the artist page, 40/0.85/0.55 vs 56/0.78/0.6 —
the heavier mix read deader, not frostier.
The NOISE + SHEEN layers are the rest of the Windows acrylic recipe,
and they are what keeps the panel from reading as two materials
(Kyle, 2026-08-31: "only half of it seems fine"). The filter can only
re-emit light the page gives it: over the hero's bright side the fog
glows, over its dark side blur has nothing to show and the panel sat
flat dead — and on unpainted pages the frost fraction black-flattens
to the same flatness. Real acrylic never goes dead over black because
it composites a noise texture and tint over the blur; the fine
monochrome turbulence (4.5%) and the faint top sheen do that here, so
both halves read as one brushed material whatever is behind. */
:global(:root.glass) .suggest {
background-color: rgba(0, 0, 0, 0.55);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E"),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.055),
rgba(255, 255, 255, 0.015) 34%,
rgba(255, 255, 255, 0) 62%
);
backdrop-filter: blur(40px) saturate(1.3) contrast(0.85);
-webkit-backdrop-filter: blur(40px) saturate(1.3) contrast(0.85);
box-shadow:
var(--glass-ring),
0 10px 32px rgba(0, 0, 0, 0.55);
}
/* Interior chrome joins the white-veil glass vocabulary; the solid
tokens it uses otherwise are opaque and would float as mini-slabs on
the smoked sheet. */
:global(:root.glass) .sg:hover,
:global(:root.glass) .sg.on,
:global(:root.glass) .sg-h .c,
:global(:root.glass) .sg img,
:global(:root.glass) .sg-img,
:global(:root.glass) .rc {
background: var(--glass-hover);
}
:global(:root.glass) .rc:hover,
:global(:root.glass) .own {
background: var(--glass-press);
}
:global(:root.glass) .sg-h .line {
background: var(--glass-hairline);
}
:global(:root.glass) .sg-ft {
border-top-color: var(--glass-hairline);
}
</style>