android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest

The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
This commit is contained in:
KeelrLeek committed 2026-09-13 19:56:20 -05:00
1 parent 6e39dc43f3
commit 26928b706d
21 files changed
+1294 -92

No files matched your search

+153 -6
View File
@@ -3,8 +3,9 @@
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 } from "../lib.svelte.js";
import { push } from "../nav.svelte.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";
@@ -47,6 +48,16 @@
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;
@@ -61,6 +72,22 @@
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;
@@ -72,6 +99,43 @@
} 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);
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; }
}
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"],
@@ -122,6 +186,10 @@
<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>
@@ -129,6 +197,23 @@
</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)}
@@ -155,15 +240,77 @@
</div>
{/each}
<div class="sheet-sec">Downloads</div>
<div class="group">
<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>
</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="stub">
<b>The network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components.
</div>
<div class="endpad"></div>