android: what the phone says when things go wrong

Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
This commit is contained in:
KeelrLeek committed 2026-09-14 01:23:26 -05:00
1 parent 44eda8dff5
commit 052e15302b
22 files changed
+536 -34

No files matched your search

+99 -2
View File
@@ -623,6 +623,88 @@ it means splitting `.trk` (one flex row with a whole-row `:active`) and
rewriting the walk's `.trk .r` selectors, for no gain a screen reader
would notice.
## When things go wrong
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
a dead cell, or holding a library folder that has gone away, and until this
round every one of those showed up as an empty box, a spinner with no end, or
a Rust string in a red toast.
**One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend
actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`,
`login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer
rejected: {0}`, `timed out`), the commands' own literals (`not connected to
Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server:
{e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a
sentence a person can act on. The rule that matters: **the stores keep the raw
string and only the DRAWING is translated.** `player.error` still holds what
Rust said, Diagnostics and the walk still read it, and `say()` is called at the
one place the toast is written — which is why fifty-nine `String(e)` sinks were
fixed without touching fifty-nine call sites, and why the sixtieth is covered
too. A message the backend already wrote in English ("The playlist needs a
name") matches no rule and passes through untouched.
The worst one it retires was on the sign-in card: a phone with no DNS drew, in
red under the password box, `could not reach server: io: failed to lookup
address information: no address associated with hostname`. It now says
"Couldn't reach the Soulseek server. Check the connection and try again."
The red toast gained the notice's shape — the sentence, and one action beside
it. **Try again** when retrying is the answer (a resolve that found no sources,
a library read that failed at launch), **Sign in** when `signedOut()` says the
trouble is that nobody is. The action is tied to the exact message it belongs
to (`player.retry = { for, fn }`), so a later, different error can never
inherit the last one's button.
What each screen says now, where it used to say nothing or worse:
- **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable
to open database file" and then "No tracks match.", which is a failed read
dressed up as an empty library. Now: "Couldn't read the library index", the
translated reason, and a Try again. "No tracks match" only when the read
SUCCEEDED.
- **A playlist** — a rejected `playlist_items` said "This playlist is empty."
Empty now means empty; failed says so, with a Try again.
- **A catalog record** and **an artist page** — the catalog's own error is
translated, the record's stub offers a Try again, and the artist's stub says
"the catalog didn't answer" rather than "had nothing to add" when that is
what happened.
- **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back
to "See what's missing" and could be tapped for ever. It now says "Couldn't
reach the catalogue — tap to try again".
- **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline
with follows, Home drew empty rails and said nothing. One line under the lead
now does, and points at the library below.
- **Search** — the whole network section sat inside `{#if online}` with no
`{:else}`, so signed out with a query typed there was no row and no reason,
just a gap. Now there is a line and a door to Community.
- **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for
this track.", and that branch was also the only one of the four with no `⋯`
button, so Paste lyrics was unreachable exactly when you wanted it. Both are
fixed: a failure says so, and both branches carry the `⋯`.
- **Transfers** — `failed · <rust>` on a row is `failed · <sentence>`.
**Two spinners were given an end.** `request_room_list` is fire-and-forget over
the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and
there is no reply channel and no timeout), so "Asking the server for its
rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in
`refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that
never answers gets "The server hasn't sent its room list." and a Try again.
Tracks' own "Reading the index…" has its failure branch above. Everything else
was measured and left alone because it is already bounded: `user_info` 30 s,
`user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s.
**One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs)
ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose
path does not `exists()`, then every album left with no tracks. With the root
unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of
trouble — You → **Rescan library** emptied the whole index, silently, and
returned `Ok(())`. There is now a guard above the prune: make the root if it is
merely not there yet (a fresh install still works), and refuse with
`library-folder-missing: <path>` if it cannot be made or read. You catches that
into a row under Library folder with the path and what to do about it, and the
toast says it in English.
## The downloads in the shade
Transfers is a screen inside the app; the desktop says what a Save is doing
@@ -836,13 +918,28 @@ and read back from the config) → recent searches and a spelling
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
record with a long sheet over it, an artist page, and the side insets faked,
all at 915×411) and back, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The mock answers every command the phone
any exception or console error. The block at the end of the mock walk is the
weather: a library index that will not read and its Try again, the client taken
offline, a refused password and a server that cannot be reached, Search with
nowhere to search, a room list that never arrives, and a rescan that refuses
because the folder is gone — then all of it cleared and the app left as it was
found. The mock answers every command the phone
uses; one it does not know is named in the report (`[mock] no mock for X`)
rather than surfacing as a TypeError toast, console warnings carry their top
frames (the smoke bundle is built unminified for that), and every probe
carries the history mirror — the entry's depth against the layers the app
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
page after a confirmed delete was found). A mock step can also set the
page after a confirmed delete was found). The mock is also the weather:
`__ssMock.fail(cmd, message)` arms a command to reject with that exact string,
`answer(cmd, value)` arms one to resolve with a value the walk names (an empty
list, a null page), `offline(on)` takes the client down — everything the
backend funnels through `with_client()` answers "not connected to Soulseek",
the two connects answer the server string, and `soulseek:state` goes out — and
`clear()` puts all of it back. They reject with a plain string because that is
what Tauri rejects with. Nine mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). A mock step can
also set the
viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride`
through the same client, which is how the walk turns the phone over and back;
the phone walk refuses one, because a real device's orientation is not the
+10 -1
View File
@@ -423,12 +423,21 @@ fn downloads_dir(app: &tauri::AppHandle) -> PathBuf {
/// until the next app launch.
#[tauri::command]
pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> {
// The prune below deletes every track row whose file does not exist, and
// then every album left with none — so a root that has gone away (an
// unmounted card, a revoked SAF grant, a drive letter that moved) used to
// empty the WHOLE index on one tap and return Ok(()). Make the folder if
// it is merely not there yet, so a fresh install still works, and refuse
// if it cannot be made or read. Same idiom as connect_and_login's.
let root = downloads_dir(&app);
if std::fs::create_dir_all(&root).is_err() || !root.is_dir() {
return Err(format!("library-folder-missing: {}", root.display()));
}
let state = app.state::<LibraryState>();
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing())
.await
.map_err(|e| e.to_string())?;
let root = downloads_dir(&app);
sweep_stale_partials(&root);
sweep_stale_auditions(&root);
sweep_empty_dirs(&root, 0);
+68 -1
View File
@@ -363,6 +363,10 @@ const enabled = (() => {
/** Returns a Promise for mocked commands, or undefined to pass through. */
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
/* the weather, BEFORE any dispatch, so a plugin: command and a catalog
command arm exactly the same way (see the doors below) */
const forecast = weather(cmd);
if (forecast) return forecast;
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
@@ -419,7 +423,70 @@ export function mockEmit(event, payload) {
}
}
const later = (ms, fn) => setTimeout(fn, ms);
if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit };
/* ── the weather ────────────────────────────────────────────────────────
Thirty rounds of walk drove the happy path and nothing else: not one step
ever made a command reject, and not one ever saw the client signed out.
This mock IS the whole backend behind the browser walk, so it is the only
place the unhappy paths can be driven from.
Four doors, all of them checked in mockInvoke BEFORE any dispatch, so a
`plugin:` command and a catalog command arm exactly the same way:
__ssMock.fail(cmd, message) the next and every call to `cmd` rejects
with that exact string; "" disarms it.
__ssMock.answer(cmd, value) `cmd` resolves with `value` — an empty
list, a null page — instead of its
fixture, so the walk names what "empty"
means rather than the mock guessing.
__ssMock.forget(cmd) takes one armed answer away.
__ssMock.offline(on) the client goes down: everything the
backend funnels through with_client()
answers "not connected to Soulseek",
connect_stored / connect_and_login answer
the server string, and soulseek:state
goes out. off restores and re-connects.
__ssMock.clear() all of it away, for the walk's last step.
They reject with a PLAIN STRING, because that is what Tauri rejects with:
an Error would arrive as "Error: …" and hide half of what say() has to
cope with. All of it sits behind `enabled`, so a real build never sees it. */
const armedFail = new Map();
const armedAnswer = new Map();
let offlineNow = false;
/* what src-tauri/src/commands.rs runs through with_client(), plus the two
searches and a download, which check the connection themselves */
const needsClient = (cmd) => /^(slsk_|buddy_)/.test(cmd) || cmd === "start_search" || cmd === "stop_search" || cmd === "download_file";
function weather(cmd) {
const f = armedFail.get(cmd);
if (f != null) return Promise.reject(f);
if (offlineNow) {
if (cmd === "connect_stored" || cmd === "connect_and_login") {
return Promise.reject("could not reach server: io: failed to lookup address information: no address associated with hostname");
}
if (needsClient(cmd)) return Promise.reject("not connected to Soulseek");
}
if (armedAnswer.has(cmd)) return Promise.resolve(armedAnswer.get(cmd));
return undefined;
}
if (enabled && typeof window !== "undefined") {
window.__ssMock = {
emit: mockEmit,
fail(cmd, message) { if (message) armedFail.set(cmd, String(message)); else armedFail.delete(cmd); return true; },
answer(cmd, value) { armedAnswer.set(cmd, value); return true; },
forget(cmd) { armedAnswer.delete(cmd); return true; },
offline(on) {
offlineNow = !!on;
mockEmit("soulseek:state", offlineNow ? { kind: "disconnected" } : { kind: "connected", username: MOCK_USER });
return offlineNow;
},
clear() {
armedFail.clear();
armedAnswer.clear();
if (offlineNow) { offlineNow = false; mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }); }
return true;
},
};
}
const MOCK_USER = "mockuser";
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23], ...Array.from({ length: 90 }, (_, i) => ["Room " + (i + 1), 90 - i])];
+31 -4
View File
@@ -44,10 +44,11 @@
import { loadPrefs } from "./prefs.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, unreadTotal } from "./slsk.svelte.js";
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import { notice, clearNote } from "./notice.svelte.js";
import { say, signedOut } from "./trouble.js";
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter, startFolder } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
@@ -130,7 +131,7 @@
The desktop does the same (FollowToast's always-mounted .layer,
PreviewOverlay's .sr-announce). Never drawn — shell.css clips it to
a pixel. */
const announce = $derived([player.error || lib.error, notice.text, notice.action?.label].filter(Boolean).join(". "));
const announce = $derived([say(player.error || lib.error), notice.text, notice.action?.label].filter(Boolean).join(". "));
/* ── the ground: two layers, the incoming one fades up over the old ── */
let ambA = $state(null);
@@ -157,6 +158,20 @@
which is why that record is a plain object. */
$effect(() => { syncShade(); });
/* ── a library read that failed gets a Try again ──────────────────
The library is the one thing every screen stands on, and a rejected
read left the app empty with a Rust string over it. The retry is wired
HERE, where the toast is drawn, rather than in lib.svelte.js: that
store reaching for the player store would close an import loop —
player.svelte.js already reads lib. */
$effect(() => {
const e = lib.error;
if (!e) return;
untrack(() => {
player.retry = { for: e, fn: () => { player.error = ""; lib.error = ""; player.retry = null; void loadLibrary(); } };
});
});
/* ── the card gets the cover once the thumbnail has landed ───────── */
$effect(() => {
const k = player.album?.art_path;
@@ -233,7 +248,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
@@ -356,7 +371,19 @@
say nothing on their own — two announcers say everything twice -->
<div class="sr-live" data-live aria-live="polite" aria-atomic="true">{announce}</div>
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">{player.error || lib.error}</div>
{@const msg = player.error || lib.error}
<!-- the notice's shape, in the red: the sentence trouble.js makes of
whatever Rust said, and one action beside it where there is one to
offer — Sign in when nobody is, else the Try again that was armed
for THIS message and no other -->
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; player.retry = null; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">
<span class="nt">{say(msg)}</span>
{#if signedOut(msg)}
<button class="na" onclick={(e) => { e.stopPropagation(); player.error = ""; lib.error = ""; player.retry = null; go("community"); }}>Sign in</button>
{:else if player.retry?.for === msg}
<button class="na" onclick={(e) => { e.stopPropagation(); const r = player.retry; player.error = ""; lib.error = ""; player.retry = null; r.fn(); }}>Try again</button>
{/if}
</div>
{/if}
{#if notice.text}
<div class="toast notice">
+4
View File
@@ -210,8 +210,12 @@
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Not so? ⋯</button>
{:else if lyr.failed}
<div class="lyr-note">Couldn't look up the words — the lookup didn't answer.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Try again, or paste them ⋯</button>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Paste them, or search ⋯</button>
{/if}
</div>
{:else}
+6 -3
View File
@@ -23,6 +23,9 @@ export const lyr = $state({
autoSyncOff: false, // the user turned the automatic correction off here
reducing: false, // a vocal-reduced copy is being made
reduced: null, // { from, to } once one has been made for this track
/* the lookup itself failed, as opposed to finding nothing: the face has
said the wrong one of those since it was written */
failed: "",
});
let gen = 0;
@@ -54,13 +57,13 @@ export async function loadLyrics(force = false) {
if (!id) return;
if (!force && lyr.key === id.key) return;
const g = ++gen;
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, autoSyncOff: false, candidates: null, searching: false });
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, autoSyncOff: false, candidates: null, searching: false, failed: "" });
try {
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null });
if (g !== gen) return;
apply(p, true);
} catch {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
} catch (e) {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "", failed: String(e) });
}
}
+4
View File
@@ -58,6 +58,10 @@ export const player = $state({
/* the sleep timer: when it pauses, as a timestamp; 0 = off */
sleepAt: 0,
error: "",
/* the toast's one action, tied to the message it belongs to:
{ for: <the exact error>, fn } — so a later, different error can never
inherit the last one's Try again */
retry: null,
});
/** Things the shell plugs in: `recentsDirty` runs when a play has been
+8 -3
View File
@@ -89,10 +89,15 @@ function placeholder(title, artist, album) {
player.status = "buffering";
player.phase = "loading";
player.error = "";
player.retry = null;
}
function fail(msg) {
function fail(msg, retry = null) {
resolver.error = msg;
player.error = msg;
/* a Try again on the toast, tied to THIS message: the ladder is worth
another run when the network simply had nothing this time. Signed out it
is not, and the toast offers Community there instead. */
player.retry = retry ? { for: msg, fn: () => { void retry().catch((e) => { player.error = String(e); }); } } : null;
if (player.track?.id === "resolving") { player.track = null; player.status = "idle"; player.phase = "ready"; }
}
@@ -124,7 +129,7 @@ export async function streamSong(artist, title, o = {}) {
resolver,
);
const cands = songCandidates(responses, title, true, o.album, o.expectSecs, artist, !artistIncluded);
if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; }
if (!cands.length) { fail(`No streamable sources found for “${title}”`, () => streamSong(artist, title, o)); return; }
const [best, ...alts] = cands.slice(0, 4);
const e = streamEntry(title, artist, alt(best), o, album);
e.t.alts = alts.map(alt);
@@ -167,7 +172,7 @@ export async function streamCatalogAlbum(album, at = 0) {
resolver,
);
const folders = albumCandidates(responses, album, true, trackCount, !artistIncluded);
if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; }
if (!folders.length) { fail(`No streamable sources found for “${album.title}”`, () => streamCatalogAlbum(album, at)); return; }
const c = folders[0];
const entries = c.audio.map((f, i) => {
const ct = catalogTrackFor(f, i, c.audio, catalogTracks);
+1 -1
View File
@@ -363,7 +363,7 @@
{/if}
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"}
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog had nothing to add.</div>
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.</div>
{/if}
<div class="endpad"></div>
+3 -1
View File
@@ -15,6 +15,7 @@
import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey, loadTrackListeners } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum, saveCatalogAlbum } from "../resolve.svelte.js";
import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js";
import { say } from "../trouble.js";
import { mmss, longDur } from "../util.js";
let { id, artist = "", title = "", screenEl = null } = $props();
@@ -180,7 +181,8 @@
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 16px">Asking the catalog…</div>
{:else if entry?.status === "error"}
<div class="stub"><b>The catalog did not answer.</b>{entry.error}</div>
<div class="stub why"><b>The catalog did not answer.</b>{say(entry.error)}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={() => lookupAlbum(id)}>Try again</button></div>
{/if}
<div class="tracks" style="padding-top: 10px">
+11 -2
View File
@@ -12,6 +12,7 @@
import { player } from "../player.svelte.js";
import { ago } from "../util.js";
import { taste, loadTaste, setInterest, askSimilar } from "../taste.svelte.js";
import { say } from "../trouble.js";
let { entry, screenEl = null } = $props();
@@ -112,7 +113,7 @@
<input class="field" type={showPass ? "text" : "password"} bind:value={pass} placeholder="Password" aria-label="Soulseek password" autocomplete="current-password" enterkeyhint="go" onkeydown={(e) => { if (e.key === "Enter") signIn(); }} />
<button class="iconbtn sm muted" onclick={() => (showPass = !showPass)} aria-label="Show password"><Icon name="eye" size={18} /></button>
</div>
{#if slsk.loginError}<div class="signin-err">{slsk.loginError}</div>{/if}
{#if slsk.loginError}<div class="signin-err">{say(slsk.loginError)}</div>{/if}
<button class="btn btn-fill wide" style="height:46px" disabled={slsk.connection === "connecting" || !user.trim() || !pass} onclick={signIn}>
{slsk.connection === "connecting" ? "Signing in…" : "Sign in"}
</button>
@@ -152,7 +153,15 @@
{/if}
<div class="sec" style="padding-top:{joinedNames.length ? 18 : 8}px"><h2>Rooms <em>· {slsk.rooms.length} on the server</em></h2></div>
{#if !slsk.rooms.length}
{#if slsk.roomsLate}
<!-- request_room_list is fire-and-forget over the wire, so this
line used to sit here for ever against a server that
simply never answered -->
<div class="sec-note rooms-note">The server hasn't sent its room list.</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={refreshRooms}>Try again</button></div>
{:else}
<div class="sec-note">Asking the server for its rooms…</div>
{/if}
{:else if slsk.rooms.length > 8}
<div class="sfield roomfilter">
<Icon name="search" size={18} />
@@ -232,7 +241,7 @@
</button>
{/each}
{:else if taste.error}
<div class="stub"><b>The server did not answer.</b>{taste.error}</div>
<div class="stub"><b>The server did not answer.</b>{say(taste.error)}</div>
{:else if !taste.likes.length}
<div class="stub"><b>Nothing to match you on yet.</b>The server puts people together on the likes you declare — declare one above and it has something to go on.</div>
{:else}
+8
View File
@@ -44,6 +44,10 @@
});
let loadedIds = "";
const slots = $derived(home.order.map((id) => home.slots[id]).filter(Boolean));
/* every follow's slot came back empty-handed. home.svelte.js has always
set slot.error on a failed catalog page and nothing has ever read it, so
offline with follows Home drew empty rails and said nothing at all. */
const catalogDown = $derived(lib.follows.length > 0 && slots.length > 0 && slots.every((s) => s.error));
const WINDOW_DAYS = 90;
const ROW_CAP = 10;
const feed = $derived(assembleFeed(slots, WINDOW_DAYS));
@@ -215,6 +219,10 @@
</div>
{/if}
{#if catalogDown}
<div class="sec-note cat-down">Couldn't reach the catalog, so there's nothing new from your artists here. What the library holds is below.</div>
{/if}
{#if feedRest.length}
<div class="sec"><h2>New from your artists <em>· {lib.follows.length} followed</em></h2></div>
<div class="rail">
+6 -2
View File
@@ -62,6 +62,9 @@
let gapRows = $state({}); // foldKey(artist) → catalog rows not owned
let gapOpen = $state({});
let gapLoading = $state({});
/* the lookup's catch was empty, so a failed reach put the line back to
"See what's missing" and it could be tapped for ever with no reason */
let gapErr = $state({});
const shelves = $derived(arts.filter((ar) => followedEntry(ar.name)));
const ownedTitles = (name) => new Set(lib.albums.filter((a) => foldKey(a.artist) === foldKey(name)).map((a) => looseTitle(a.title)));
async function toggleGaps(name) {
@@ -71,6 +74,7 @@
if (!follow) return;
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
gapErr = { ...gapErr, [key]: "" };
try {
const page = await api.catalogArtist(follow.artist_id);
const released = (page?.albums ?? []).filter((a) => !isUpcoming(a) && !a.appears_on);
@@ -78,7 +82,7 @@
gapRows = { ...gapRows, [key]: released.filter((r) => !owned.has(looseTitle(r.title))) };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
} catch (e) { gapErr = { ...gapErr, [key]: String(e) }; }
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[key]) gapOpen = { ...gapOpen, [key]: true };
@@ -171,7 +175,7 @@
</button>
{#if count !== 0}
<button class="gapline" onclick={() => toggleGaps(ar.name)} aria-expanded={!!gapOpen[key]}>
{#if gapLoading[key]}Checking the catalogue…{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
{#if gapLoading[key]}Checking the catalogue…{:else if gapErr[key]}Couldn't reach the catalogue — tap to try again{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
<span class="cta">{gapOpen[key] ? "hide" : "show"}<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} /></span>
</button>
{:else}
+12 -3
View File
@@ -14,6 +14,7 @@
import { player } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";
import { say } from "../trouble.js";
import { mmss, longDur } from "../util.js";
let { id } = $props();
@@ -21,15 +22,20 @@
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
let items = $state([]);
let loaded = $state(false);
/* a rejected read used to leave items empty and loaded true, so the page
said "This playlist is empty." about a playlist it had not read */
let loadErr = $state("");
let again = $state(0);
let gen = 0;
$effect(() => {
const p = pl;
if (!p) return;
void p.revision;
void again;
const g = ++gen;
api.playlistItems(p.id).then(
(r) => { if (g === gen) { items = r || []; loaded = true; } },
(e) => { if (g === gen) { loaded = true; player.error = String(e); } },
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } },
(e) => { if (g === gen) { loaded = true; loadErr = String(e); } },
);
});
/* the bar's sheet (playlist-more) reads the open page's rows here */
@@ -97,7 +103,10 @@
</div>
</div>
{#if loaded && !items.length}
{#if loaded && loadErr}
<div class="stub why"><b>Couldn't read this playlist.</b>{say(loadErr)}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={() => { loadErr = ""; again++; }}>Try again</button></div>
{:else if loaded && !items.length}
<div class="stub"><b>This playlist is empty.</b>Add tracks from a record's or a track's sheet — long-press one and choose Add to playlist.</div>
{:else}
{#if items.length}
+10 -3
View File
@@ -14,13 +14,14 @@
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { lib, artists } from "../lib.svelte.js";
import { push, openHalf, patch } from "../nav.svelte.js";
import { push, go, openHalf, patch } from "../nav.svelte.js";
import { player, playIndexRow, albumOfRow, playEntries } from "../player.svelte.js";
import { slsk } from "../slsk.svelte.js";
import { net, search as netSearch, speedLabel, groupFormat, groupEntries } from "../net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { catalog, artistKey, loadProfile, seedFor, lookupArtist, yearOf, fans, picture } from "../catalog.svelte.js";
import { latestOf } from "../disco.js";
import { say } from "../trouble.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { entry, screenEl = null } = $props();
@@ -316,7 +317,7 @@
</div>
{/if}
{:else}
{#if catErr}<div class="sec-note">{catErr}</div>{/if}
{#if catErr}<div class="sec-note">{say(catErr)}</div>{/if}
{#if catBusy && !cat}<div class="sec-note">Asking the catalog…</div>{/if}
{#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean <button class="dym" onclick={() => setQ(didYouMean)}>{didYouMean}</button>?{/if}</div>{/if}
@@ -429,7 +430,7 @@
<h2>The network <em>· {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}</em></h2>
{#if !net.searching}<button class="all net-ask" onclick={askNetwork}>Again<Icon name="refresh" size={14} /></button>{/if}
</div>
{#if net.error}<div class="sec-note">{net.error}</div>{/if}
{#if net.error}<div class="sec-note">{say(net.error)}</div>{/if}
{#if net.groups.length}
<div class="chips netfilters" style="padding-bottom: 10px">
<button class="chip" class:on={netFree} class:quiet={!netFree} aria-pressed={netFree} onclick={() => (netFree = !netFree)}>Free slot</button>
@@ -472,6 +473,12 @@
</div>
{/each}
{/if}
{:else}
<!-- signed out, the whole network section rendered nothing at all: no
row where "Search the network" lives, and no reason for its
absence. The catalog and the library above still answer. -->
<div class="sec-note offline-note">Not signed in, so nothing here can play from the network yet — sign in on Community.</div>
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => go("community")}>Go to Community</button></div>
{/if}
{/if}
+12 -5
View File
@@ -7,6 +7,7 @@
import * as api from "../../lib/api.js";
import { player, playIndexRow, albumOfRow } from "../player.svelte.js";
import { openHalf, patch } from "../nav.svelte.js";
import { say } from "../trouble.js";
import { mmss, longDur } from "../util.js";
let { entry } = $props();
@@ -17,10 +18,14 @@
$effect(() => {
if (index !== null) return;
api.libraryTrackIndex().then(
(rows) => { index = rows || []; },
(rows) => { index = rows || []; failed = ""; },
(e) => { index = []; failed = String(e); },
);
});
/* a rejected read used to draw "0 tracks · 0:00 · unable to open database
file" and "No tracks match." under it — a failure dressed as an empty
library. Putting the index back to null re-runs the effect above. */
function reread() { failed = ""; index = null; }
let q = $state(entry.c.tf || "");
let inputEl = $state(null);
@@ -51,9 +56,8 @@
{#if q}<button class="iconbtn sm" onclick={() => { setQ(""); inputEl?.focus(); }} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
</div>
<div class="sec-note">
{#if index === null}Reading the index…{:else}{index.length} tracks · {longDur(total)}{/if}
{#if list.length > CAP} · showing {CAP} of {list.length}{/if}
{#if failed} · {failed}{/if}
{#if index === null}Reading the index…{:else if failed}The index could not be read.{:else}{index.length} tracks · {longDur(total)}{/if}
{#if !failed && list.length > CAP} · showing {CAP} of {list.length}{/if}
</div>
{#each list.slice(0, CAP) as r, i (r.path)}
@@ -64,6 +68,9 @@
<span class="r" role="button" tabindex="0" aria-label={"More for " + r.title} onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); more(r); }}><Icon name="more" size={17} /></span>
</button>
{/each}
{#if index && !list.length}
{#if failed}
<div class="stub why"><b>Couldn't read the library index.</b>{say(failed)}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={reread}>Try again</button></div>
{:else if index && !list.length}
<div class="stub"><b>No tracks match.</b></div>
{/if}
+22 -1
View File
@@ -14,6 +14,8 @@
let cfg = $state(null);
let scanning = $state(false);
/* the library root the backend could not reach, out of its refusal */
let folderGone = $state("");
$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) */
@@ -62,7 +64,19 @@
async function rescan() {
if (scanning) return;
scanning = true;
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
folderGone = "";
try { await api.rescanLibrary(); await loadLibrary(); }
catch (e) {
/* the backend refuses a rescan whose root it cannot reach
(src-tauri/src/pipeline.rs): the prune inside it would otherwise
delete every row whose file it could not see, which is the whole
library when the card is out */
const msg = String(e);
const TAG = "library-folder-missing:";
const at = msg.indexOf(TAG);
if (at >= 0) folderGone = msg.slice(at + TAG.length).trim();
player.error = msg;
}
scanning = false;
}
@@ -215,6 +229,13 @@
<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>
{#if folderGone}
<div class="row folder-gone">
<span class="lead"><Icon name="alert" size={20} /></span>
<span class="b"><span class="t">The library folder isn't there</span><span class="m mono">{folderGone}</span></span>
</div>
<div class="sec-note" style="padding: 0 14px 12px">Nothing was forgotten — the rescan refused rather than dropping every record it could not see. Reconnect the storage, or pick the folder again above.</div>
{/if}
<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>
+27
View File
@@ -1533,3 +1533,30 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.phead .all { font-size: 12.5px; font-weight: 750; color: var(--text-secondary); white-space: nowrap; }
.phead .all:active { color: #fff; }
.phead .all + .all { margin-left: 15px; }
/* ── what the phone says when things go wrong ──────────────────────────
The error toast gets the notice's shape: the sentence, and one action
beside it where there is one to offer — Try again, or Sign in when the
trouble is that nobody is signed in. The rules mirror .toast.notice's
above rather than sharing them: this toast is the red one, and its line
WRAPS (a translated sentence is a sentence, not a label), which is the
one thing the notice's .nt must never do. */
.toast { display: flex; align-items: center; gap: 12px; }
.toast .nt { flex: 1; min-width: 0; }
.toast .na { flex: none; color: #ffd7d7; font-weight: 800; font-size: 13px; padding: 4px 8px; margin: -4px -8px -4px 0; border-radius: 6px; white-space: nowrap; }
.toast .na:active { background: rgba(255, 255, 255, 0.12); }
/* a stub that is explaining a failure rather than an emptiness: the same
card, with the backend's own words under the lead in the muted colour */
.stub.why { color: var(--text-muted); }
/* the two lines a screen says instead of a spinner that will not end */
.sec-note.rooms-note,
.sec-note.offline-note { color: #ffb4b4; }
/* the library folder the rescan could not reach — a row under the folder
itself, so the path and what to do about it are in the same place. The
path is drawn exactly as the Library folder row above it draws its own
(`.m mono`, which .row does not give a face — leave that as it is rather
than making these two rows disagree). */
.row.folder-gone .t { color: #ffb4b4; }
+17 -1
View File
@@ -23,6 +23,15 @@ export const slsk = $state({
buddyState: {},
/** the room or the person whose screen is open, so its unread stays 0 */
openChat: null,
/** The server's room list is FIRE-AND-FORGET over the wire: the client
* sends `room_list` and there is no reply channel and no timeout
* (crates/soulseek-client/src/lib.rs), so a server that never answers
* left "Asking the server for its rooms…" up for ever. The wait is ours.
* `roomsWaitMs` is a plain field so the walk can shorten it rather than
* the harness needing a back door — which does mean the mechanism is
* proved at 1.2 s and the shipped 12 s is a judgement, not a measurement. */
roomsWaitMs: 12000,
roomsLate: false,
});
const FEED_MAX = 300;
@@ -78,8 +87,13 @@ export async function forgetAccount() {
slsk.hasPassword = false;
}
let roomsTimer = null;
export function refreshRooms() {
api.slskRoomList().catch(() => {});
slsk.roomsLate = false;
clearTimeout(roomsTimer);
roomsTimer = setTimeout(() => { if (!slsk.rooms.length) slsk.roomsLate = true; }, slsk.roomsWaitMs);
/* a refused request is a late list at once — there is nothing to wait for */
api.slskRoomList().catch(() => { clearTimeout(roomsTimer); slsk.roomsLate = true; });
}
export async function joinRoom(name) {
await api.slskJoinRoom(name);
@@ -141,6 +155,8 @@ export async function bindSlsk() {
}
});
await on("soulseek:room-list", (rooms) => {
clearTimeout(roomsTimer);
slsk.roomsLate = false;
slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));
});
await on("soulseek:room-joined", (p) => {
+2 -1
View File
@@ -4,6 +4,7 @@
numbers keeps the last known progress), pruned once a burst drains. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { say } from "./trouble.js";
export const transfers = $state({
/** id → payload { id, username, path, name, state, queue_place, local_path, error, received, total } */
@@ -40,7 +41,7 @@ export function stateLine(d) {
case "queued": return d.queue_place ? `queued · ${d.queue_place} ahead` : "queued";
case "transferring": return d.total ? `${Math.round((d.received / d.total) * 100)}%` : "transferring…";
case "complete": return "saved";
case "failed": return d.error ? `failed · ${d.error}` : "failed";
case "failed": return d.error ? `failed · ${say(d.error)}` : "failed";
case "cancelled": return "cancelled";
default: return d.state || "";
}
+70
View File
@@ -0,0 +1,70 @@
/* What the phone says when things go wrong.
The backend answers in Rust. `ClientError`'s Display impls (crates/
soulseek-client/src/lib.rs) hand up `io: {0}`, `protocol: {0}`, `login
failed: INVALIDPASS`, `user {0} is offline or unknown`, `timed out`; the
commands add their own literals (`not connected to Soulseek`, `no stored
credentials`, `no cache dir`, `could not reach server: {e}`); rusqlite and
std::io come through `e.to_string()`. Fifty-nine places in src/mobile/ put
one of those straight into a field a person reads, and the red toast drew
it verbatim — a phone in a lift showed, under the password box, "could not
reach server: io: failed to lookup address information: no address
associated with hostname".
ONE translator, called where the words are DRAWN rather than where they
are caught. The stores keep the raw string — Diagnostics and the walk still
see exactly what Rust said — and only the sentence on screen is ours, so
every future `String(e)` is covered by the same rule as the fifty-nine
already here. A message the backend already wrote in English ("The playlist
needs a name") matches nothing and passes through untouched.
The order below is the order they are tried: the most specific shape
first, the two catch-alls (`io:` / `protocol:`) last but one, and anything
unrecognised kept as it is, capped. */
/** The message inside whatever was thrown: an Error, a plain string (which
* is what Tauri rejects with), or something with neither. */
const clean = (e) => String(e?.message ?? e ?? "").replace(/^Error:\s*/, "").trim();
const RULES = [
/* this round's own guard (src-tauri/src/pipeline.rs): the library root
could not be made or read, so the rescan refused rather than pruning
every row whose file it could not see */
[/^library-folder-missing:\s*(.*)$/i, (m) => `The library folder isn't there any more — ${m[1]}. Reconnect the storage, or pick the folder again in You.`],
/* with_client()'s funnel, and start_search's plainer version */
[/not connected( to (soulseek|server))?$/i, () => "Not signed in to Soulseek. Sign in on Community and try again."],
[/^no stored credentials$/i, () => "No Soulseek account saved on this phone yet. Sign in on Community."],
/* the server's own refusal words, which are not for reading */
[/^login failed:\s*(.*)$/i, (m) => (/invalidpass/i.test(m[1]) ? "Soulseek refused that password." : /invalidusername/i.test(m[1]) ? "Soulseek refused that username." : `Soulseek refused the sign-in (${m[1]}).`)],
[/^could not reach server/i, () => "Couldn't reach the Soulseek server. Check the connection and try again."],
[/^timed out$/i, () => "The network didn't answer in time."],
[/^user (.+?) is offline or unknown$/i, (m) => `${m[1]} isn't online right now.`],
[/^could not reach peer (.+)$/i, (m) => `${m[1]}'s client didn't answer — offline, or behind a firewall.`],
[/^transfer rejected:\s*(.*)$/i, (m) => `They refused the transfer${m[1] ? ` (${m[1].replace(/\.$/, "")})` : ""}.`],
[/^(io|protocol):\s*(.*)$/i, () => "The network didn't answer. Check the connection and try again."],
/* the library's own two: a database that will not open, and a folder the
phone has not been given */
[/unable to open database file|os error (2|3)\b|cannot find the (path|file)/i, () => "The library folder isn't readable right now."],
[/permission denied|os error 13\b/i, () => "The phone refused access to that folder — check the app's storage permission in You."],
[/^no cache dir$/i, () => "This phone has no cache folder to work in."],
/* a page with no Tauri under it — the browser walk, and only ever that */
[/reading 'invoke'|transformCallback/i, () => "The app's backend isn't here (this is the browser walk)."],
];
/** Longer than this and it was never a sentence anyone meant to write. */
const CAP = 180;
/** The backend's error → something a person can act on. */
export function say(e) {
const raw = clean(e);
if (!raw) return "";
for (const [re, fn] of RULES) {
const m = re.exec(raw);
if (m) return fn(m);
}
return raw.length > CAP ? raw.slice(0, CAP - 1) + "…" : raw;
}
/** Is this the kind of trouble Community fixes? The two places that offer a
* way to sign in rather than a Try again ask here. */
export const signedOut = (e) => /not connected|no stored credentials/i.test(clean(e));
+105
View File
@@ -251,6 +251,26 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
/* ── the weather: what the screen says when a read fails, an answer
is empty, or nobody is signed in. Every one of these is a line
this round put there; before it they were an empty box, a spinner
with no end, or a Rust string in the red toast. */
slskOn: (function () { try { return __ss.slsk.connection; } catch (e) { return ''; } })(),
signin: !!document.querySelector('.signin'),
signinErr: (document.querySelector('.signin-err') || {}).textContent || '',
why: (document.querySelector('.stub.why') || {}).textContent || '',
/* the toast's one action, by its words: "Try again" or "Sign in" */
retry: (document.querySelector('.toast:not(.notice) .na') || {}).textContent || '',
again: !![].slice.call(document.querySelectorAll('.imp-foot .btn')).find(function (b) { return /Try again/.test(b.textContent); }),
roomsNote: (document.querySelector('.rooms-note') || {}).textContent || '',
offlineNote: (document.querySelector('.offline-note') || {}).textContent || '',
netAsk: !!document.querySelector('.net-ask'),
folderGone: (document.querySelector('.folder-gone') || {}).textContent || '',
lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '',
lyrTools: !!document.querySelector('.lyr-tools'),
trks: document.querySelectorAll('.screen .trk').length,
/* the phone's trouble-words step leaves its eleven sentences here */
trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(),
syStrip: document.querySelectorAll('.sy-strip .sy-col').length,
syTicks: document.querySelectorAll('.sy-strip .sy-tick').length,
syEst: (document.querySelector('.sy-est') || {}).textContent || '',
@@ -745,6 +765,62 @@ const MOCK_STEPS = [
{ view: [411, 914], wait: 800 },
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
{ do: `__ss.closeSheet()`, wait: 600 },
/* ── the weather ────────────────────────────────────────────────────
Everything above this line is the happy path; nothing in thirty
rounds of walk had ever made a command reject or taken the client
offline, so none of these lines had ever been drawn. The mock's own
doors (__ssMock.fail / answer / offline / clear) are the weather, and
every assertion below is on SETTLED state — an armed failure stays
armed, a sign-in card stays up, the folder-gone row stays — because
the probe lands after the screenshot, well past the step's own wait.
The one timed assertion is the room list, and its timeout is shortened
to 1.2 s against the 3.1 s of waits between arming it and reading it.
The last step puts the weather away and
leaves the app exactly as it found it — anything appended after this
block inherits a signed-in app on Home.
The first step settles FIRST, and it has to: the landscape block above
leaves the library tab three screens deep (record → artist), so one tap
on the Library tab lands on the artist page and `.segtabs button` would
be its discography tabs, not Albums/Artists/Tracks. It also wipes any
error the happy path left standing, so `!i.toast` below means this
block's toast and no other. */
{ do: `(function () { __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 },
{ do: `__ssMock.fail('library_track_index', 'unable to open database file')`, wait: 300 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "weather-tracks", wait: 1300 },
{ do: `__ssMock.fail('library_track_index', '')`, wait: 200 },
{ do: `__click('.imp-foot .btn', 'Try again')`, name: "weather-tracks-ok", wait: 1300 },
/* signed out, in a lift: the card is back and nothing says why in Rust */
{ do: `__ssMock.offline(true)`, wait: 600 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "weather-signedout", wait: 1100 },
/* the single most visible raw-Rust site on the phone, twice over */
{ do: `__ssMock.fail('connect_and_login', 'login failed: INVALIDPASS')`, wait: 200 },
{ do: `(function () { var p = document.querySelectorAll('.signin .field')[1]; p.value = 'hunter2'; p.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, wait: 400 },
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-badpass", wait: 1200 },
{ do: `__ssMock.fail('connect_and_login', 'could not reach server: io: failed to lookup address information: no address associated with hostname')`, wait: 200 },
{ do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-noserver", wait: 1200 },
/* Search signed out with a query typed showed NOTHING where the network
section lives — no row, no reason. Now it says so and offers the door */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, name: "weather-search-out", wait: 1800 },
/* the room list is fire-and-forget over the wire (the client sends it and
nothing answers if the server will not), so the wait is ours: an armed
answer that resolves and emits nothing is exactly that server. Community
is left on Buddies by the walk above, so the Rooms tab is asked for by
name — the note lives under that one. */
{ do: `__ssMock.fail('connect_and_login', ''); __ssMock.offline(false)`, wait: 1200 },
{ do: `(function () { __ss.slsk.roomsWaitMs = 1200; __ss.slsk.rooms = []; __ssMock.answer('slsk_room_list', null); __ss.refreshRooms(); return true; })()`, wait: 200 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, wait: 900 },
{ do: `__click('.segtabs button', 'Rooms')`, name: "weather-rooms-late", wait: 2200 },
/* one tap on Rescan library used to empty the whole index and say Ok */
{ do: `__ssMock.fail('rescan_library', 'library-folder-missing: /storage/1A2B-3C4D/Music')`, wait: 200 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
{ do: `__click('.screen .row', 'Rescan library')`, name: "weather-rescan", wait: 1500 },
/* and the weather clears */
{ do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 },
{ do: `__settle()`, wait: 900 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 },
];
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
@@ -791,6 +867,24 @@ const MOCK_EXPECT = {
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
"sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"),
/* ── the weather ────────────────────────────────────────────────────
Each of these fails if the round's fix is undone: the stub if a failed
read goes back to being drawn as an empty library, the sign-in lines if
the toast or the card stops translating, the search note if the block
goes back to rendering nothing at all signed out, the room note if the
spinner loses its end, the folder row if the rescan stops refusing.
Every one also asserts the RAW string is gone — that is the round. */
"weather-tracks": (i) => /^Couldn't read the library index/.test(i.why) && /library folder isn't readable/.test(i.why)
&& i.again && i.trks === 0 && !/database file/.test(i.why + i.toast),
"weather-tracks-ok": (i) => !i.why && !i.again && i.trks > 0 && !i.toast,
"weather-signedout": (i) => i.slskOn === "offline" && i.signin && !i.signinErr && !i.toast,
"weather-badpass": (i) => i.signinErr === "Soulseek refused that password." && !/INVALIDPASS/.test(i.signinErr),
"weather-noserver": (i) => /^Couldn't reach the Soulseek server/.test(i.signinErr) && !/io:|address information/.test(i.signinErr),
"weather-search-out": (i) => i.title.startsWith("Search") && /Community/.test(i.offlineNote) && !i.netAsk,
"weather-rooms-late": (i) => i.slskOn === "online" && /hasn't sent its room list/.test(i.roomsNote) && i.again,
"weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast)
&& !/library-folder-missing/.test(i.toast + i.folderGone),
"weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title),
/* sideways: the chrome budget, then every surface that was clipped. The
before-value of each is in the rig's now.json — 46% of the height as
chrome, a 879px cover with the transport 687px below the fold, a
@@ -1119,6 +1213,12 @@ const PHONE_STEPS = [
{ do: `history.back()`, wait: 800 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
/* the translator, on the real build: the backend's own vocabulary in,
plain sentences out. No side effects — nothing is signed out (the
login throttle is a remembered trap), nothing is asked of the network,
nothing is written. It also catches a build where trouble.js simply
did not ship: __ss.say would not be there and the line stays empty. */
{ do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
];
const PHONE_EXPECT = {
@@ -1197,6 +1297,11 @@ const PHONE_EXPECT = {
&& /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast),
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
/* eleven real backend strings through the one translator on the device:
every output a sentence, and not one of them still carrying Rust */
"trouble-words": (i) => i.trouble.split(" || ").length === 11
&& i.trouble.split(" || ").every(function (s) { return s.length > 12; })
&& !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble),
end: (i) => /^(Good |Still)/.test(i.title),
};