Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+718
View File
@@ -0,0 +1,718 @@
<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 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>
<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>