A track that opens on a lone guitar and swells into a full chorus played that
chorus far too loud. The head of an unmeasured stream was being treated as a
measurement of the whole song: at ten decoded seconds the analyzer's running
integrated value became the gain for the entire play, and a quiet opening
under-reads, so the estimator asked to turn the track UP and the song then
arrived at its real level already amplified. `PRELIM_MAX_BOOST_DB = 3.0`
bounded that mistake without preventing it, and the three decibels land
exactly on the loudest part of the song.
Measured on the 69 complete tracks in the stream cache, with the app's own
meter and its own gain policy, comparing what the 10 s head asks for against
what the finished track asks for:
prefix error in applied gain (+ = plays too loud)
1s min -4.6 p50 +7.7 p95 +15.8 max +17.0 too loud: 61/69
3s min -0.9 p50 +5.2 p95 +15.7 max +17.0 too loud: 57/69
10s min -1.5 p50 +2.9 p95 +14.6 max +17.0 too loud: 57/69
The head is wrong in one direction and wrong by a lot. So it may only ever
cut: `PRELIM_MAX_BOOST_DB` is 0.0, and a boost has to be earned by a
measurement that reached the end of the media.
**Cuts-only rather than the flat unity the design called for.** The design
doc's stated first-release policy is that an unknown partial stream stays at
0 dB, with cuts-only left as a later experiment. Measured against this
corpus, unity is the worse of the two in the direction being complained
about — first-play loudness relative to the −14 LUFS target:
unity (doc's policy) p50 +5.6 p95 +8.5 LOUDEST +10.6 >3dB over: 57/69
cuts-only p50 +2.7 p95 +6.7 LOUDEST +8.0 >3dB over: 31/69
old policy, capped +3 p50 +2.7 p95 +9.5 LOUDEST +11.0 >3dB over: 32/69
verified (later plays) p50 +0.0 p95 +0.0 LOUDEST +0.0 >3dB over: 0/69
Unity would hand back the cut that loud-opening tracks already get today:
"Spread Eagle Cross the Block" reads −3.4 LUFS over its head and −3.4 over
the whole track, and is cut 10.6 dB — under unity its first play would be
10.6 dB louder than it is now, which is the reported bug wearing a different
hat. The tail risk that argues for unity is a loud opening over a quiet body,
where the head over-cuts; on this corpus that case tops out at 1.5 dB (My
Bloody Valentine, "Only Shallow": head −11.3, track −12.8) and the existing
−15 dB floor bounds it. The corpus is one library and contains no fortissimo
opening over a pianissimo body, so it bounds the error it can see, not the
error that exists. Revisit if a cut ever sounds wrong.
**Only a whole programme may be cached.** `analyze_loudness` returned the
same `Option<(f64, f32)>` whether it reached the end of the media or the
bytes simply stopped, and `on_track_started` stored it either way — with a
comment saying it was valid "whatever happened to this one". It is not:
`GrowingFile::read` maps an aborted transfer to `Ok(0)`, a graceful EOF, so
skipping a song mid-download recorded whatever its intro measured, filed
under the full declared remote size, and every later play boosted the whole
track off it by up to `MAX_GAIN_DB` — the same bug as above, but permanent
and three times larger. It now returns `Analysis::Complete | Incomplete`,
where completeness is decided by asking the transfer how it ended
(`StreamProgress::terminal`) rather than by asking a reader that cannot tell,
and only `Complete` reaches the cache, the preload boundary gain, or a
preview clip's gain.
That path was reachable but had not fired. Re-measuring every cached track
against its stored value found no disagreement, and of the ten interrupted
transfers still sitting in the stream cache, none had left a loudness entry
at all — the analyzer normally wins its race with the download and either
finishes properly or is cancelled before the abort reaches it. So the
existing cache is not quarantined and not wiped: of 141 entries only 6 ask
for any boost above 0.5 dB, four of those are present on disk and verified
correct by a fresh scan, and the other two ask +1.4 and +1.0 dB. Discarding
141 good measurements to protect against a hazard that measurement says is
not there would cost more than it saves, and any record that is wrong is
overwritten by the next complete play.
**The audible start is now bounded from the play request.** Both existing
windows are measured from the first measurement, and a track opening below
the −70 LUFS gate never produces one, so silence or a long fade-in held
playback until the analyzer found audio loud enough to measure — on a slow
stream, a wait with no upper bound. `PRELIM_HARD_DEADLINE` (2.5 s) releases
the start regardless, at the best cut the evidence so far justifies. This
costs nothing: the player blocks on the growing file anyway, so releasing
early hands it the start rather than making it wait. Verified against a
223 MB FLAC whose first bytes had not arrived at 2.2 s.
**Stop now cancels the measurement window.** Found while verifying the
deadline: `player_control("stop")` never touched the loudness generation, so
a track stopped during its window could be handed its deferred start a moment
later and begin playing after the user had stopped it. Pre-existing, but the
deadline makes it reliably reachable, so `on_playback_stopped` orphans the
analyzer and the held start.
Not done, and deliberately: true peak and the −1.0 dBTP ceiling (the meter
measures sample peak, so `PEAK_CEILING` is still 0.98 sample), the versioned
measurement cache with real asset identity, ReplayGain/R128 metadata, the
output limiter, track-bound gapless gain, and queue-ahead analysis. Those are
later phases and none of them is needed to stop the over-boost.
Verified live, driving the running app:
quiet intro, first play head −27.4 LUFS → 0.0 dB (was +3.0 dB)
quiet intro, second play track −6.0 LUFS → −8.01 dB from the cache
loud intro, first play head −3.4 LUFS → −10.64 dB cut still applied
Soulseek stream, cold head −18.8 LUFS → 0.0 dB, track measured −9.0
and cached for the next play without touching
the gain in force
stop mid-window no start behind the stop, no cache entry written
The debug overlay now names what the gain was decided from — "whole-track
measurement (cache)", "prefix (cut only)", "unity (nothing measured yet)" —
because the difference is invisible in the numbers alone, which is how this
went unnoticed. Settings no longer claims "boosts never clip", which nothing
in the output path enforces; it describes the actual guarantee instead. The
module header's claims that the gain glides mid-track and that previews are
not normalized were both false and are gone.
150 Rust tests and 85 JS tests pass.
740 lines
27 KiB
Svelte
740 lines
27 KiB
Svelte
<script>
|
||
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||
import * as api from "../lib/api.js";
|
||
import Icon from "../components/Icon.svelte";
|
||
|
||
let settings = $state(null);
|
||
let saving = $state(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 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>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>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;
|
||
}
|
||
</style>
|