android: a streamed file copied into a folder, the listening port, the dev build's diagnostics

The now-playing sheet's streamed track can be copied into a folder picked
on the phone, as the file it is — the cache written out, nothing downloaded
again, no library row (copy_stream_to_folder, which the desktop has had a
command for and no UI). You → Sharing carries the listening port, what
peers dial when they download from you: unset is the client's own 50300,
the app asks the router to forward it over UPnP and falls back to the
server relaying, and a change applies on the next connect. The dev flavour
gets the desktop's debug overlay as a screen under This phone — CPU,
memory, the network, the stream and its loudness, what the pre-buffer is
fetching, the size of the play history — behind devBuild in util.js, which
is Vite's own DEV flag, so the embedded build the phone keeps has no row
to tap.
This commit is contained in:
KeelrLeek committed 2026-09-13 23:59:47 -05:00
1 parent 1737c5e1b6
commit aff29aba5c
8 files changed
+272 -2

No files matched your search

+29
View File
@@ -597,6 +597,34 @@ function mockCatalog(cmd, args) {
}
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
if (cmd === "copy_stream_to_folder") {
/* the real command copies only what the stream cache already holds */
let saved = 0, missing = 0;
for (const f of args?.files || []) { if (cacheReady.has(f.path)) saved++; else missing++; }
return Promise.resolve([saved, missing]);
}
if (cmd === "play_row_count") return Promise.resolve(4210);
if (cmd === "debug_stats") {
/* the overlay's shape, camelCase as serde renames it; the numbers
wander a little so the screen is seen to be live */
const t = Date.now() / 1000;
return Promise.resolve({
cpuProcess: 7.5 + Math.sin(t), cpuSystem: 22 + Math.cos(t) * 3,
gpuPercent: null, gpuAppPercent: null,
memWorkingSet: 431_000_000, memPrivate: 0,
memSystemUsed: 5_400_000_000, memSystemTotal: 7_900_000_000,
netRxBps: 1_250_000, netTxBps: 96_000,
processCount: 3, uptimeSecs: 742,
dlReceived: xfer ? Math.round(xfer.received) : null,
dlTotal: xfer ? xfer.total : null,
dlDone: xfer ? xfer.received >= xfer.total : null,
normalizationEnabled: mockSettings.volume_normalization !== false, targetLufs: -14,
loudness: { lufs: -9.8, peak: 0.98, gainDb: -4.2, cached: true, analyzing: false, source: "cache", headStartDb: 0, prefixGainDb: null },
mainFadeGain: 1, normNowDb: -4.2, normTargetDb: -4.2,
previewVolume: null, previewNormDb: null,
preloadDl: [{ username: "dusty_crates", path: "Music\\Daft Punk\\Discovery\\02 Aerodynamic.flac", state: "transferring", place: null, received: 5_000_000, total: 12_000_000, speedBps: 850_000 }],
});
}
if (cmd === "get_settings") return Promise.resolve({ ...mockSettings, stream_source_prefs: { ...mockSettings.stream_source_prefs }, download_source_prefs: { ...mockSettings.download_source_prefs } });
if (cmd === "set_settings") {
/* the command's arguments are camelCase; the config's fields are snake_case */
@@ -624,6 +652,7 @@ const mockSettings = {
library_layout: "{artist}\\{album}",
shared_folders: [],
upload_slots: 2,
listen_port: null,
share_files: 1234,
word_timing: false,
replace_embedded_art: true,
+20 -1
View File
@@ -214,6 +214,24 @@
: "Nothing to save");
} catch (e) { oops(e); }
}
/* the desktop's copy_stream_to_folder: the cached stream written into a
folder picked on this phone, as the file it is — no re-download, no
tagging, no library row. Nothing leaves the phone, so the Wi-Fi guard
has no say here. A track that has not finished streaming is counted
back as missing, never fetched. */
async function copyToFolder(entries) {
const files = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path }));
if (!files.length) return;
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can write to — pick one on this phone's storage."; return; }
const [saved, missing] = await api.copyStreamToFolder(files, r.path);
note(saved ? saved + " file" + (saved === 1 ? "" : "s") + " copied" + (missing ? " · " + missing + " not cached yet" : "")
: "Not cached yet — play it through first");
} catch (e) {
if (!/cancelled/.test(String(e))) oops(e);
}
}
/* a peer's sheet (a raw result's folder, or the browse screen's user) */
function playGroup(g) {
const es = groupEntries(g, 0);
@@ -346,6 +364,7 @@
{#if streamMates(track, album).length > 1}
{@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))}
{/if}
{@render row("copy", "Copy to a folder…", "the streamed file as it is, into a folder on this phone", act(() => copyToFolder([{ t: track, album }])))}
<div class="sheet-hair"></div>
{/if}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
@@ -487,7 +506,7 @@
{#if a.multiline}
<textarea class="field area" bind:value={promptVal} placeholder={a.placeholder || ""} rows="3" use:focusLater></textarea>
{:else}
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" inputmode={a.numeric ? "numeric" : undefined} enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
{/if}
<div class="prompt-acts">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
+4
View File
@@ -32,6 +32,7 @@
import Thread from "./screens/Thread.svelte";
import You from "./screens/You.svelte";
import Transfers from "./screens/Transfers.svelte";
import Diagnostics from "./screens/Diagnostics.svelte";
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
import { bindHome } from "./home.svelte.js";
import Stub from "./screens/Stub.svelte";
@@ -73,6 +74,7 @@
case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "diag": return { title: "Diagnostics", kicker: "Dev build" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "browse": return { title: e.c.username, kicker: "Their files" };
case "peer": return { title: e.c.username, kicker: "On Soulseek" };
@@ -251,6 +253,8 @@
<You />
{:else if entry.n === "transfers"}
<Transfers />
{:else if entry.n === "diag"}
<Diagnostics />
{:else}
<Stub kind={entry.n} />
{/if}
+108
View File
@@ -0,0 +1,108 @@
<script>
/* Diagnostics — the desktop's debug overlay (Ctrl+Shift+D) as a screen,
on the dev flavour only: what this process costs the phone, what the
network is moving, the track playing and its loudness correction, the
tracks the pre-buffer is fetching behind it, and how big the play
history has grown. Polled once a second while the screen is open and
never otherwise, the way the overlay does it. The GPU counters are
Windows PDH and the private commit is a Win32 call, so neither row is
here — on the phone they would read nothing at all. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { fmtBytes, basename } from "../util.js";
let stats = $state(null);
let plays = $state(null);
let failed = $state("");
/* nothing reactive is READ in here: the effect runs once when the screen
mounts and its cleanup when the screen leaves. An effect that reads
what it writes takes its whole subtree down with it. */
$effect(() => {
let live = true;
const tick = async () => {
try {
const s = await api.debugStats();
if (live) { stats = s; failed = ""; }
} catch (e) {
if (live) failed = String(e);
}
};
void tick();
api.playRowCount().then((n) => { if (live) plays = n; }, () => {});
const id = setInterval(tick, 1000);
return () => { live = false; clearInterval(id); };
});
const pct = (v) => (v == null ? "—" : v.toFixed(1) + "%");
const size = (n) => (n ? fmtBytes(n) : "—");
const rate = (n) => (n ? fmtBytes(n) + "/s" : "—");
const db = (v) => (v == null ? "—" : (v > 0 ? "+" : "") + v.toFixed(1) + " dB");
const upfor = (s) => {
if (s == null) return "—";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return h ? h + " hr " + m + " min" : m ? m + " min" : s + " s";
};
const share = (p) => (p.total ? Math.max(0.02, Math.min(1, p.received / p.total)) : 0);
</script>
<div class="phead"><div class="row"><h1>Diagnostics</h1></div></div>
<div class="sec-note">The desktop's debug overlay, on the dev flavour. Polled once a second while this screen is open.</div>
{#if failed}
<div class="stub">
<b>No diagnostics.</b>
{failed}
</div>
{/if}
<div class="sheet-sec">This process</div>
<div class="group diag">
<div class="kv"><span class="k">App CPU</span><span class="v">{pct(stats?.cpuProcess)}</span></div>
<div class="kv"><span class="k">System CPU</span><span class="v">{pct(stats?.cpuSystem)}</span></div>
<div class="kv"><span class="k">Memory</span><span class="v">{size(stats?.memWorkingSet)}</span></div>
<div class="kv"><span class="k">System memory</span><span class="v dim">{size(stats?.memSystemUsed)} of {size(stats?.memSystemTotal)}</span></div>
<div class="kv"><span class="k">Processes</span><span class="v">{stats?.processCount ?? "—"}</span></div>
<div class="kv"><span class="k">Up</span><span class="v">{upfor(stats?.uptimeSecs)}</span></div>
</div>
<div class="sec-note">Processes counts this app's own tree. Android runs the WebView in a system process of its own, so it is outside these numbers; on the desktop the whole WebView2 family is inside them.</div>
<div class="sheet-sec">Network</div>
<div class="group diag">
<div class="kv"><span class="k">Down</span><span class="v">{rate(stats?.netRxBps)}</span></div>
<div class="kv"><span class="k">Up</span><span class="v">{rate(stats?.netTxBps)}</span></div>
</div>
<div class="sec-note">Every interface on the phone, not this app alone.</div>
<div class="sheet-sec">The track playing</div>
<div class="group diag">
<div class="kv"><span class="k">Stream</span><span class="v">{!stats ? "—" : stats.dlTotal ? size(stats.dlReceived) + " of " + size(stats.dlTotal) + (stats.dlDone ? " · done" : "") : "nothing streaming"}</span></div>
<div class="kv"><span class="k">Normalization</span><span class="v">{!stats ? "—" : stats.normalizationEnabled ? "on · " + (stats.targetLufs ?? -14) + " LUFS" : "off"}</span></div>
<div class="kv"><span class="k">Measured</span><span class="v dim">{stats?.loudness?.lufs != null ? stats.loudness.lufs.toFixed(1) + " LUFS" + (stats.loudness.analyzing ? " · measuring" : "") : "—"}</span></div>
<div class="kv"><span class="k">Gain</span><span class="v">{db(stats?.normNowDb)}{stats && stats.normNowDb != null && stats.normNowDb !== stats.normTargetDb ? " → " + db(stats.normTargetDb) : ""}</span></div>
</div>
<div class="sheet-sec">On deck</div>
{#if stats?.preloadDl?.length}
<div class="group diag-deck">
{#each stats.preloadDl as p (p.username + p.path)}
<div class="row">
<span class="lead"><Icon name="download" size={20} /></span>
<span class="b">
<span class="t">{basename(p.path)}</span>
<span class="m">{p.username} · {p.state}{p.place != null ? " · queue " + p.place : ""}{p.speedBps ? " · " + fmtBytes(p.speedBps) + "/s" : ""}</span>
{#if p.total}<span class="lbar"><i style="transform: scaleX({share(p).toFixed(3)})"></i><em>{fmtBytes(p.received)}</em></span>{/if}
</span>
</div>
{/each}
</div>
{:else}
<div class="sec-note">Nothing pre-buffering. The player readies the next three tracks while one plays.</div>
{/if}
<div class="sheet-sec">Library</div>
<div class="group diag">
<div class="kv"><span class="k">Play rows</span><span class="v">{plays != null ? plays.toLocaleString() : "—"}</span></div>
</div>
<div class="endpad"></div>
+33
View File
@@ -10,6 +10,7 @@
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
import { refreshSourcePrefs } from "../../lib/sources.js";
import { devBuild } from "../util.js";
let cfg = $state(null);
let scanning = $state(false);
@@ -130,6 +131,25 @@
}
}
const removeShared = (folder) => saveCfg({ sharedFolders: (cfg?.shared_folders ?? []).filter((f) => f !== folder) });
/* the Soulseek listener: the port other peers dial when they download
from you. Unset means the client's own 50300, read at connect — bind
walks forward a few ports if it is taken. `set_settings` only assigns
a port that is Some, so a number typed here can be changed but never
taken back to unset. */
const DEFAULT_LISTEN_PORT = 50300;
function portPrompt() {
openHalf("prompt", {
title: "Listening port",
value: cfg?.listen_port != null ? String(cfg.listen_port) : "",
placeholder: String(DEFAULT_LISTEN_PORT),
ok: "Save", numeric: true,
onsubmit: (v) => {
const n = Number(v);
if (!Number.isInteger(n) || n < 1024 || n > 65535) { player.error = "A port is a number between 1024 and 65535."; return; }
void saveCfg({ listenPort: n });
},
});
}
async function refetchCovers() {
if (refetching) return;
refetching = true;
@@ -265,6 +285,7 @@
</div>
<div class="sheet-sec">Sharing</div>
<div class="sec-note">What other people can take from you, and how they reach you. Your library folder is always shared; the listening port is what peers dial when they download from you — the app asks the router to forward it over UPnP and falls back to the server relaying the connection when the router will not. On mobile data the carrier's NAT blocks incoming connections whatever the port is, and a change applies on the next connect.</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">
@@ -272,6 +293,11 @@
<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>
<button class="row" onclick={portPrompt}>
<span class="lead"><Icon name="hash" size={20} /></span>
<span class="b"><span class="t">Listening port</span><span class="m">{cfg?.listen_port ?? DEFAULT_LISTEN_PORT}{cfg?.listen_port == null ? " (the default)" : ""} · what peers dial to download from you</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#each cfg?.shared_folders ?? [] as folder (folder)}
<div class="row">
<span class="lead"><Icon name="folder" size={20} /></span>
@@ -328,6 +354,13 @@
<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>
{#if devBuild}
<button class="row" onclick={() => push("diag", {})}>
<span class="lead"><Icon name="devices" size={20} /></span>
<span class="b"><span class="t">Diagnostics</span><span class="m">the desktop's debug overlay — this process, the network, the stream and what is pre-buffering</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
</div>
+10
View File
@@ -175,3 +175,13 @@ export function drag(el, opts) {
},
};
}
/** The dev flavour, and nothing else. That build is the one that loads from
* the Vite dev server over the cable (ANDROID.md, "Iterating without
* cargo"), so Vite's own DEV flag IS the flavour; the embedded build the
* phone keeps is compiled and reads false. The mock walk builds for
* production too, so it says who it is through the key the harness sets. */
export const devBuild = (() => {
if (import.meta.env.DEV) return true;
try { return localStorage.getItem("mockLibrary") === "1"; } catch { return false; }
})();