Thirty rounds of onMount had never been read end to end, and read end to end it was a queue: two dozen commands with a critical path ten deep, because Tauri's `listen` is an invoke like any other. loadLibrary asked for five independent SQLite reads one behind the other, bindSlsk registered eight listeners one behind the other, and watchLibrary and the player's bindEvents did two each. None of them depended on anything before them. They go out in one round now — the same commands, critical path 10 to 3, and ten of them used to leave in the shell's own first turn where twenty-three do now — and in a rig that runs the real store modules with the bridge instrumented, lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip swept rather than measured; the phone's own numbers are unproven until the walk prints bootMs and bootLibMs. The behaviour is meant to be identical. loadLibrary's only movable part was lib.error, which has two writers and used to be decided by the order they were awaited in; the two messages are now carried out of the round and applied in that same order, so a failing library still shows the library's message. Every combination of which of the five reads fails, over six latency shapes, comes out identical to the old code. bindSlsk still does not return until every handler is up, so connectStored cannot let a fast connected state land with nobody listening, and bindEvents still hands back the transport's unlisten and still rejects only when the transport is the one that failed. src/lib/boot.js is the project's first launch instrument — one counter every invoke and every listen passes, sealed at the first frame the shell paints and never re-opened, so a probe landing a second and a half later still reads the launch. perf.mjs measures frames; nothing measured the launch. The mock walk's Home step now holds a budget against it and both walks print bootCalls, bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no clock in it: a call that waits on the one before it can only issue its first in the shell's own turn, so a chain put back in a queue falls under the floor whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there at all, which is the census itself being broken rather than the app. One import line goes with it: Sheet.svelte's `import * as api`, the only one of the ten dead imports this round turned up whose reason is the bundle at all. Rollup already drops an unreferenced NAMED import, so a dead name on an import line is hygiene rather than bytes; a namespace import is the one shape tree-shaking cannot see through, and this one was dead. It frees nothing on its own — twenty-nine other files in the phone's graph hold the same module the same way, so api.js is pinned into the chunk whatever Sheet.svelte does — but it is the only one of the ten that belongs to a round about weight. The nine that are drift go out with the sweep in the round after this one. Two things were looked at and left alone, both written down in ANDROID.md rather than half-done. Home's per-artist catalog walk is the deepest chain in the app, and that serial walk is the Deezer and MusicBrainz pacing, not an oversight. And the follows really are read twice at launch, but the obvious guard does not work: Home's effect calls loadHome with force the first time they land, so the guard would never fire. One small local read, left for a round that can do it properly.
175 lines
7.0 KiB
JavaScript
175 lines
7.0 KiB
JavaScript
/* The library, as the phone reads it: albums from library.db, the recents
|
|
from the play history, follows and playlists. One store, loaded once at
|
|
launch; the pieces that change (recents) are re-read when they do. */
|
|
import * as api from "../lib/api.js";
|
|
import { listen } from "../lib/events.js";
|
|
|
|
export const lib = $state({
|
|
albums: [],
|
|
recents: [], // library rows only, with an album_id
|
|
follows: [],
|
|
playlists: [],
|
|
/** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */
|
|
stats: {},
|
|
version: "",
|
|
username: null,
|
|
loaded: false,
|
|
error: "",
|
|
});
|
|
|
|
const tracksCache = new Map(); // album id -> Promise<Track[]>
|
|
|
|
/** An album's tracks, cached for the session (a record's rows do not
|
|
* change under the phone). */
|
|
export function tracksOf(id) {
|
|
if (!tracksCache.has(id)) {
|
|
tracksCache.set(
|
|
id,
|
|
api.albumTracks(id).catch((e) => { tracksCache.delete(id); throw e; }),
|
|
);
|
|
}
|
|
return tracksCache.get(id);
|
|
}
|
|
|
|
export function albumById(id) {
|
|
return lib.albums.find((a) => a.id === id) || null;
|
|
}
|
|
|
|
/** Newest first — "recently added" everywhere. */
|
|
export function newest() {
|
|
return lib.albums.slice().sort((a, b) => b.added_at - a.added_at);
|
|
}
|
|
|
|
/** An artist's records, newest year first. */
|
|
export function albumsBy(artist) {
|
|
const k = String(artist || "").toLowerCase();
|
|
return lib.albums
|
|
.filter((a) => a.artist.toLowerCase() === k)
|
|
.sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at);
|
|
}
|
|
|
|
/** Every artist in the library, biggest first, with the portrait the
|
|
* library already holds (artist_art_path) when it has one. Casing splits in
|
|
* the tags ("King Gizzard & The/the Lizard Wizard") merge here. */
|
|
export function artists() {
|
|
const m = new Map();
|
|
for (const a of lib.albums) {
|
|
const k = a.artist.toLowerCase();
|
|
let e = m.get(k);
|
|
if (!e) { e = { name: a.artist, albums: 0, tracks: 0, art: null, cover: null }; m.set(k, e); }
|
|
e.albums++;
|
|
e.tracks += a.track_count || 0;
|
|
if (!e.art && a.artist_art_path) e.art = a.artist_art_path;
|
|
if (!e.cover && a.art_path) e.cover = a.art_path;
|
|
}
|
|
return [...m.values()].sort((x, y) => y.albums - x.albums || y.tracks - x.tracks || x.name.localeCompare(y.name));
|
|
}
|
|
|
|
export function isFollowed(name) {
|
|
const k = String(name || "").toLowerCase();
|
|
return lib.follows.some((f) => f.name.toLowerCase() === k);
|
|
}
|
|
export function followedEntry(name) {
|
|
const k = String(name || "").toLowerCase();
|
|
return lib.follows.find((f) => f.name.toLowerCase() === k) || null;
|
|
}
|
|
export async function refreshFollows() {
|
|
try { lib.follows = (await api.followedArtists()) || []; } catch { /* keep what we had */ }
|
|
}
|
|
/** Follow or unfollow, by the catalog identity; the list is re-read. */
|
|
export async function toggleFollow(artistId, name, portrait = null) {
|
|
const cur = followedEntry(name);
|
|
if (cur) await api.unfollowArtist(cur.artist_id);
|
|
else await api.followArtist(artistId, name, portrait ?? null);
|
|
await refreshFollows();
|
|
}
|
|
|
|
export async function refreshRecents() {
|
|
try {
|
|
const rows = await api.recentAlbums(14);
|
|
lib.recents = (rows || []).filter((r) => r.kind === "library" && r.album_id != null);
|
|
} catch {
|
|
/* the rail simply stays as it was */
|
|
}
|
|
}
|
|
|
|
/** A download the pipeline just filed, a rescan, an import: the backend
|
|
* says so (soulseek:library-changed) and the albums are re-read. */
|
|
let watching = false;
|
|
export async function watchLibrary() {
|
|
if (watching) return;
|
|
watching = true;
|
|
let timer = null;
|
|
try {
|
|
/* two subscriptions that never depended on one another. `listen` is
|
|
an invoke like any other, so one behind the other is two bridge
|
|
round trips at launch for nothing. */
|
|
await Promise.all([
|
|
listen("soulseek:library-changed", () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(async () => {
|
|
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
|
|
await refreshRecents();
|
|
void refreshStats();
|
|
}, 600);
|
|
}),
|
|
/* a playlist mutation (the desktop's commands emit it) re-lists them */
|
|
listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }),
|
|
]);
|
|
} catch { /* not a phone */ }
|
|
}
|
|
|
|
export async function loadLibrary() {
|
|
/* Five reads that never depended on one another. Each is a
|
|
spawn_blocking SQLite read behind the library's single
|
|
`conn: Mutex<Connection>`, so the queries still take their turn —
|
|
what is saved is five bridge round trips collapsing into one.
|
|
|
|
Every call keeps the handling it had. The one thing that could have
|
|
moved is `lib.error`, which has two writers and used to be settled
|
|
by the order they were awaited in; the two messages are carried out
|
|
of the round and applied in that same order instead, so a failing
|
|
library still shows the library's message and not app_status's, no
|
|
matter which of them answers last. Nothing is dropped, nothing new
|
|
is asked for, and `lib.loaded` still goes up only once all five
|
|
have settled. */
|
|
const [statusErr, albumsErr, , follows, playlists] = await Promise.all([
|
|
api.appStatus().then((st) => {
|
|
lib.version = st?.version ?? "";
|
|
lib.username = st?.connected_as ?? null;
|
|
return null;
|
|
}, (e) => String(e)),
|
|
api.libraryAlbums().then((rows) => { lib.albums = rows; return null; }, (e) => String(e)),
|
|
refreshRecents(), // writes lib.recents itself, and swallows its own failure
|
|
api.followedArtists().then((rows) => rows || [], () => []),
|
|
api.listPlaylists().then((rows) => rows || [], () => []),
|
|
]);
|
|
if (statusErr) lib.error = statusErr;
|
|
if (albumsErr) lib.error = albumsErr;
|
|
lib.follows = follows;
|
|
lib.playlists = playlists;
|
|
lib.loaded = true;
|
|
void refreshStats();
|
|
}
|
|
|
|
/** The files' own aggregates per record — one call for the whole library
|
|
* (the desktop's RECENTS_FLOOR_SECS, 45, is the floor a play must reach
|
|
* to count as the last one). */
|
|
export async function refreshStats() {
|
|
try {
|
|
const rows = (await api.libraryAlbumStats(45)) || [];
|
|
lib.stats = Object.fromEntries(rows.map((r) => [r.album_id, r]));
|
|
} catch { /* the cards stay quiet */ }
|
|
}
|
|
/** "FLAC 24/96" for a lossless record, "MP3 320k" for a lossy one. */
|
|
export function statLabel(st) {
|
|
if (!st || !st.formats?.length) return "";
|
|
const f = String(st.formats[0]).toUpperCase();
|
|
if (st.bit_depth && st.sample_rate) return f + " " + st.bit_depth + "/" + (st.sample_rate % 1000 === 0 ? st.sample_rate / 1000 : (st.sample_rate / 1000).toFixed(1));
|
|
return st.avg_kbps ? f + " " + st.avg_kbps + "k" : f;
|
|
}
|
|
|
|
export async function refreshPlaylists() {
|
|
try { lib.playlists = (await api.listPlaylists()) || []; } catch { /* keep what we have */ }
|
|
}
|