Files
SoulStream/src/views/Settings.svelte
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00

993 lines
37 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import { applyGlass } from "../lib/glass.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
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();
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;
}
}
</script>
<div class="settings">
<h1>Settings</h1>
{#if !settings}
<p class="muted">Loading…</p>
{:else}
<section>
<h2>Account</h2>
<div class="row">
<div class="label">
<div class="name">Soulseek account</div>
<div class="hint">
{settings.username
? `Signed in as ${settings.username}`
: "Not signed in"}
{#if settings.connected}<span class="online"> · online</span>{/if}
</div>
</div>
<div class="controls">
{#if app.connection !== "online"}
<button class="btn" onclick={() => (app.showLogin = true)}>Connect</button>
{/if}
{#if settings.has_password}
<button class="btn ghost" onclick={forget}>Forget credentials</button>
{/if}
</div>
</div>
<p class="stored-note">
<Icon name="lock" size={13} />
Your password is stored in the Windows Credential Manager, not in a plain file.
</p>
</section>
<section>
<h2>Appearance</h2>
<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>
<select
class="device-select"
value={settings.glass_mode ? "on" : "off"}
disabled={saving}
onchange={(e) => setGlassMode(e.currentTarget.value === "on")}
>
<option value="off">Off — solid</option>
<option value="on">On — dark glass</option>
</select>
</div>
</section>
<section>
<h2>Playback</h2>
<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>
<select
class="device-select"
value={settings.audio_output_device ?? ""}
disabled={saving}
onchange={(e) => setAudioOutput(e.currentTarget.value)}
>
<option value="">System default (recommended)</option>
{#if settings.audio_output_device && !settings.audio_output_devices.includes(settings.audio_output_device)}
<option value={settings.audio_output_device}>{settings.audio_output_device} (unavailable)</option>
{/if}
{#each settings.audio_output_devices as device (device)}
<option value={device}>{device}</option>
{/each}
</select>
</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>
<select
class="device-select"
value={settings.volume_normalization ? "on" : "off"}
disabled={saving}
onchange={(e) => setVolumeNormalization(e.currentTarget.value === "on")}
>
<option value="on">On (recommended)</option>
<option value="off">Off</option>
</select>
</div>
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
<section>
<h2>Lyrics</h2>
<div class="row">
<div class="label">
<div class="name">Word-by-word fill <span class="exp-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>
<select
class="device-select"
value={settings.word_timing ? "on" : "off"}
disabled={saving}
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
>
<option value="off">Off (recommended for now)</option>
<option value="on">On</option>
</select>
</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>
</div>
{#if lyricModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
</button>
{/if}
</div>
{#if modelError}
<p class="muted small">Download failed: {modelError}</p>
{/if}
{#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. Timed songs re-prepare the next time
you open their lyrics.
</div>
</div>
{#if sepModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
</button>
{/if}
</div>
{#if sepError}
<p class="muted small">Download failed: {sepError}</p>
{/if}
{/if}
{/if}
</section>
<section>
<h2>Streaming</h2>
<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. Choose Forever to add everything you stream to the library instead.</div>
</div>
<select
class="device-select"
value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)}
disabled={saving}
onchange={(e) => setStreamRetention(e.currentTarget.value)}
>
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">1 week</option>
<option value="14">2 weeks</option>
<option value="30">1 month (recommended)</option>
<option value="90">3 months</option>
<option value="forever">Forever — save to library</option>
</select>
</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>
<select
class="device-select"
value={String(settings.stream_cache_max_mb)}
disabled={saving}
onchange={(e) => setStreamCacheMax(e.currentTarget.value)}
>
<option value="512">512 MB</option>
<option value="1024">1 GB</option>
<option value="2048">2 GB (recommended)</option>
<option value="5120">5 GB</option>
<option value="10240">10 GB</option>
<option value="20480">20 GB</option>
<option value="0">Unlimited</option>
</select>
</div>
<p class="muted small">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</p>
</section>
<section>
<h2>Library & downloads</h2>
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="hint path">{settings.download_dir}</div>
</div>
<button class="btn" onclick={chooseDownloadDir}>Change…</button>
</div>
<p class="muted small">This is your downloaded-music library and is always shared. Folder changes apply after reconnecting.</p>
<div class="layout-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.</div>
</div>
<input
class="layout-input"
value={settings.library_layout}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()}
onchange={(e) => setLibraryLayout(e.currentTarget.value)}
/>
</div>
<p class="muted small">New downloads are organized into this format; existing files are not moved.</p>
</section>
<section>
<h2>Sharing</h2>
<p class="muted small">
Sharing keeps you a good citizen — leechers get banned. Your download
folder is always shared ({settings.share_files} files indexed).
Add extra folders below.
</p>
{#if settings.shared_folders.length > 0}
<div class="folder-list">
{#each settings.shared_folders as folder (folder)}
<div class="folder">
<span class="path">{folder}</span>
<button
class="remove"
title="Stop sharing"
onclick={() => removeSharedFolder(folder)}
><Icon name="x" size={15} /></button>
</div>
{/each}
</div>
{/if}
<button class="btn" onclick={addSharedFolder}>Add folder…</button>
</section>
<section>
<h2>Album artwork</h2>
<p class="muted small">Covers are resolved from Deezer, Apple, and the Cover Art Archive; the artwork shown in the catalog wins, at the highest resolution any source serves. One canonical image is used for the library, the embedded tags, and the album folder.</p>
<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>
<select
class="device-select"
value={settings.replace_embedded_art ? "replace" : "keep"}
disabled={saving}
onchange={(e) => setReplaceEmbeddedArt(e.currentTarget.value)}
>
<option value="replace">Replace with canonical (recommended)</option>
<option value="keep">Keep uploader's art</option>
</select>
</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>
<select
class="device-select"
value={String(settings.embed_art_max_edge)}
disabled={saving}
onchange={(e) => setEmbedArtMaxEdge(e.currentTarget.value)}
>
<option value="0">Original (largest files)</option>
<option value="1500">1500 px (recommended)</option>
<option value="1000">1000 px</option>
</select>
</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>
<select
class="device-select"
value={settings.write_folder_cover ? "write" : "skip"}
disabled={saving}
onchange={(e) => setWriteFolderCover(e.currentTarget.value)}
>
<option value="write">Write cover.jpg + folder.jpg</option>
<option value="skip">Leave folders untouched</option>
</select>
</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>
</div>
<button class="btn" onclick={refreshAllAlbumArt} disabled={refreshingAlbums}>
{refreshingAlbums ? "Upgrading…" : "Re-fetch covers"}
</button>
</div>
{#if albumRefreshResult}<p class="muted small refresh-result">{albumRefreshResult}</p>{/if}
</section>
<section>
<h2>Artist artwork</h2>
<div class="row">
<div class="label">
<div class="name">Hero renderer</div>
<div class="hint">Live GPU creates one native-4K master, then mip-filters it cleanly for every window size.</div>
</div>
<select
class="device-select"
value={settings.artist_hero_upscaler ?? "live_gpu"}
disabled={saving}
onchange={(e) => setArtistHeroUpscaler(e.currentTarget.value)}
>
<option value="live_gpu">Live GPU 4K (recommended)</option>
<option value="fsr_legacy">Original baked FSR 1</option>
<option value="lanczos3">Baked Lanczos3</option>
</select>
</div>
<p class="muted small">Live GPU applies crop ? EASU ? RCAS ? color/fade/grain once at 3840?2160. Resizing only re-samples that fixed master, preserving grain scale and avoiding baked-file latency.</p>
<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>
</div>
<button class="btn" onclick={refreshArtistArt} disabled={refreshingArtists}>
{refreshingArtists ? "Refreshing…" : "Refresh artwork"}
</button>
</div>
{#if artistRefreshResult}<p class="muted small refresh-result">{artistRefreshResult}</p>{/if}
</section>
<section>
<h2>Source quality</h2>
<p class="muted small">
Independent preferences for streaming and downloading. The ceiling and floor
bound which peer files qualify; priority decides whether the resolver picks
the best format or the fastest transfer among them.
</p>
{#each [
{ key: "stream", label: "Streaming" },
{ key: "download", label: "Downloads" },
] as mode (mode.key)}
<div class="row">
<div class="label">
<div class="name">{mode.label} — maximum</div>
<div class="hint">Skip files above this ceiling (lossy-only avoids huge lossless transfers).</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).max}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "max", e.currentTarget.value)}
>
<option value="any">No limit (lossless preferred)</option>
<option value="lossy_only">Lossy only (≤ 320 kbps)</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — minimum</div>
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).min}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "min", e.currentTarget.value)}
>
<option value="any">Any quality</option>
<option value="kbps192">192 kbps and up</option>
<option value="kbps256">256 kbps and up</option>
<option value="kbps320">320 kbps and up</option>
<option value="lossless">Lossless only</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — priority</div>
<div class="hint">
Fastest transfer estimates arrival time from file size ÷ peer speed
(plus queue wait) and picks whatever lands soonest — a large FLAC from a
fast peer can win. Only files inside your min/max window compete.
</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).priority}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "priority", e.currentTarget.value)}
>
<option value="quality">Best quality</option>
<option value="speed">Fastest transfer (est. time)</option>
</select>
</div>
{/each}
</section>
<section>
<h2>Uploads</h2>
<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="stepper">
<button onclick={() => setSlots(settings.upload_slots - 1)}>
<Icon name="minus" size={16} />
</button>
<span>{settings.upload_slots}</span>
<button onclick={() => setSlots(settings.upload_slots + 1)}>
<Icon name="plus" size={16} />
</button>
</div>
</div>
</section>
{#if saving}
<p class="muted small">Saving…</p>
{:else if savedFlash}
<p class="saved small">✓ Saved</p>
{:else if saveError}
<p class="save-error small">{saveError}</p>
{/if}
{/if}
</div>
<style>
.settings {
max-width: 720px;
}
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
section {
margin-bottom: 32px;
}
h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.label {
min-width: 0;
}
.name {
font-weight: 600;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
}
.path {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 11px;
word-break: break-all;
}
.online {
color: var(--accent-hover);
}
.controls {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border-radius: 20px;
background: var(--text-primary);
color: #000;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.btn:hover {
transform: scale(1.03);
}
.btn.ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn.ghost:hover {
color: var(--text-primary);
transform: none;
}
.stored-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
}
.muted {
color: var(--text-secondary);
}
.small {
font-size: 12px;
}
.folder-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.layout-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 10px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.layout-input {
width: 270px;
max-width: 48%;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 12px;
outline: none;
}
.layout-input:focus { border-color: var(--text-secondary); }
.device-select {
width: 310px;
max-width: 52%;
padding: 9px 34px 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.device-select:focus { border-color: var(--text-secondary); }
.device-select:disabled { opacity: 0.6; }
.folder {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--bg-elevated);
border-radius: 6px;
}
.remove {
color: var(--text-muted);
flex-shrink: 0;
}
.remove:hover {
color: #f15e6c;
}
.stepper {
display: flex;
align-items: center;
gap: 14px;
}
.stepper button {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 18px;
display: grid;
place-items: center;
}
.stepper button:hover {
background: var(--bg-press);
}
.stepper span {
min-width: 20px;
text-align: center;
font-weight: 700;
}
.saved {
color: var(--accent-hover);
}
.save-error {
color: #f15e6c;
}
.model-btn {
flex: none;
padding: 8px 16px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.model-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.model-btn:disabled {
opacity: 0.7;
cursor: default;
}
.exp-tag {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
vertical-align: 2px;
}
.model-ok {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: #3ecf7e;
font-size: 13px;
font-weight: 700;
}
/* ── dark glass ────────────────────────────────────────────────────────
This page is almost entirely cards: twenty-odd .row blocks at opaque
--bg-elevated, each with a --bg-base input or select sunk into it. Under
glass that read as a column of black slabs stacked on the frost. The
ladder lives in global.css; solid mode is untouched. */
/* Cards lift off the pane with white and a hairline, not with more black —
black would net 0.81 against the pane's own 0.7075 and sink below it. */
:global(:root.glass) .row,
:global(:root.glass) .layout-row {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
/* A watched-folder chip sits INSIDE a .row, so it takes the ring only —
a second surface fill on top of the card's would read as a patch. */
:global(:root.glass) .folder {
background: none;
box-shadow: var(--glass-ring);
}
/* Controls sink INTO the card, which is the one place a little black is
right. 0.18 nets ~0.76 — a five-point well; the edge does the defining,
which is why the hairline comes with it. */
:global(:root.glass) .device-select,
:global(:root.glass) .layout-input {
background: var(--glass-well);
border-color: var(--glass-hairline);
}
/* The focus ring already switches to --text-secondary in both modes; it
reads fine against the well and is deliberately left alone. */
:global(:root.glass) .btn.ghost {
border-color: var(--glass-hairline);
}
:global(:root.glass) .stepper button {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
:global(:root.glass) .stepper button:hover {
background: var(--glass-hover);
}
</style>