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

+28 -1
View File
@@ -237,7 +237,11 @@ Library clears the play history behind a confirm. The now-playing
sheet's ⋯ on a streamed track is the desktop's
Save menu: Save to library (at once when the cache is byte-complete, else
through the download pipeline) and Save this record for the streamed
record the queue holds. A moment after boot the phone warms the library's thumbnails the
record the queue holds; beside them, "Copy to a folder…" writes that same
cached stream into a folder picked on this phone, as the plain file it is
(`copy_stream_to_folder` — no re-download, no tagging, no library row; a
track that has not finished streaming is counted back, never fetched).
A moment after boot the phone warms the library's thumbnails the
desktop's way (`library_art_prewarm` at the cards' edge, one decode at a
time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
@@ -422,6 +426,29 @@ the lyric alignment models (a desktop runtime), artist artwork's upscaler.
Search's empty page lists the recent searches this phone used (the last
eight: Enter, or the screen left with a query in it), kept on the device.
Sharing carries one thing the desktop never exposed at all: the listening
port (`listenPort`), what other peers dial when they download from you.
Unset means the client's own 50300 (`DEFAULT_LISTEN_PORT`; bind walks
forward a few ports if it is taken); the app asks the router to forward it
over UPnP and falls back to the server relaying the connection, and a
change applies on the next connect — on mobile data the carrier's NAT
blocks incoming connections whatever the number is. `set_settings` only
assigns a port that is Some, so it can be changed but never taken back to
unset: the phone walk saves a spare number and then 50300, which is the
port an unset config already used.
The dev flavour gets one more row under This phone: Diagnostics
(`screens/Diagnostics.svelte`), the desktop's Ctrl+Shift+D overlay as a
screen — process and system CPU, memory, the network rates, the track
playing with its loudness correction, the tracks the pre-buffer is
fetching, and how big the play history has grown (`debug_stats`,
`play_row_count`), polled once a second while it is open and never
otherwise. `devBuild` in `util.js` is the gate: Vite's own DEV flag, true
only for the build that loads from the dev server over the cable, so the
embedded build the phone keeps has no row to tap. The GPU counters are
Windows PDH and the private commit is a Win32 call, so neither is on the
screen — off Windows they report None and 0.
## Background playback and the lock-screen card
The service is started with `startForegroundService`, so it promotes itself
+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; }
})();
+40
View File
@@ -167,6 +167,11 @@ const PROBE = `(function () {
hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(),
cardImgs: document.querySelectorAll('.rail .acard img').length,
cardPhs: document.querySelectorAll('.rail .acard svg').length,
port: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Listening port/.test(x.textContent); }); var m = r && r.querySelector('.m'); return m ? m.textContent.trim() : ''; })(),
diagRows: document.querySelectorAll('.group.diag .kv').length,
diagCpu: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /App CPU/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
diagPlays: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /Play rows/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
diagDeck: document.querySelectorAll('.group.diag-deck .row').length,
popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length,
barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length,
heroPix: !!document.querySelector('.hero .hero-img.dissolve'),
@@ -336,6 +341,10 @@ const MOCK_STEPS = [
/* the now-playing sheet's ⋯: the streamed track saved into the library
(its cache is complete by now, so it files at once) */
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, name: "np-more", wait: 700 },
/* the same cache, copied out as a plain file into a picked folder —
nothing downloaded again, no library row */
{ do: `__click('.halfsheet .row', 'Copy to a folder')`, name: "np-copied", wait: 900 },
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
/* the raw peers: a peer's sheet → their shares browsed → a folder opened
→ one of its files streams */
@@ -506,6 +515,19 @@ const MOCK_STEPS = [
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 },
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-off", wait: 400 },
/* the listening port: typed in, saved, read back off the config — then
a port below 1024 refused and the saved one left standing */
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50555'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 700 },
{ do: `__click('.screen .row', 'Listening port')`, wait: 500 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 600 },
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
/* the dev flavour's diagnostics — the desktop's Ctrl+Shift+D overlay as
a screen; a normal build has no row here to tap */
{ do: `__click('.screen .row', 'Diagnostics')`, name: "diag", wait: 1500 },
{ do: `history.back()`, name: "diag-back", wait: 800 },
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */
@@ -555,6 +577,11 @@ const MOCK_EXPECT = {
"wifi-on": (i) => i.wifiOnly === "on",
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
"wifi-off": (i) => i.wifiOnly === "off" && !i.toast,
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port),
"you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port),
"diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1,
"diag-back": (i) => i.title.startsWith("You"),
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1,
@@ -625,6 +652,7 @@ const MOCK_EXPECT = {
"net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
"net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"),
"np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"np-copied": (i) => !i.half.includes("up") && /1 file copied/.test(i.notice),
"np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
"net-filter-flac": (i) => i.peers === 3,
@@ -817,6 +845,15 @@ const PHONE_STEPS = [
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words-off", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 },
/* the listening port on the real core, camelCase for real: a spare
number, then 50300 — the client's own default, so the phone ends the
walk on exactly the port an unset config would already have used */
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 900 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50310'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 1200 },
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
/* the real config's contact list, read back over the real IPC */
@@ -871,6 +908,9 @@ const PHONE_EXPECT = {
"you-words-off": (i) => i.wordfill === "off" && !i.toast,
"you-artedge": (i) => i.artEdge === "1000 px" && !i.toast,
"you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast,
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),