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}