Popular is asked on its own as well as with the page, so the section can paint before the full page is built and an artist whose page came back with none still gets one. A catalog record's rows that the album's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the deferred Last.fm pass by their own artist id. The artist page's ⋯ is its own sheet now: choose a hero photo from this phone, or put the catalog's own back; set_artist_hero is pick_artist_hero's install step split out and shared, so the desktop's dialog keeps its door. You gains the desktop's Refresh artist portraits, and a data: URL is the image itself in the art gate — a chosen hero comes back from Rust as one, and the thumbnail fetch used to throw it away.
335 lines
20 KiB
Svelte
335 lines
20 KiB
Svelte
<script>
|
||
/* You — Appearance (real, kept on the device) and what this phone is
|
||
running. Playback, storage and the network settings come next. */
|
||
import Icon from "../../components/Icon.svelte";
|
||
import * as api from "../../lib/api.js";
|
||
import { lib, loadLibrary, refreshRecents } from "../lib.svelte.js";
|
||
import { push, openHalf } from "../nav.svelte.js";
|
||
import { note } from "../notice.svelte.js";
|
||
import { transfers, active } from "../transfers.svelte.js";
|
||
import { player } from "../player.svelte.js";
|
||
import { settings, setSetting } from "../prefs.svelte.js";
|
||
import { refreshSourcePrefs } from "../../lib/sources.js";
|
||
|
||
let cfg = $state(null);
|
||
let scanning = $state(false);
|
||
$effect(() => { api.getSettings().then((s) => { cfg = s; }, () => {}); });
|
||
/* the desktop's Playback setting: every track brought to −14 LUFS (the
|
||
player crate's BS.1770 meter; on by default) */
|
||
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
|
||
async function toggleNormalization() {
|
||
const next = !normalization;
|
||
try { await api.setSettings({ volumeNormalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
|
||
}
|
||
/* the library folder: picked on the phone, set as the app's download
|
||
dir (the folder IS the library), rescanned. A folder in shared storage
|
||
needs all-files access, granted by Kyle on the system screen. */
|
||
let access = $state(null);
|
||
async function checkAccess() { try { access = (await api.allFilesAccess())?.granted ?? null; } catch { access = null; } }
|
||
/* re-checked when the app comes back from the system's permission screen */
|
||
$effect(() => {
|
||
checkAccess();
|
||
const onShow = () => { if (!document.hidden) checkAccess(); };
|
||
document.addEventListener("visibilitychange", onShow);
|
||
return () => document.removeEventListener("visibilitychange", onShow);
|
||
});
|
||
const privateFolder = $derived(!cfg?.download_dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(cfg.download_dir));
|
||
let picking = $state(false);
|
||
async function pickFolder() {
|
||
if (picking) return;
|
||
picking = true;
|
||
try {
|
||
const r = await api.pickLibraryFolder();
|
||
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
|
||
await api.setSettings({ downloadDir: r.path });
|
||
cfg = await api.getSettings();
|
||
await rescan();
|
||
} catch (e) {
|
||
if (!/cancelled/.test(String(e))) player.error = String(e);
|
||
} finally { picking = false; }
|
||
}
|
||
function clearHistory() {
|
||
openHalf("confirm", {
|
||
title: "Clear the play history?",
|
||
body: "Jump back in and the play counts start over. The library and the playlists stay.",
|
||
ok: "Clear", danger: true,
|
||
onconfirm: async () => {
|
||
try { await api.clearPlayHistory(); await refreshRecents(); note("Play history cleared"); } catch (e) { player.error = String(e); }
|
||
},
|
||
});
|
||
}
|
||
async function rescan() {
|
||
if (scanning) return;
|
||
scanning = true;
|
||
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
|
||
scanning = false;
|
||
}
|
||
|
||
/* the desktop's Source quality settings (Settings → Sources): a window
|
||
per mode and the priority inside it; the resolver re-reads them */
|
||
const MODES = [["stream", "Streaming"], ["download", "Downloads"]];
|
||
const MAX_Q = [["any", "No limit"], ["lossy_only", "Lossy only"]];
|
||
const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
|
||
const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]];
|
||
const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
|
||
/* the desktop's Streaming settings: what happens to a streamed track */
|
||
const RETENTION = [["1", "1 day"], ["3", "3 days"], ["7", "1 week"], ["14", "2 weeks"], ["30", "1 month"], ["90", "3 months"], ["forever", "Forever — save to library"]];
|
||
const CACHE = [["512", "512 MB"], ["1024", "1 GB"], ["2048", "2 GB"], ["5120", "5 GB"], ["10240", "10 GB"], ["20480", "20 GB"], ["0", "Unlimited"]];
|
||
const retention = $derived(!cfg ? "30" : cfg.stream_keep_forever ? "forever" : String(cfg.stream_retention_days ?? 30));
|
||
const cacheMax = $derived(!cfg ? "2048" : String(cfg.stream_cache_max_mb ?? 2048));
|
||
async function setRetention(v) {
|
||
if (v === retention) return;
|
||
try {
|
||
await api.setSettings(v === "forever" ? { streamKeepForever: true } : { streamKeepForever: false, streamRetentionDays: Number(v) });
|
||
cfg = await api.getSettings();
|
||
} catch (e) { player.error = String(e); }
|
||
}
|
||
async function setCacheMax(v) {
|
||
if (v === cacheMax) return;
|
||
try { await api.setSettings({ streamCacheMaxMb: Number(v) }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
|
||
}
|
||
const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) });
|
||
async function setPref(mode, field, value) {
|
||
if (prefsOf(mode)[field] === value) return;
|
||
const next = { ...prefsOf(mode), [field]: value };
|
||
try {
|
||
await api.setSettings(mode === "stream" ? { streamSourcePrefs: next } : { downloadSourcePrefs: next });
|
||
cfg = await api.getSettings();
|
||
await refreshSourcePrefs();
|
||
} catch (e) { player.error = String(e); }
|
||
}
|
||
|
||
/* the desktop's Library & downloads / Sharing / Lyrics / Album artwork
|
||
settings, on the phone's components; every save camelCase */
|
||
const ART_MODE = [["replace", "Canonical cover"], ["keep", "Uploader's art"]];
|
||
const ART_EDGE = [["0", "Original"], ["1500", "1500 px"], ["1000", "1000 px"]];
|
||
const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace");
|
||
const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500));
|
||
let refetching = $state(false);
|
||
let refreshingArtists = $state(false);
|
||
async function saveCfg(patch) {
|
||
try { await api.setSettings(patch); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
|
||
}
|
||
const setFlag = (key, v) => saveCfg({ [key]: !!v });
|
||
const setNumber = (key, v) => saveCfg({ [key]: v });
|
||
const setSlots = (n) => saveCfg({ uploadSlots: Math.max(1, Math.min(10, n)) });
|
||
function layoutPrompt() {
|
||
openHalf("prompt", {
|
||
title: "Album folder format", value: cfg?.library_layout || "", placeholder: "{artist}\\{album} ({year})", ok: "Save",
|
||
onsubmit: (v) => { if (v.trim()) void saveCfg({ libraryLayout: v.trim() }); },
|
||
});
|
||
}
|
||
async function addShared() {
|
||
try {
|
||
const r = await api.pickLibraryFolder();
|
||
if (!r?.path) { player.error = "That folder has no path the phone can share — pick one on this phone's storage."; return; }
|
||
const have = cfg?.shared_folders ?? [];
|
||
if (!have.includes(r.path)) await saveCfg({ sharedFolders: [...have, r.path] });
|
||
} catch (e) {
|
||
if (!/cancelled/.test(String(e))) player.error = String(e);
|
||
}
|
||
}
|
||
const removeShared = (folder) => saveCfg({ sharedFolders: (cfg?.shared_folders ?? []).filter((f) => f !== folder) });
|
||
async function refetchCovers() {
|
||
if (refetching) return;
|
||
refetching = true;
|
||
try { const n = await api.refreshAllAlbumArt(); note(`${n ?? ""} cover${n === 1 ? "" : "s"} re-fetched`.trim()); }
|
||
catch (e) { player.error = String(e); }
|
||
finally { refetching = false; }
|
||
}
|
||
/* the desktop Settings row: a portrait for every artist already here */
|
||
async function refreshPortraits() {
|
||
if (refreshingArtists) return;
|
||
refreshingArtists = true;
|
||
try { const n = await api.refreshArtistArt(); note(`${n ?? 0} portrait${n === 1 ? "" : "s"} refreshed`); }
|
||
catch (e) { player.error = String(e); }
|
||
finally { refreshingArtists = false; }
|
||
}
|
||
const ROWS = [
|
||
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
|
||
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
|
||
["motion", "layers", "Motion", "springs on screens, sheets and the nav"],
|
||
["glass", "display", "Glass blur", "the desktop's frosted chrome — costs frames on this phone"],
|
||
];
|
||
</script>
|
||
|
||
<div class="phead"><div class="row"><h1>You</h1></div></div>
|
||
|
||
<div class="sheet-sec" style="padding-top: 4px">Appearance</div>
|
||
<div class="group">
|
||
{#each ROWS as [k, icon, t, m] (k)}
|
||
<button class="row" onclick={() => setSetting(k, !settings[k])}>
|
||
<span class="lead"><Icon name={icon} size={20} /></span>
|
||
<span class="b"><span class="t">{t}</span><span class="m">{m}</span></span>
|
||
<span class="sw" class:on={settings[k]}><span></span></span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
|
||
<div class="sheet-sec">Playback</div>
|
||
<div class="group">
|
||
<button class="row" onclick={toggleNormalization}>
|
||
<span class="lead"><Icon name="volume" size={20} /></span>
|
||
<span class="b"><span class="t">Volume normalization</span><span class="m">every track at about the same loudness (−14 LUFS); a track is measured on its first play and matched from then on</span></span>
|
||
<span class="sw" class:on={normalization}><span></span></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Library</div>
|
||
<div class="group">
|
||
<button class="row" onclick={pickFolder}>
|
||
<span class="lead"><Icon name="folder" size={20} /></span>
|
||
<span class="b"><span class="t">{picking ? "Picking…" : "Library folder"}</span><span class="m mono">{cfg?.download_dir || "—"}</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
{#if !privateFolder && access === false}
|
||
<button class="row" onclick={() => api.requestAllFilesAccess().catch((e) => { player.error = String(e); })}>
|
||
<span class="lead"><Icon name="alert" size={20} /></span>
|
||
<span class="b"><span class="t">Allow all files access</span><span class="m">a folder on the phone's storage needs it — the system screen opens, come back after</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
{/if}
|
||
<div class="kv"><span class="k">Records</span><span class="v">{lib.albums.length}</span></div>
|
||
<div class="kv"><span class="k">Playlists</span><span class="v">{lib.playlists.length}</span></div>
|
||
<button class="row" onclick={rescan}>
|
||
<span class="lead"><Icon name="refresh" size={20} /></span>
|
||
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
|
||
</button>
|
||
<button class="row" onclick={clearHistory}>
|
||
<span class="lead"><Icon name="history" size={20} /></span>
|
||
<span class="b"><span class="t">Clear play history</span><span class="m">what Jump back in and the play counts are built from</span></span>
|
||
</button>
|
||
<button class="row" onclick={() => push("transfers", {})}>
|
||
<span class="lead"><Icon name="download" size={20} /></span>
|
||
<span class="b"><span class="t">Transfers</span><span class="m">{active().length ? `${active().length} in flight` : Object.keys(transfers.items).length ? "nothing in flight" : "what Save is fetching into the library"}</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Streaming</div>
|
||
<div class="sec-note">What happens to a track after you streamed it. Streamed songs stay in a private cache for instant replay — not in your library; Forever adds everything you stream to the library instead. Retention counts from the last play; saved and downloaded music is never touched.</div>
|
||
<div class="group prefs" data-mode="streamcache">
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Keep streamed tracks</span><span class="m">how long the cache holds a track after its last play</span></div>
|
||
<div class="chips">
|
||
{#each RETENTION as [v, l] (v)}<button class="chip" class:on={retention === v} class:quiet={retention !== v} onclick={() => setRetention(v)}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Cache size limit</span><span class="m">the oldest streamed tracks go first past this size</span></div>
|
||
<div class="chips">
|
||
{#each CACHE as [v, l] (v)}<button class="chip" class:on={cacheMax === v} class:quiet={cacheMax !== v} onclick={() => setCacheMax(v)}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Sources</div>
|
||
<div class="sec-note">Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.</div>
|
||
{#each MODES as [key, label] (key)}
|
||
<div class="group prefs" data-mode={key}>
|
||
<div class="pref-head">{label}</div>
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Maximum</span><span class="m">lossy only avoids huge lossless transfers</span></div>
|
||
<div class="chips">
|
||
{#each MAX_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).max === v} class:quiet={prefsOf(key).max !== v} onclick={() => setPref(key, "max", v)}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Minimum</span><span class="m">files below the floor are skipped; a lossy file must report its bitrate</span></div>
|
||
<div class="chips">
|
||
{#each MIN_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).min === v} class:quiet={prefsOf(key).min !== v} onclick={() => setPref(key, "min", v)}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Priority</span><span class="m">among the files inside that window</span></div>
|
||
<div class="chips">
|
||
{#each PRI as [v, l] (v)}<button class="chip" class:on={prefsOf(key).priority === v} class:quiet={prefsOf(key).priority !== v} onclick={() => setPref(key, "priority", v)}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
|
||
<div class="sheet-sec">Downloads</div>
|
||
<div class="group">
|
||
<button class="row" onclick={() => setSetting("wifiOnly", !settings.wifiOnly)}>
|
||
<span class="lead"><Icon name="globe" size={20} /></span>
|
||
<span class="b"><span class="t">Downloads on Wi-Fi only</span><span class="m">on mobile data, Save waits; streaming is never held back</span></span>
|
||
<span class="sw" class:on={settings.wifiOnly}><span></span></span>
|
||
</button>
|
||
<button class="row" onclick={layoutPrompt}>
|
||
<span class="lead"><Icon name="folder-open" size={20} /></span>
|
||
<span class="b"><span class="t">Album folder format</span><span class="m mono">{cfg?.library_layout || "{artist}\\{album}"}</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
<div class="sec-note" style="padding: 0 14px 12px">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder. New downloads are organized into this format; existing files are not moved.</div>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Sharing</div>
|
||
<div class="group">
|
||
<div class="kv"><span class="k">Your library folder</span><span class="v dim">always shared · {(cfg?.share_files ?? 0).toLocaleString()} files</span></div>
|
||
<div class="row slots">
|
||
<span class="lead"><Icon name="users" size={20} /></span>
|
||
<span class="b"><span class="t">Upload slots</span><span class="m">how many peers can download from you at once</span></span>
|
||
<span class="stepper"><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) - 1)} aria-label="Fewer slots"><Icon name="minus" size={16} /></button><span class="n">{cfg?.upload_slots ?? 2}</span><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) + 1)} aria-label="More slots"><Icon name="plus" size={16} /></button></span>
|
||
</div>
|
||
{#each cfg?.shared_folders ?? [] as folder (folder)}
|
||
<div class="row">
|
||
<span class="lead"><Icon name="folder" size={20} /></span>
|
||
<span class="b"><span class="t mono">{folder}</span><span class="m">also shared</span></span>
|
||
<button class="iconbtn sm muted" onclick={() => removeShared(folder)} aria-label="Stop sharing"><Icon name="x" size={18} /></button>
|
||
</div>
|
||
{/each}
|
||
<button class="row" onclick={addShared}>
|
||
<span class="lead"><Icon name="plus" size={20} /></span>
|
||
<span class="b"><span class="t">Add a shared folder</span><span class="m">on this phone's storage; the library folder needs no adding</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Lyrics</div>
|
||
<div class="group">
|
||
<button class="row" onclick={() => setFlag("wordTiming", !cfg?.word_timing)}>
|
||
<span class="lead"><Icon name="lyrics" size={20} /></span>
|
||
<span class="b"><span class="t">Word-by-word fill</span><span class="m">each word fills as it is sung; off keeps the line-by-line sync everywhere</span></span>
|
||
<span class="sw" class:on={!!cfg?.word_timing}><span></span></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sheet-sec">Album artwork</div>
|
||
<div class="group prefs" data-mode="albumart">
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Art inside downloaded files</span><span class="m">the canonical cover, or whatever the uploader embedded</span></div>
|
||
<div class="chips">
|
||
{#each ART_MODE as [v, l] (v)}<button class="chip" class:on={artMode === v} class:quiet={artMode !== v} onclick={() => setFlag("replaceEmbeddedArt", v === "replace")}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
<div class="pref">
|
||
<div class="pref-l"><span class="t">Embedded cover size</span><span class="m">the longest edge of the copy written into each file; the original is kept in the folder</span></div>
|
||
<div class="chips">
|
||
{#each ART_EDGE as [v, l] (v)}<button class="chip" class:on={artEdge === v} class:quiet={artEdge !== v} onclick={() => setNumber("embedArtMaxEdge", Number(v))}>{l}</button>{/each}
|
||
</div>
|
||
</div>
|
||
<button class="row" onclick={() => setFlag("writeFolderCover", !(cfg?.write_folder_cover ?? true))}>
|
||
<span class="lead"><Icon name="image" size={20} /></span>
|
||
<span class="b"><span class="t">Album folder cover</span><span class="m">cover.jpg and folder.jpg beside the files, so other players show the same art</span></span>
|
||
<span class="sw" class:on={cfg?.write_folder_cover ?? true}><span></span></span>
|
||
</button>
|
||
<button class="row" onclick={refetchCovers} disabled={refetching}>
|
||
<span class="lead"><Icon name="refresh" size={20} /></span>
|
||
<span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
|
||
</button>
|
||
<button class="row" onclick={refreshPortraits} disabled={refreshingArtists}>
|
||
<span class="lead"><Icon name="users" size={20} /></span>
|
||
<span class="b"><span class="t">{refreshingArtists ? "Refreshing portraits…" : "Refresh artist portraits"}</span><span class="m">a photo for every artist already in your library</span></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sheet-sec">This phone</div>
|
||
<div class="group">
|
||
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
|
||
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
|
||
</div>
|
||
|
||
|
||
<div class="endpad"></div>
|