989 lines
65 KiB
Svelte
989 lines
65 KiB
Svelte
<script>
|
||
// Settings — shape B from research/settings-redesign/index.html (Kyle,
|
||
// 2026-09-08): one long page of section cards with a sticky jump index
|
||
// on the left, segments and switches instead of native dropdowns (a
|
||
// menu-styled select where a list is long), comfy rows that keep their
|
||
// explanation, and the Networks card folded in from Community → You so
|
||
// every switch lives in one place. The page fills the pane.
|
||
import { app, network, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||
import { setWordFill } from "../lib/lyrics.svelte.js";
|
||
import { applyGlass } from "../lib/glass.js";
|
||
import { openFirstRun } from "../lib/firstRun.svelte.js";
|
||
import { updater, checkForUpdates, setUpdater, installUpdate } from "../lib/updater.svelte.js";
|
||
// The updater card reads the backend's snapshot; `pct` is the install download.
|
||
const up = $derived(updater.status);
|
||
const avail = $derived(up?.available ?? null);
|
||
const pct = $derived(updater.progress?.total ? Math.min(100, Math.round((updater.progress.received / updater.progress.total) * 100)) : null);
|
||
// The private-release token: typed here, saved to the Credential Manager
|
||
// by the backend, never read back into the page.
|
||
let tokenDraft = $state("");
|
||
async function saveToken() {
|
||
const t = tokenDraft.trim();
|
||
if (!t) return;
|
||
await setUpdater({ token: t });
|
||
tokenDraft = "";
|
||
}
|
||
import * as api from "../lib/api.js";
|
||
import Icon from "../components/Icon.svelte";
|
||
import Chooser from "../components/Chooser.svelte";
|
||
import {
|
||
community,
|
||
refreshFreenet,
|
||
refreshNodeInfo,
|
||
refreshLinks,
|
||
refreshPersona,
|
||
refreshInbox,
|
||
personaKey,
|
||
adapterLabel,
|
||
adapterConnected,
|
||
statusKind,
|
||
detectNode,
|
||
startNode,
|
||
stopNode,
|
||
setFreenetEnabled,
|
||
setLoadRemoteImages,
|
||
linkSelf,
|
||
unlinkSelf,
|
||
publishInbox,
|
||
checkInbox,
|
||
} from "../lib/community.svelte.js";
|
||
|
||
let settings = $state(null);
|
||
let saving = $state(false);
|
||
// Lyric alignment model (Tier B word timing) — downloaded only from the
|
||
// explicit click below, per the preparation-worker consent charter.
|
||
let lyricModel = $state(null);
|
||
let modelBusy = $state(false);
|
||
let modelPct = $state(0);
|
||
let modelError = $state("");
|
||
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
|
||
// row below, shown only once the aligner model above exists.
|
||
let sepModel = $state(null);
|
||
let sepBusy = $state(false);
|
||
let sepPct = $state(0);
|
||
let sepError = $state("");
|
||
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
|
||
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
|
||
$effect(() => {
|
||
let unlisten = null;
|
||
let unlistenSep = null;
|
||
void import("@tauri-apps/api/event").then(({ listen }) => {
|
||
listen("lyrics:model-progress", (e) => {
|
||
const p = e.payload ?? {};
|
||
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
|
||
}).then((u) => (unlisten = u));
|
||
listen("lyrics:sep-model-progress", (e) => {
|
||
const p = e.payload ?? {};
|
||
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
|
||
}).then((u) => (unlistenSep = u));
|
||
});
|
||
return () => {
|
||
unlisten?.();
|
||
unlistenSep?.();
|
||
};
|
||
});
|
||
async function downloadModel() {
|
||
if (modelBusy) return;
|
||
modelBusy = true;
|
||
modelError = "";
|
||
modelPct = 0;
|
||
try {
|
||
await api.lyricModelDownload();
|
||
lyricModel = await api.lyricModelStatus();
|
||
} catch (e) {
|
||
modelError = String(e).slice(0, 160);
|
||
} finally {
|
||
modelBusy = false;
|
||
}
|
||
}
|
||
async function downloadSepModel() {
|
||
if (sepBusy) return;
|
||
sepBusy = true;
|
||
sepError = "";
|
||
sepPct = 0;
|
||
try {
|
||
await api.vocalSepDownload();
|
||
sepModel = await api.vocalSepStatus();
|
||
} catch (e) {
|
||
sepError = String(e).slice(0, 160);
|
||
} finally {
|
||
sepBusy = false;
|
||
}
|
||
}
|
||
let savedFlash = $state(false);
|
||
let saveError = $state("");
|
||
let refreshingArtists = $state(false);
|
||
let artistRefreshResult = $state("");
|
||
let refreshingAlbums = $state(false);
|
||
let albumRefreshResult = $state("");
|
||
|
||
async function load() {
|
||
try {
|
||
settings = await api.getSettings();
|
||
} catch {
|
||
settings = null;
|
||
}
|
||
}
|
||
load();
|
||
|
||
// The networks the page shows come from the Community store; asked for
|
||
// on open so the switch and the node line are current without visiting
|
||
// the Community page first.
|
||
$effect(() => {
|
||
void Promise.all([refreshFreenet(), refreshNodeInfo(), refreshLinks(), refreshPersona(), refreshInbox()]).catch(() => {});
|
||
});
|
||
|
||
async function commit(patch) {
|
||
saving = true;
|
||
saveError = "";
|
||
try {
|
||
await api.setSettings(patch);
|
||
await load();
|
||
savedFlash = true;
|
||
setTimeout(() => (savedFlash = false), 1500);
|
||
return true;
|
||
} catch (e) {
|
||
saveError = `Could not save: ${e}`;
|
||
return false;
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function setVolumeNormalization(next) {
|
||
if (next !== settings.volume_normalization) {
|
||
await commit({ volumeNormalization: next });
|
||
}
|
||
}
|
||
|
||
async function setGlassMode(next) {
|
||
if (next !== settings.glass_mode) {
|
||
// Applies live — the acrylic backdrop and the CSS thinning land
|
||
// together the moment the save succeeds.
|
||
if (await commit({ glassMode: next })) applyGlass(next);
|
||
}
|
||
}
|
||
|
||
async function setWordTiming(next) {
|
||
if (next !== settings.word_timing) {
|
||
await commit({ wordTiming: next });
|
||
// Applies live: surfaces stop/start rendering word markup
|
||
// reactively, and turning it on asks for the current track.
|
||
setWordFill(next);
|
||
}
|
||
}
|
||
|
||
async function chooseDownloadDir() {
|
||
const dir = await api.pickFolder();
|
||
if (dir) await commit({ downloadDir: dir });
|
||
}
|
||
|
||
async function addSharedFolder() {
|
||
const dir = await api.pickFolder();
|
||
if (dir && !settings.shared_folders.includes(dir)) {
|
||
await commit({ sharedFolders: [...settings.shared_folders, dir] });
|
||
}
|
||
}
|
||
|
||
async function setLibraryLayout(value) {
|
||
if (value !== settings.library_layout) await commit({ libraryLayout: value });
|
||
}
|
||
|
||
async function removeSharedFolder(folder) {
|
||
await commit({
|
||
sharedFolders: settings.shared_folders.filter((f) => f !== folder),
|
||
});
|
||
}
|
||
|
||
async function setSlots(n) {
|
||
await commit({ uploadSlots: Math.max(1, Math.min(10, n)) });
|
||
}
|
||
|
||
async function setAudioOutput(device) {
|
||
if (device !== (settings.audio_output_device ?? "")) {
|
||
await commit({ audioOutputDevice: device });
|
||
}
|
||
}
|
||
|
||
const SOURCE_PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
|
||
function sourcePrefsFor(mode) {
|
||
const stored = mode === "stream"
|
||
? settings.stream_source_prefs
|
||
: settings.download_source_prefs;
|
||
return { ...SOURCE_PREF_DEFAULTS, ...(stored ?? {}) };
|
||
}
|
||
|
||
async function setSourcePref(mode, field, value) {
|
||
const next = { ...sourcePrefsFor(mode), [field]: value };
|
||
const patch = mode === "stream"
|
||
? { streamSourcePrefs: next }
|
||
: { downloadSourcePrefs: next };
|
||
if (await commit(patch)) await refreshSourcePrefs();
|
||
}
|
||
|
||
async function setArtistHeroUpscaler(upscaler) {
|
||
if (upscaler === settings.artist_hero_upscaler) return;
|
||
if (await commit({ artistHeroUpscaler: upscaler })) {
|
||
clearCatalogHeroArtCache();
|
||
}
|
||
}
|
||
|
||
async function forget() {
|
||
await api.forgetCredentials();
|
||
app.username = "";
|
||
await load();
|
||
}
|
||
|
||
async function setStreamRetention(value) {
|
||
if (value === "forever") {
|
||
if (!settings.stream_keep_forever) {
|
||
await commit({ streamKeepForever: true });
|
||
}
|
||
} else {
|
||
const days = Number(value);
|
||
if (settings.stream_keep_forever || days !== settings.stream_retention_days) {
|
||
await commit({ streamKeepForever: false, streamRetentionDays: days });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function setStreamCacheMax(value) {
|
||
const mb = Number(value);
|
||
if (mb !== settings.stream_cache_max_mb) {
|
||
await commit({ streamCacheMaxMb: mb });
|
||
}
|
||
}
|
||
|
||
async function setReplaceEmbeddedArt(value) {
|
||
const next = value === "replace";
|
||
if (next !== settings.replace_embedded_art) {
|
||
await commit({ replaceEmbeddedArt: next });
|
||
}
|
||
}
|
||
|
||
async function setEmbedArtMaxEdge(value) {
|
||
const next = Number(value);
|
||
if (next !== settings.embed_art_max_edge) {
|
||
await commit({ embedArtMaxEdge: next });
|
||
}
|
||
}
|
||
|
||
async function setWriteFolderCover(value) {
|
||
const next = value === "write";
|
||
if (next !== settings.write_folder_cover) {
|
||
await commit({ writeFolderCover: next });
|
||
}
|
||
}
|
||
|
||
// Sequential (one album ≈ one second: MusicBrainz is throttled), so a big
|
||
// library takes a while — the button stays busy until the sweep finishes.
|
||
async function refreshAllAlbumArt() {
|
||
if (refreshingAlbums) return;
|
||
refreshingAlbums = true;
|
||
albumRefreshResult = "";
|
||
try {
|
||
const count = await api.refreshAllAlbumArt();
|
||
albumRefreshResult = `Upgraded ${count} album ${count === 1 ? "cover" : "covers"}.`;
|
||
} catch (e) {
|
||
albumRefreshResult = `Refresh failed: ${e}`;
|
||
} finally {
|
||
refreshingAlbums = false;
|
||
}
|
||
}
|
||
|
||
async function refreshArtistArt() {
|
||
if (refreshingArtists) return;
|
||
refreshingArtists = true;
|
||
artistRefreshResult = "";
|
||
try {
|
||
const count = await api.refreshArtistArt();
|
||
artistRefreshResult = `Refreshed ${count} artist ${count === 1 ? "portrait" : "portraits"}.`;
|
||
} catch (e) {
|
||
artistRefreshResult = `Refresh failed: ${e}`;
|
||
} finally {
|
||
refreshingArtists = false;
|
||
}
|
||
}
|
||
|
||
// ---- networks (from Community → You) ----------------------------------
|
||
const online = $derived(app.connection === "online");
|
||
const me = $derived(app.username ?? "");
|
||
const key = $derived(personaKey());
|
||
const freenetOn = $derived(!!community.freenet?.enabled);
|
||
const nodeUp = $derived(adapterConnected(community.freenet?.status));
|
||
const status = $derived(adapterLabel(community.freenet?.status));
|
||
const ownLink = $derived(community.links?.own ?? null);
|
||
/** The stored link is only "current" for the signed-in account and the
|
||
* persona this device holds now — a different login or key is unlinked. */
|
||
const ownLinkCurrent = $derived(
|
||
!!ownLink && !!key && !!me && ownLink.username_normalized === me.trim().toLowerCase() && ownLink.persona_key === key,
|
||
);
|
||
const fmtCount = (n) => (n ?? 0).toLocaleString();
|
||
function fmtWhen(ms) {
|
||
if (!ms) return "";
|
||
const d = new Date(ms);
|
||
const now = new Date();
|
||
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||
}
|
||
|
||
// ---- the long lists, as [value, label] for the chooser -------------------
|
||
const RETENTION = [["1", "1 day"], ["3", "3 days"], ["7", "1 week"], ["14", "2 weeks"], ["30", "1 month (recommended)"], ["90", "3 months"], ["forever", "Forever — save to library"]];
|
||
const CACHE = [["512", "512 MB"], ["1024", "1 GB"], ["2048", "2 GB (recommended)"], ["5120", "5 GB"], ["10240", "10 GB"], ["20480", "20 GB"], ["0", "Unlimited"]];
|
||
const MIN_QUALITY = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
|
||
const audioOutputs = $derived.by(() => {
|
||
if (!settings) return [];
|
||
const out = [["", "System default (recommended)"]];
|
||
const cur = settings.audio_output_device;
|
||
if (cur && !settings.audio_output_devices.includes(cur)) out.push([cur, `${cur} (unavailable)`]);
|
||
for (const d of settings.audio_output_devices) out.push([d, d]);
|
||
return out;
|
||
});
|
||
|
||
// ---- the index ---------------------------------------------------------
|
||
const SECTIONS = [
|
||
{ id: "account", title: "Account", group: "You", icon: "users" },
|
||
{ id: "networks", title: "Networks", group: "You", icon: "globe" },
|
||
{ id: "updates", title: "Updates", group: "You", icon: "download" },
|
||
{ id: "appearance", title: "Appearance", group: "Look & sound", icon: "eye" },
|
||
{ id: "playback", title: "Playback", group: "Look & sound", icon: "volume" },
|
||
{ id: "lyrics", title: "Lyrics", group: "Look & sound", icon: "lyrics" },
|
||
{ id: "streaming", title: "Streaming", group: "Music", icon: "music" },
|
||
{ id: "library", title: "Library & downloads", group: "Music", icon: "library" },
|
||
{ id: "sharing", title: "Sharing", group: "Music", icon: "export" },
|
||
{ id: "quality", title: "Source quality", group: "Music", icon: "layers" },
|
||
{ id: "albumart", title: "Album artwork", group: "Artwork", icon: "disc" },
|
||
{ id: "artistart", title: "Artist artwork", group: "Artwork", icon: "image" },
|
||
];
|
||
const GROUPS = ["You", "Look & sound", "Music", "Artwork"];
|
||
const iconOf = (id) => SECTIONS.find((x) => x.id === id)?.icon ?? "settings";
|
||
let active = $state("account");
|
||
let pageEl = $state(null);
|
||
|
||
/** The scroll container is the pane's content, not the window. */
|
||
const scroller = () => pageEl?.closest(".content") ?? null;
|
||
|
||
function jump(id) {
|
||
const el = pageEl?.querySelector(`#set-${id}`);
|
||
if (!el) return;
|
||
active = id;
|
||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
||
}
|
||
|
||
// Scroll spy: the topmost section whose head has passed the top of the
|
||
// pane is the one the index highlights.
|
||
$effect(() => {
|
||
const root = scroller();
|
||
if (!root || !pageEl) return;
|
||
const onScroll = () => {
|
||
const top = root.getBoundingClientRect().top + 80;
|
||
let best = SECTIONS[0].id;
|
||
for (const s of SECTIONS) {
|
||
const el = pageEl.querySelector(`#set-${s.id}`);
|
||
if (el && el.getBoundingClientRect().top <= top) best = s.id;
|
||
}
|
||
active = best;
|
||
};
|
||
root.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => root.removeEventListener("scroll", onScroll);
|
||
});
|
||
</script>
|
||
|
||
{#snippet sw(on, words, onchange, disabled = false)}
|
||
<span class="tval">{on ? words[0] : words[1]}</span>
|
||
<button class="toggle" class:on onclick={() => onchange(!on)} {disabled} role="switch" aria-checked={on}><i></i></button>
|
||
{/snippet}
|
||
|
||
{#snippet seg(options, value, onpick, disabled = false)}
|
||
<span class="seg" role="radiogroup">
|
||
{#each options as [v, label] (v)}
|
||
<button class:on={v === value} onclick={() => v !== value && onpick(v)} {disabled} role="radio" aria-checked={v === value}>{label}</button>
|
||
{/each}
|
||
</span>
|
||
{/snippet}
|
||
|
||
<div class="settings" bind:this={pageEl}>
|
||
{#if !settings}
|
||
<p class="muted">Loading…</p>
|
||
{:else}
|
||
<div class="indexwrap">
|
||
<nav class="index" aria-label="Settings sections">
|
||
<div class="h">Settings</div>
|
||
{#each GROUPS as g (g)}
|
||
<div class="grp">{g}</div>
|
||
{#each SECTIONS.filter((s) => s.group === g) as s (s.id)}
|
||
<button class="jump" class:on={active === s.id} onclick={() => jump(s.id)}><span class="ic"><Icon name={s.icon} size={13} /></span>{s.title}</button>
|
||
{/each}
|
||
{/each}
|
||
<div class="state" aria-live="polite">
|
||
{#if saving}<span class="pill">Saving…</span>{:else if savedFlash}<span class="pill ok"><Icon name="check" size={12} /> Saved</span>{:else if saveError}<span class="pill err">{saveError}</span>{/if}
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="body">
|
||
<!-- ── Account ─────────────────────────────────────────── -->
|
||
<section id="set-account" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("account")} size={14} /></span><h2>Account</h2><span class="blurb">Your Soulseek login and where the password lives.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Soulseek account {#if settings.connected || online}<span class="tag ok">online</span>{:else if settings.username}<span class="tag off">offline</span>{/if}</div>
|
||
<div class="hint">{settings.username ? `Signed in as ${settings.username}.` : "Not signed in."} Your password is stored in the Windows Credential Manager, not in a plain file.</div>
|
||
</div>
|
||
<div class="ctl">
|
||
{#if app.connection !== "online"}
|
||
<button class="ax-white" onclick={() => (app.showLogin = true)}>Connect</button>
|
||
{/if}
|
||
{#if settings.has_password}
|
||
<button class="ax-ghost" onclick={forget}>Forget credentials</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">First-time setup</div>
|
||
<div class="hint">The welcome tour from the first launch: library folder, account, community peer, look. Runs again from here and changes nothing until you do.</div>
|
||
</div>
|
||
<div class="ctl"><button class="ax-ghost" onclick={() => openFirstRun("settings")}>Run again</button></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Networks (from Community → You) ─────────────────── -->
|
||
<section id="set-networks" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("networks")} size={14} /></span><h2>Networks</h2><span class="blurb">The two networks this app speaks, and whether they are tied together.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Soulseek {#if online}<span class="tag ok">online</span>{:else}<span class="tag off">offline</span>{/if}{#if ownLinkCurrent}<span class="tag ok">linked</span>{/if}</div>
|
||
<div class="hint">
|
||
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in.{/if}
|
||
{#if !key}
|
||
Create a SoulStream identity on Community → You to link this account to it.
|
||
{:else if !me}
|
||
Sign in to Soulseek to link this account to your identity.
|
||
{:else if ownLinkCurrent}
|
||
Linked to your SoulStream identity — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a message to yourself. Other SoulStream users who ask get a signed answer.
|
||
{:else}
|
||
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published.
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="ctl">
|
||
{#if key && me}
|
||
{#if ownLinkCurrent}
|
||
<button class="ax-ghost" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
|
||
{:else}
|
||
<button class="ax-fill" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link to my identity"}</button>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Freenet <span class="tag">experimental</span></div>
|
||
<div class="hint">
|
||
{#if freenetOn}
|
||
Local node on 127.0.0.1:{community.freenet?.ws_port ?? 7509} · <span class="tag {status.tone}">{status.text}</span>
|
||
<button class="lnk" onclick={() => detectNode()} disabled={!!community.busy.detect}>{community.busy.detect ? "checking…" : "check again"}</button>
|
||
{:else}
|
||
Off. Carries your identity, profile, playlists, friend links, Spaces and holdings. Turning it on publishes nothing; turning it off is instant and touches nothing else in the app.
|
||
{/if}
|
||
</div>
|
||
{#if freenetOn && statusKind(community.freenet?.status) === "node-absent"}
|
||
<div class="noderow">
|
||
{#if community.nodeInfo?.binary}
|
||
<button class="ax-fill sm" onclick={() => startNode()} disabled={!!community.busy.node}><Icon name="disc" size={12} /> {community.busy.node ? "Starting…" : "Start node"}</button>
|
||
<span class="hint">runs the installed peer{#if community.nodeInfo.version} ({community.nodeInfo.version}){/if} in the background · telemetry pinned off · data in the app folder</span>
|
||
{:else}
|
||
<span class="hint warn">No Freenet peer found on this machine — install it (cargo install freenet) and check again.</span>
|
||
{/if}
|
||
</div>
|
||
{:else if freenetOn && community.nodeInfo?.started_by_app}
|
||
<div class="noderow">
|
||
<button class="ax-ghost sm" onclick={() => stopNode()} disabled={!!community.busy.node}>{community.busy.node ? "Stopping…" : "Stop node"}</button>
|
||
<span class="hint">started by SoulStream · keeps running when the app closes; this button stops it</span>
|
||
</div>
|
||
{/if}
|
||
<details class="disclose">
|
||
<summary>Before you rely on it — seven things that are true</summary>
|
||
<ul>
|
||
<li>A desktop background peer must run; it uses network, disk, memory and CPU.</li>
|
||
<li>The network is alpha. Things you publish are replicated and cannot be guaranteed erased.</li>
|
||
<li>Freenet is not an anonymity service.</li>
|
||
<li>The node's own diagnostics are a node setting SoulStream pins off; verify after upgrades.</li>
|
||
<li>Nothing is published when you enable this. Publishing is always a button.</li>
|
||
<li>Nothing here ever says "online" — a successful fetch is not presence.</li>
|
||
<li>Phones and TVs cannot run the same peer today.</li>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
<div class="ctl">{@render sw(freenetOn, ["on", "off"], (v) => setFreenetEnabled(v), !!community.busy.freenet)}</div>
|
||
</div>
|
||
{#if freenetOn}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Load remote profile images</div>
|
||
<div class="hint">Other people's avatars, fetched from the network — verified, resized, cached by hash. Off shows their identicon only.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(community.freenet?.load_remote_images ?? true, ["on", "off"], (v) => setLoadRemoteImages(v), !!community.busy.images)}</div>
|
||
</div>
|
||
{#if key}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Encrypted inbox {#if community.inbox?.published}<span class="tag ok"><Icon name="lock" size={10} /> key {community.inbox.box_key_id}</span>{/if}</div>
|
||
<div class="hint">{#if community.inbox?.published}Friend requests and shares arrive sealed to a key kept on this device{#if community.inbox.last_check} · checked {fmtWhen(community.inbox.last_check * 1000)}{/if}.{:else}Lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between.{/if}</div>
|
||
</div>
|
||
<div class="ctl">
|
||
{#if community.inbox?.published}
|
||
<button class="ax-ghost" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
|
||
{:else}
|
||
<button class="ax-fill" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an inbox"}</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
{#if community.error}<div class="foot warn">{community.error}</div>{/if}
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Updates ─────────────────────────────────────────── -->
|
||
<section id="set-updates" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("updates")} size={14} /></span><h2>Updates</h2><span class="blurb">New releases from the project, on the channel you pick. Installing is always a button.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">SoulStream {up?.version ?? "…"} {#if up?.dev_build}<span class="tag off">dev build</span>{/if}{#if avail}<span class="tag acc">{avail.version} available</span>{:else if up?.last_check && !up?.last_error}<span class="tag ok">up to date</span>{/if}</div>
|
||
<div class="hint">
|
||
{#if up?.checking}Checking {up.channel}…
|
||
{:else if up?.last_error}<span class="warn">Last check failed: {up.last_error}</span>
|
||
{:else if up?.last_check}Checked {fmtWhen(up.last_check * 1000)} on the {up.channel} channel.
|
||
{:else}Not checked yet on the {up?.channel ?? "stable"} channel.{/if}
|
||
{#if updater.error}<span class="warn"> {updater.error}</span>{/if}
|
||
</div>
|
||
</div>
|
||
<div class="ctl"><button class="ax-ghost" onclick={() => checkForUpdates()} disabled={!!up?.checking || !!up?.installing}>{up?.checking ? "Checking…" : "Check now"}</button></div>
|
||
</div>
|
||
{#if avail}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">{avail.version} is ready {#if avail.date}<span class="tag">{new Date(avail.date * 1000).toLocaleDateString()}</span>{/if}</div>
|
||
{#if avail.notes}<div class="hint notes">{avail.notes}</div>{/if}
|
||
{#if up?.installing}
|
||
<div class="hint">{pct != null ? `Downloading · ${pct}%` : "Downloading…"} — the app closes into the installer and comes back on its own.</div>
|
||
<span class="pbar" aria-hidden="true"><i style="width: {pct ?? 6}%"></i></span>
|
||
{:else if up?.dev_build}
|
||
<div class="hint warn">This is a dev build run from the target folder; the installer would set up the packaged app beside it.</div>
|
||
{/if}
|
||
</div>
|
||
<div class="ctl"><button class="ax-fill" onclick={() => installUpdate()} disabled={!!up?.installing}><Icon name="download" size={12} /> {up?.installing ? "Installing…" : "Install and restart"}</button></div>
|
||
</div>
|
||
{/if}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Channel</div>
|
||
<div class="hint">Stable follows the releases. Nightly is built from <b>main</b> every day and can break; switching back to Stable offers the next stable build even when its number is lower.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["stable", "Stable"], ["nightly", "Nightly"]], up?.channel ?? "stable", (v) => setUpdater({ channel: v }), !up || !!up.installing)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Check automatically</div>
|
||
<div class="hint">Shortly after launch and every six hours. Finding one shows a toast; nothing installs on its own.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(up?.auto_check ?? true, ["on", "off"], (v) => setUpdater({ autoCheck: v }), !up)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Access token {#if up?.has_token}<span class="tag ok">set</span>{:else if up?.source_kind === "github"}<span class="tag warn">needed for a private repository</span>{/if}</div>
|
||
<div class="hint">The project's repository is private while the alpha runs: releases are read with a token that can only read them. Paste the one you were given — it goes to the Windows Credential Manager, not a plain file, and leaves with Clear.</div>
|
||
</div>
|
||
<div class="ctl">
|
||
<input type="password" class="mono tok" bind:value={tokenDraft} placeholder={up?.has_token ? "••••••••••••" : "github_pat_…"} spellcheck="false" autocomplete="off" aria-label="Access token" onkeydown={(e) => e.key === "Enter" && saveToken()} disabled={!up} />
|
||
<button class="ax-white" onclick={saveToken} disabled={!tokenDraft.trim()}>Save</button>
|
||
{#if up?.has_token}<button class="ax-ghost" onclick={() => setUpdater({ token: "" })}>Clear</button>{/if}
|
||
</div>
|
||
</div>
|
||
<details class="disclose src">
|
||
<summary>Update source</summary>
|
||
<div class="hint">The manifest each channel reads (<code>latest.json</code>, Tauri's updater format, signed with the project's key). Leave empty for the project's GitHub releases; point it at a mirror or your own Gitea to update from there.</div>
|
||
<label class="srcrow"><span>Stable</span><input class="mono" value={up?.endpoint_stable ?? ""} placeholder={up?.default_stable ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointStable: e.currentTarget.value })} /></label>
|
||
<label class="srcrow"><span>Nightly</span><input class="mono" value={up?.endpoint_nightly ?? ""} placeholder={up?.default_nightly ?? ""} spellcheck="false" onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setUpdater({ endpointNightly: e.currentTarget.value })} /></label>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Appearance ──────────────────────────────────────── -->
|
||
<section id="set-appearance" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("appearance")} size={14} /></span><h2>Appearance</h2><span class="blurb">How the window is drawn.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Dark glass</div>
|
||
<div class="hint">Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Cards, rows and highlights thin out with it; menus and dialogs stay solid so nothing gets hard to read.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(!!settings.glass_mode, ["dark glass", "solid"], (v) => setGlassMode(v), saving)}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Playback ────────────────────────────────────────── -->
|
||
<section id="set-playback" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("playback")} size={14} /></span><h2>Playback</h2><span class="blurb">Output and loudness.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Audio output</div>
|
||
<div class="hint">System default follows Windows when headphones or speakers change.</div>
|
||
</div>
|
||
<div class="ctl"><Chooser options={audioOutputs} value={settings.audio_output_device ?? ""} onchange={(v) => setAudioOutput(v)} disabled={saving} /></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Normalize volume</div>
|
||
<div class="hint">Plays every track at roughly the same loudness (−14 LUFS, like Spotify), so quiet and loud masters stop needing the volume knob. A track is measured while it first streams and matched exactly from then on; until that finishes SoulStream will turn it down but never up, so a quiet intro can't make the chorus too loud.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(!!settings.volume_normalization, ["on", "off"], (v) => setVolumeNormalization(v), saving)}</div>
|
||
</div>
|
||
<div class="foot">Changes apply immediately and keep the current track near the same position.</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Lyrics ──────────────────────────────────────────── -->
|
||
<section id="set-lyrics" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("lyrics")} size={14} /></span><h2>Lyrics</h2><span class="blurb">Line sync is always on; word fill is the experiment.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Word-by-word fill <span class="tag">experimental</span></div>
|
||
<div class="hint">Fills each word as it is sung, karaoke-style. Still experimental: timing can drift on dense or echo-heavy songs. Off keeps the reliable line-by-line sync everywhere and pauses the background word analysis.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(!!settings.word_timing, ["on", "off"], (v) => setWordTiming(v), saving)}</div>
|
||
</div>
|
||
{#if settings.word_timing}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Precise word timing</div>
|
||
<div class="hint">Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that listens to each lyric line and times every word against the actual singing, in most languages. Runs entirely on this computer, in the background, never against the playing track. Without it, word timing stays a lighter estimate. Already-timed songs re-prepare automatically the next time you open their lyrics.</div>
|
||
{#if modelError}<div class="hint warn">Download failed: {modelError}</div>{/if}
|
||
</div>
|
||
<div class="ctl">
|
||
{#if lyricModel?.present}
|
||
<span class="tag ok"><Icon name="check" size={11} /> Downloaded</span>
|
||
{:else}
|
||
<button class="ax-fill" disabled={modelBusy} onclick={downloadModel}>{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{#if lyricModel?.present}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Vocal isolation</div>
|
||
<div class="hint">Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from the Ultimate Vocal Remover project) that lifts the singing away from the band before words are timed — noticeably steadier timing on dense mixes. Same rules as above: runs on this computer, in the background, never against the playing track.</div>
|
||
{#if sepError}<div class="hint warn">Download failed: {sepError}</div>{/if}
|
||
</div>
|
||
<div class="ctl">
|
||
{#if sepModel?.present}
|
||
<span class="tag ok"><Icon name="check" size={11} /> Downloaded</span>
|
||
{:else}
|
||
<button class="ax-fill" disabled={sepBusy} onclick={downloadSepModel}>{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Streaming ───────────────────────────────────────── -->
|
||
<section id="set-streaming" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("streaming")} size={14} /></span><h2>Streaming</h2><span class="blurb">What happens to a track after you streamed it.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Keep streamed tracks</div>
|
||
<div class="hint">Streamed songs stay in a private cache for instant replay — not in your library. Forever adds everything you stream to the library instead.</div>
|
||
</div>
|
||
<div class="ctl"><Chooser options={RETENTION} value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)} onchange={(v) => setStreamRetention(v)} disabled={saving} /></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Cache size limit</div>
|
||
<div class="hint">Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.</div>
|
||
</div>
|
||
<div class="ctl"><Chooser options={CACHE} value={String(settings.stream_cache_max_mb)} onchange={(v) => setStreamCacheMax(v)} disabled={saving} /></div>
|
||
</div>
|
||
<div class="foot">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Library & downloads ─────────────────────────────── -->
|
||
<section id="set-library" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("library")} size={14} /></span><h2>Library & downloads</h2><span class="blurb">Where music lands and how it is filed.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Library folder</div>
|
||
<div class="path">{settings.download_dir}</div>
|
||
<div class="hint">Your downloaded-music library, always shared. Folder changes apply after reconnecting.</div>
|
||
</div>
|
||
<div class="ctl"><button class="ax-white" onclick={chooseDownloadDir}>Change…</button></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Album folder format</div>
|
||
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder. Full dates display as DD-MM-YYYY. New downloads are organized into this format; existing files are not moved.</div>
|
||
</div>
|
||
<div class="ctl">
|
||
<input class="mono" value={settings.library_layout} onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()} onchange={(e) => setLibraryLayout(e.currentTarget.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Sharing ─────────────────────────────────────────── -->
|
||
<section id="set-sharing" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("sharing")} size={14} /></span><h2>Sharing</h2><span class="blurb">Sharing keeps you a good citizen — leechers get banned.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Shared folders</div>
|
||
<div class="hint">Your library folder is always shared ({fmtCount(settings.share_files)} files indexed). Add extra folders here.</div>
|
||
</div>
|
||
<div class="ctl"><button class="ax-white" onclick={addSharedFolder}>Add folder…</button></div>
|
||
</div>
|
||
{#if settings.shared_folders.length > 0}
|
||
<div class="folders">
|
||
{#each settings.shared_folders as folder (folder)}
|
||
<div class="folder"><span class="fpath">{folder}</span><button class="x" title="Stop sharing" onclick={() => removeSharedFolder(folder)}><Icon name="x" size={14} /></button></div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Upload slots</div>
|
||
<div class="hint">How many peers can download from you at once.</div>
|
||
</div>
|
||
<div class="ctl">
|
||
<span class="stepper">
|
||
<button onclick={() => setSlots(settings.upload_slots - 1)} disabled={saving} aria-label="Fewer slots"><Icon name="minus" size={14} /></button>
|
||
<span>{settings.upload_slots}</span>
|
||
<button onclick={() => setSlots(settings.upload_slots + 1)} disabled={saving} aria-label="More slots"><Icon name="plus" size={14} /></button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Source quality ──────────────────────────────────── -->
|
||
<section id="set-quality" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("quality")} size={14} /></span><h2>Source quality</h2><span class="blurb">Independent preferences for streaming and downloading.</span></div>
|
||
<div class="card">
|
||
<div class="foot top">The ceiling and floor bound which peer files qualify; priority decides whether the resolver picks the best format or the fastest transfer among them. Fastest estimates arrival from file size ÷ peer speed plus queue wait — a large FLAC from a fast peer can win.</div>
|
||
{#each [{ key: "stream", label: "Streaming" }, { key: "download", label: "Downloads" }] as mode (mode.key)}
|
||
<div class="subgrp">{mode.label}<span class="ln"></span></div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Maximum</div>
|
||
<div class="hint">Skip files above this ceiling — lossy-only avoids huge lossless transfers.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["any", "No limit"], ["lossy_only", "Lossy only"]], sourcePrefsFor(mode.key).max, (v) => setSourcePref(mode.key, "max", v), saving)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Minimum</div>
|
||
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
|
||
</div>
|
||
<div class="ctl"><Chooser options={MIN_QUALITY} value={sourcePrefsFor(mode.key).min} onchange={(v) => setSourcePref(mode.key, "min", v)} disabled={saving} /></div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Priority</div>
|
||
<div class="hint">Only files inside the window above compete.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["quality", "Best quality"], ["speed", "Fastest transfer"]], sourcePrefsFor(mode.key).priority, (v) => setSourcePref(mode.key, "priority", v), saving)}</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Album artwork ───────────────────────────────────── -->
|
||
<section id="set-albumart" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("albumart")} size={14} /></span><h2>Album artwork</h2><span class="blurb">Covers come from Deezer, Apple and the Cover Art Archive; the catalog's wins, at the highest resolution any source serves.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Art inside downloaded files</div>
|
||
<div class="hint">Replace whatever picture the uploader embedded with the canonical cover, or keep theirs when present.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["replace", "Replace with canonical"], ["keep", "Keep uploader's art"]], settings.replace_embedded_art ? "replace" : "keep", (v) => setReplaceEmbeddedArt(v), saving)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Embedded cover size</div>
|
||
<div class="hint">Longest edge of the copy written into each audio file. The full-resolution original is always kept in the album folder and art cache.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["0", "Original"], ["1500", "1500 px"], ["1000", "1000 px"]], String(settings.embed_art_max_edge), (v) => setEmbedArtMaxEdge(v), saving)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Album folder cover</div>
|
||
<div class="hint">Write the canonical cover as cover.jpg and folder.jpg so Explorer and other players show the same artwork.</div>
|
||
</div>
|
||
<div class="ctl">{@render sw(!!settings.write_folder_cover, ["write", "leave"], (v) => setWriteFolderCover(v ? "write" : "skip"), saving)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Re-fetch all album covers</div>
|
||
<div class="hint">Upgrade every library album to the canonical high-resolution cover — cache, embedded tags and folder files. Roughly one album per second.</div>
|
||
{#if albumRefreshResult}<div class="hint result">{albumRefreshResult}</div>{/if}
|
||
</div>
|
||
<div class="ctl"><button class="ax-ghost" onclick={refreshAllAlbumArt} disabled={refreshingAlbums}>{refreshingAlbums ? "Upgrading…" : "Re-fetch covers"}</button></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Artist artwork ──────────────────────────────────── -->
|
||
<section id="set-artistart" class="sec">
|
||
<div class="sec-head"><span class="hic"><Icon name={iconOf("artistart")} size={14} /></span><h2>Artist artwork</h2><span class="blurb">Portraits and the hero renderer.</span></div>
|
||
<div class="card">
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Hero renderer</div>
|
||
<div class="hint">Live GPU creates one native-4K master (crop → EASU → RCAS → color/fade/grain once at 3840×2160), then mip-filters it cleanly for every window size, preserving grain scale.</div>
|
||
</div>
|
||
<div class="ctl">{@render seg([["live_gpu", "Live GPU 4K"], ["fsr_legacy", "Baked FSR 1"], ["lanczos3", "Baked Lanczos3"]], settings.artist_hero_upscaler ?? "live_gpu", (v) => setArtistHeroUpscaler(v), saving)}</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="label">
|
||
<div class="name">Refresh artist portraits</div>
|
||
<div class="hint">Fetch portraits for artists already in your library.</div>
|
||
{#if artistRefreshResult}<div class="hint result">{artistRefreshResult}</div>{/if}
|
||
</div>
|
||
<div class="ctl"><button class="ax-ghost" onclick={refreshArtistArt} disabled={refreshingArtists}>{refreshingArtists ? "Refreshing…" : "Refresh artwork"}</button></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.settings {
|
||
--ok: #3ddc84;
|
||
--warn: #e8a33d;
|
||
--err: #f15e6c;
|
||
--surf: var(--bg-elevated);
|
||
--surf-hi: var(--bg-highlight);
|
||
--hair: rgba(255, 255, 255, 0.07);
|
||
max-width: none;
|
||
padding: 0 4px 60px 0;
|
||
}
|
||
.muted { color: var(--text-secondary); }
|
||
|
||
/* ── the index ── */
|
||
.indexwrap { display: grid; grid-template-columns: 210px 1fr; gap: 28px; align-items: start; }
|
||
.index { position: sticky; top: 12px; display: flex; flex-direction: column; }
|
||
.index .h { padding: 0 10px 10px; font-size: 22px; font-weight: 800; letter-spacing: -0.01em; }
|
||
.index .grp { padding: 10px 10px 4px; font-size: 10px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
|
||
.jump { display: flex; align-items: center; gap: 9px; width: 100%; padding: 5px 10px 5px 8px; border-radius: 8px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary); text-align: left; }
|
||
.jump:hover { color: #fff; background: rgba(255, 255, 255, 0.05); }
|
||
.jump.on { color: #fff; background: var(--surf-hi); }
|
||
.jump .ic { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; flex: none; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); transition: background 0.15s, color 0.15s; }
|
||
.jump:hover .ic { color: #fff; }
|
||
.jump.on .ic { background: var(--accent); color: #000; }
|
||
.index .state { margin: 14px 10px 0; min-height: 26px; }
|
||
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 99px; font-size: 11.5px; font-weight: 800; background: var(--surf-hi); color: var(--text-secondary); }
|
||
.pill.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||
.pill.err { background: color-mix(in srgb, var(--err) 14%, transparent); color: #ff8d98; white-space: normal; }
|
||
|
||
/* ── sections and rows ── */
|
||
.sec { margin-bottom: 26px; scroll-margin-top: 12px; }
|
||
.sec-head { display: flex; align-items: center; gap: 10px; margin: 0 0 8px; flex-wrap: wrap; }
|
||
.sec-head .hic { width: 24px; height: 24px; border-radius: 7px; display: grid; place-items: center; flex: none; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); }
|
||
.sec-head .hic + h2 { margin-left: -2px; }
|
||
.sec-head h2 { margin: 0; font-size: 15px; font-weight: 800; letter-spacing: -0.01em; }
|
||
.sec-head .blurb { font-size: 12px; color: var(--text-muted); }
|
||
.card { background: var(--surf); border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
|
||
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--hair); }
|
||
.card > .row:first-child, .subgrp + .row { border-top: none; }
|
||
.label { min-width: 0; flex: 1; }
|
||
.name { font-size: 13.5px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.hint { font-size: 12px; color: var(--text-secondary); margin-top: 3px; line-height: 1.45; max-width: 72ch; }
|
||
.hint b { color: var(--text-primary); font-weight: 700; }
|
||
.hint.warn { color: #f0c070; }
|
||
.hint.result { color: var(--accent-hover); }
|
||
.path { font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); word-break: break-all; margin-top: 3px; }
|
||
.ctl { flex: none; display: flex; align-items: center; gap: 8px; }
|
||
.tag { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 99px; background: var(--accent-soft); color: var(--accent-hover); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||
.tag.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: #5ee89a; }
|
||
.tag.off { background: var(--surf-hi); color: var(--text-muted); }
|
||
.tag.warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: #f0c070; }
|
||
.tag.err { background: color-mix(in srgb, var(--err) 14%, transparent); color: #ff8d98; }
|
||
.tag.acc { background: var(--accent-soft); color: var(--accent-hover); }
|
||
.foot { font-size: 11.5px; color: var(--text-muted); padding: 8px 16px 10px; border-top: 1px solid var(--hair); line-height: 1.5; }
|
||
.foot.top { border-top: none; padding: 12px 16px 4px; }
|
||
.foot.warn { color: #f0c070; }
|
||
.subgrp { display: flex; align-items: center; gap: 8px; padding: 12px 16px 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
|
||
.subgrp .ln { flex: 1; height: 1px; background: var(--hair); }
|
||
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
|
||
.hint.notes { white-space: pre-wrap; max-height: 9em; overflow: auto; }
|
||
.pbar { display: block; width: min(100%, 360px); height: 4px; border-radius: 99px; background: var(--surf-hi); overflow: hidden; margin-top: 8px; }
|
||
.pbar i { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.2s; }
|
||
.disclose.src { padding: 10px 16px 14px; border-top: 1px solid var(--hair); }
|
||
.disclose.src .hint { margin: 6px 0 10px; }
|
||
.disclose.src code { font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; }
|
||
.srcrow { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
|
||
.srcrow input { width: 100%; min-width: 0; }
|
||
input.tok { width: 220px; }
|
||
.lnk { color: var(--text-secondary); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; font-size: 12px; }
|
||
.lnk:disabled { opacity: 0.5; }
|
||
.disclose { margin-top: 8px; }
|
||
.disclose summary { font-size: 11.5px; font-weight: 800; color: var(--text-secondary); cursor: pointer; }
|
||
.disclose ul { margin: 6px 0 0; padding-left: 18px; font-size: 11.5px; color: var(--text-muted); line-height: 1.6; }
|
||
|
||
/* ── controls ── */
|
||
.tval { font-size: 12px; font-weight: 800; color: var(--text-secondary); min-width: 62px; text-align: right; }
|
||
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
|
||
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
|
||
.toggle.on { background: var(--accent); }
|
||
.toggle.on i { left: 20px; background: #000; }
|
||
.toggle:disabled { opacity: 0.45; cursor: default; }
|
||
.seg { display: inline-flex; padding: 3px; border-radius: 99px; background: var(--surf-hi); gap: 2px; }
|
||
.seg button { padding: 5px 12px; border-radius: 99px; font-size: 12px; font-weight: 800; color: var(--text-secondary); white-space: nowrap; }
|
||
.seg button.on { background: var(--accent); color: #000; }
|
||
.seg button:hover:not(.on):not(:disabled) { color: #fff; }
|
||
.seg button:disabled { opacity: 0.6; cursor: default; }
|
||
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
|
||
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||
.ax-ghost.sm { height: 28px; padding: 0 11px; font-size: 11px; }
|
||
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
|
||
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
|
||
.ax-fill.sm { height: 28px; padding: 0 12px; font-size: 11px; }
|
||
.ax-fill:hover:not(:disabled) { background: var(--accent-hover); }
|
||
.ax-fill:disabled { opacity: 0.55; cursor: default; }
|
||
.ax-white { display: inline-flex; align-items: center; height: 32px; padding: 0 16px; border-radius: 99px; font-size: 12.5px; font-weight: 800; background: #fff; color: #000; white-space: nowrap; transition: transform 0.15s; }
|
||
.ax-white:hover { transform: scale(1.03); }
|
||
.stepper { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 99px; background: var(--surf-hi); }
|
||
.stepper button { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: var(--text-secondary); }
|
||
.stepper button:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||
.stepper button:disabled { opacity: 0.5; cursor: default; }
|
||
.stepper span { min-width: 28px; text-align: center; font-weight: 900; font-variant-numeric: tabular-nums; }
|
||
.mono { width: 300px; max-width: 46vw; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); color: var(--text-primary); font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; outline: none; }
|
||
.mono:focus { border-color: var(--text-secondary); }
|
||
.folders { display: flex; flex-direction: column; gap: 4px; padding: 0 16px 10px; }
|
||
.folder { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.04); font-family: "Cascadia Code", Consolas, monospace; font-size: 11.5px; color: var(--text-secondary); }
|
||
.folder .fpath { min-width: 0; word-break: break-all; }
|
||
.folder .x { margin-left: auto; color: var(--text-muted); display: grid; place-items: center; flex: none; }
|
||
.folder .x:hover { color: var(--err); }
|
||
|
||
@media (max-width: 900px) {
|
||
.indexwrap { grid-template-columns: 1fr; }
|
||
.index { position: static; flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
||
.index .h, .index .grp, .index .state { display: none; }
|
||
.jump { width: auto; }
|
||
}
|
||
</style>
|