1116 lines
39 KiB
Svelte
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>
|