From 052e15302b57d7b914348fc5a54854f6a339b7e0 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:23:26 -0500 Subject: [PATCH] android: what the phone says when things go wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ANDROID.md | 101 +++++++++++++++++++++++- src-tauri/src/pipeline.rs | 11 ++- src/lib/mockLibrary.js | 69 +++++++++++++++- src/mobile/Mobile.svelte | 35 ++++++++- src/mobile/Sheet.svelte | 4 + src/mobile/lyrics.svelte.js | 9 ++- src/mobile/player.svelte.js | 4 + src/mobile/resolve.svelte.js | 11 ++- src/mobile/screens/Artist.svelte | 2 +- src/mobile/screens/CatalogAlbum.svelte | 4 +- src/mobile/screens/Community.svelte | 15 +++- src/mobile/screens/Home.svelte | 8 ++ src/mobile/screens/Library.svelte | 8 +- src/mobile/screens/Playlist.svelte | 15 +++- src/mobile/screens/Search.svelte | 13 ++- src/mobile/screens/Tracks.svelte | 17 ++-- src/mobile/screens/You.svelte | 23 +++++- src/mobile/shell.css | 27 +++++++ src/mobile/slsk.svelte.js | 18 ++++- src/mobile/transfers.svelte.js | 3 +- src/mobile/trouble.js | 70 +++++++++++++++++ tests/mobile/smoke.mjs | 105 +++++++++++++++++++++++++ 22 files changed, 537 insertions(+), 35 deletions(-) create mode 100644 src/mobile/trouble.js diff --git a/ANDROID.md b/ANDROID.md index d8c7892..4530446 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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 · ` on a row is `failed · `. + +**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: ` 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 diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 67e57f5..7956c97 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -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::(); 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); diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 2b5669f..aab4f5a 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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])]; diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index cfa6449..f609ff1 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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(); }; }); @@ -356,7 +371,19 @@ say nothing on their own — two announcers say everything twice -->
{announce}
{#if player.error || lib.error} -
{ player.error = ""; lib.error = ""; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">{player.error || lib.error}
+ {@const msg = player.error || lib.error} + +
{ player.error = ""; lib.error = ""; player.retry = null; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message"> + {say(msg)} + {#if signedOut(msg)} + + {:else if player.retry?.for === msg} + + {/if} +
{/if} {#if notice.text}
diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte index d1e75e1..8ebde09 100644 --- a/src/mobile/Sheet.svelte +++ b/src/mobile/Sheet.svelte @@ -210,8 +210,12 @@ {:else if lyr.status === "instrumental"}
Instrumental.
+ {:else if lyr.failed} +
Couldn't look up the words — the lookup didn't answer.
+ {:else}
No lyrics found for this track.
+ {/if}
{:else} diff --git a/src/mobile/lyrics.svelte.js b/src/mobile/lyrics.svelte.js index acec7da..4a8152d 100644 --- a/src/mobile/lyrics.svelte.js +++ b/src/mobile/lyrics.svelte.js @@ -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) }); } } diff --git a/src/mobile/player.svelte.js b/src/mobile/player.svelte.js index aad362d..91b3d81 100644 --- a/src/mobile/player.svelte.js +++ b/src/mobile/player.svelte.js @@ -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: , 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 diff --git a/src/mobile/resolve.svelte.js b/src/mobile/resolve.svelte.js index af70e7d..c713fbf 100644 --- a/src/mobile/resolve.svelte.js +++ b/src/mobile/resolve.svelte.js @@ -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); diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index 8545cd1..f94d067 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -363,7 +363,7 @@ {/if} {#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"} -
Nothing here yet.No records by {shownName} on this phone, and the catalog had nothing to add.
+
Nothing here yet.No records by {shownName} on this phone, and the catalog {entry?.status === "error" ? "didn't answer" : "had nothing to add"}.
{/if}
diff --git a/src/mobile/screens/CatalogAlbum.svelte b/src/mobile/screens/CatalogAlbum.svelte index 895c79d..e68b50c 100644 --- a/src/mobile/screens/CatalogAlbum.svelte +++ b/src/mobile/screens/CatalogAlbum.svelte @@ -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"}
Asking the catalog…
{:else if entry?.status === "error"} -
The catalog did not answer.{entry.error}
+
The catalog did not answer.{say(entry.error)}
+
{/if}
diff --git a/src/mobile/screens/Community.svelte b/src/mobile/screens/Community.svelte index 3cb3caf..7052fe4 100644 --- a/src/mobile/screens/Community.svelte +++ b/src/mobile/screens/Community.svelte @@ -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 @@ { if (e.key === "Enter") signIn(); }} />
- {#if slsk.loginError}{/if} + {#if slsk.loginError}{/if} @@ -152,7 +153,15 @@ {/if}

Rooms · {slsk.rooms.length} on the server

{#if !slsk.rooms.length} -
Asking the server for its rooms…
+ {#if slsk.roomsLate} + +
The server hasn't sent its room list.
+
+ {:else} +
Asking the server for its rooms…
+ {/if} {:else if slsk.rooms.length > 8}
@@ -232,7 +241,7 @@ {/each} {:else if taste.error} -
The server did not answer.{taste.error}
+
The server did not answer.{say(taste.error)}
{:else if !taste.likes.length}
Nothing to match you on yet.The server puts people together on the likes you declare — declare one above and it has something to go on.
{:else} diff --git a/src/mobile/screens/Home.svelte b/src/mobile/screens/Home.svelte index 6b6681f..348cb0b 100644 --- a/src/mobile/screens/Home.svelte +++ b/src/mobile/screens/Home.svelte @@ -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 @@
{/if} +{#if catalogDown} +
Couldn't reach the catalog, so there's nothing new from your artists here. What the library holds is below.
+{/if} + {#if feedRest.length}

New from your artists · {lib.follows.length} followed

diff --git a/src/mobile/screens/Library.svelte b/src/mobile/screens/Library.svelte index ba8ef41..4a41a54 100644 --- a/src/mobile/screens/Library.svelte +++ b/src/mobile/screens/Library.svelte @@ -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 @@ {#if count !== 0} {:else} diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte index 9d21aeb..df5061c 100644 --- a/src/mobile/screens/Playlist.svelte +++ b/src/mobile/screens/Playlist.svelte @@ -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 @@
- {#if loaded && !items.length} + {#if loaded && loadErr} +
Couldn't read this playlist.{say(loadErr)}
+
+ {:else if loaded && !items.length}
This playlist is empty.Add tracks from a record's or a track's sheet — long-press one and choose Add to playlist.
{:else} {#if items.length} diff --git a/src/mobile/screens/Search.svelte b/src/mobile/screens/Search.svelte index b3cfd60..aa0aeec 100644 --- a/src/mobile/screens/Search.svelte +++ b/src/mobile/screens/Search.svelte @@ -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 @@ {/if} {:else} - {#if catErr}
{catErr}
{/if} + {#if catErr}
{say(catErr)}
{/if} {#if catBusy && !cat}
Asking the catalog…
{/if} {#if catEmpty && !catBusy}
Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean ?{/if}
{/if} @@ -429,7 +430,7 @@

The network · {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}

{#if !net.searching}{/if} - {#if net.error}
{net.error}
{/if} + {#if net.error}
{say(net.error)}
{/if} {#if net.groups.length}
@@ -472,6 +473,12 @@
{/each} {/if} + {:else} + +
Not signed in, so nothing here can play from the network yet — sign in on Community.
+
{/if} {/if} diff --git a/src/mobile/screens/Tracks.svelte b/src/mobile/screens/Tracks.svelte index 9c8c708..5cb1bc9 100644 --- a/src/mobile/screens/Tracks.svelte +++ b/src/mobile/screens/Tracks.svelte @@ -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}{/if}
- {#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}
{#each list.slice(0, CAP) as r, i (r.path)} @@ -64,6 +68,9 @@ {}} onclick={(e) => { e.stopPropagation(); more(r); }}> {/each} -{#if index && !list.length} +{#if failed} +
Couldn't read the library index.{say(failed)}
+
+{:else if index && !list.length}
No tracks match.
{/if} diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte index 7c1e589..04d4183 100644 --- a/src/mobile/screens/You.svelte +++ b/src/mobile/screens/You.svelte @@ -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 @@ {scanning ? "Scanning…" : "Rescan library"}looks for new files in the folder, forgets the ones that are gone + {#if folderGone} +
+ + The library folder isn't there{folderGone} +
+
Nothing was forgotten — the rescan refused rather than dropping every record it could not see. Reconnect the storage, or pick the folder again above.
+ {/if}