From aff29aba5c6d940a4b069ead0a43e2dd4aec68b1 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:59:47 -0500 Subject: [PATCH] android: a streamed file copied into a folder, the listening port, the dev build's diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ANDROID.md | 29 ++++++- src/lib/mockLibrary.js | 29 +++++++ src/mobile/HalfSheet.svelte | 21 ++++- src/mobile/Mobile.svelte | 4 + src/mobile/screens/Diagnostics.svelte | 108 ++++++++++++++++++++++++++ src/mobile/screens/You.svelte | 33 ++++++++ src/mobile/util.js | 10 +++ tests/mobile/smoke.mjs | 40 ++++++++++ 8 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/mobile/screens/Diagnostics.svelte diff --git a/ANDROID.md b/ANDROID.md index 6c9e696..6d7ade2 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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 diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index f5c78f8..09c77d1 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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, diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index c238774..214be82 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -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 }])))}
{/if} {@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))} @@ -487,7 +506,7 @@ {#if a.multiline} {:else} - { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> + { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> {/if}
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index fbab6d8..a2ce64d 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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 @@ {:else if entry.n === "transfers"} + {:else if entry.n === "diag"} + {:else} {/if} diff --git a/src/mobile/screens/Diagnostics.svelte b/src/mobile/screens/Diagnostics.svelte new file mode 100644 index 0000000..89f5097 --- /dev/null +++ b/src/mobile/screens/Diagnostics.svelte @@ -0,0 +1,108 @@ + + +

Diagnostics

+
The desktop's debug overlay, on the dev flavour. Polled once a second while this screen is open.
+ +{#if failed} +
+ No diagnostics. + {failed} +
+{/if} + +
This process
+
+
App CPU{pct(stats?.cpuProcess)}
+
System CPU{pct(stats?.cpuSystem)}
+
Memory{size(stats?.memWorkingSet)}
+
System memory{size(stats?.memSystemUsed)} of {size(stats?.memSystemTotal)}
+
Processes{stats?.processCount ?? "—"}
+
Up{upfor(stats?.uptimeSecs)}
+
+
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.
+ +
Network
+
+
Down{rate(stats?.netRxBps)}
+
Up{rate(stats?.netTxBps)}
+
+
Every interface on the phone, not this app alone.
+ +
The track playing
+
+
Stream{!stats ? "—" : stats.dlTotal ? size(stats.dlReceived) + " of " + size(stats.dlTotal) + (stats.dlDone ? " · done" : "") : "nothing streaming"}
+
Normalization{!stats ? "—" : stats.normalizationEnabled ? "on · " + (stats.targetLufs ?? -14) + " LUFS" : "off"}
+
Measured{stats?.loudness?.lufs != null ? stats.loudness.lufs.toFixed(1) + " LUFS" + (stats.loudness.analyzing ? " · measuring" : "") : "—"}
+
Gain{db(stats?.normNowDb)}{stats && stats.normNowDb != null && stats.normNowDb !== stats.normTargetDb ? " → " + db(stats.normTargetDb) : ""}
+
+ +
On deck
+{#if stats?.preloadDl?.length} +
+ {#each stats.preloadDl as p (p.username + p.path)} +
+ + + {basename(p.path)} + {p.username} · {p.state}{p.place != null ? " · queue " + p.place : ""}{p.speedBps ? " · " + fmtBytes(p.speedBps) + "/s" : ""} + {#if p.total}{fmtBytes(p.received)}{/if} + +
+ {/each} +
+{:else} +
Nothing pre-buffering. The player readies the next three tracks while one plays.
+{/if} + +
Library
+
+
Play rows{plays != null ? plays.toLocaleString() : "—"}
+
+ +
diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte index 742a705..cae14b4 100644 --- a/src/mobile/screens/You.svelte +++ b/src/mobile/screens/You.svelte @@ -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 @@
Sharing
+
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.
Your library folderalways shared · {(cfg?.share_files ?? 0).toLocaleString()} files
@@ -272,6 +293,11 @@ Upload slotshow many peers can download from you at once {cfg?.upload_slots ?? 2}
+ {#each cfg?.shared_folders ?? [] as folder (folder)}
@@ -328,6 +354,13 @@
SoulStream{lib.version || "—"}
Soulseek{lib.username || "not signed in"}
+ {#if devBuild} + + {/if}
diff --git a/src/mobile/util.js b/src/mobile/util.js index 4834233..c5c103f 100644 --- a/src/mobile/util.js +++ b/src/mobile/util.js @@ -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; } +})(); diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 7630706..6ad8e82 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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),