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.
993 lines
37 KiB
Svelte
993 lines
37 KiB
Svelte
<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>
|