android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest

The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
This commit is contained in:
KeelrLeek committed 2026-09-13 19:56:20 -05:00
1 parent 6e39dc43f3
commit 26928b706d
21 files changed
+1294 -92

No files matched your search

+81 -12
View File
@@ -172,6 +172,46 @@ namesake), the catalog's released records minus the shelf's by loose title,
dimmed cards that open the catalog record page, the totals remembered in
`library.gaps.v1` so the line says its number before the lookup.
## A peer, and their shares
A raw result's ⋯ (or a long press on its head) is the peer's sheet: play
the folder as a record, save it to the library as it is, browse their
files, message them, add or remove them as a buddy. **Browse** is the
hub's Browse tab as a screen (`screens/Browse.svelte`, `slsk_browse_user`)
— the same sheet is a buddy's on Community → Buddies (a long press), and
"Look up a user" there opens it for any name typed (the hub's browse by
name):
every folder they share with its files, sizes and formats, the totals in
the header; a folder opens to its files, plays through the stream engine
or saves whole. One request for the whole share, so a big one takes a
moment — and a share can hold thousands of folders (the first peer the
walk met: 7,511 folders, 158,937 files, 2.4 TB), so the screen shows a
filter and renders 80 at a time with a "more" button; rendering all of
them at once cost an audio dropout. The phone walk browses the first peer
of its network search.
More long presses: a catalog song in the search results opens the catalog
track's sheet (play, save, add to a playlist, the record, the artist); a
row of the queue face opens the track's sheet with Play now, Move to next
and Remove from queue on top; a raw result's file (and a file on the Browse
screen) opens its own — play from here, save this one file, browse; a name
in a room's list opens the peer sheet. The catalog record page's bar ⋯ is
the record's sheet: play, save, add every track to a playlist as streams,
the artist — and the same sheet is a long press on any catalog card (the
results' Records rail, Home's feed, the Gaps shelf). The now-playing
sheet's lyrics face has its tools on a long press or the link under the
words (`lyrics.svelte.js`, the desktop's): the offset nudged half a
second at a time and kept for the track, another LRCLIB candidate chosen
when the words are wrong, the lookup forgotten and done again. A record's
sheet re-fetches its cover (the desktop's per-record refresh); You →
Library clears the play history behind a confirm. The now-playing
sheet's ⋯ on a streamed track is the desktop's
Save menu: Save to library (at once when the cache is byte-complete, else
through the download pipeline) and Save this record for the streamed
record the queue holds. Search corrects an obvious misspelling from the
names this phone knows (the library, the searches that worked) — the desktop's rule,
a word only when a real name is within two edits.
## Playlists
The desktop's two playlist pages (`src/views/Playlists.svelte`,
@@ -203,7 +243,11 @@ describe / pin / duplicate / delete, `playlist_add_items`,
slice starts at its cue (`t.startSecs`) and the player moves on at the
next cue (`t.endSecs`); the scrubber still shows the whole recording.
- **A row's sheet** (`pltrack`): Play from here, Play next, Add to queue,
Save to library for a stream row, Go to artist, Remove from playlist.
Save to library for a stream row, Find a local match for a row without
a file (the desktop's repair panel as a sheet: the library searched by
the row's name, a pick relinks the row through `playlist_relink_item`;
an unknown placeholder adopts the track's identity), Go to artist,
Remove from playlist.
A removal is undoable for seven seconds from the neutral toast
(`notice.svelte.js` — the desktop's undo pill); the stash pins the
playlist it came from.
@@ -240,8 +284,8 @@ describe / pin / duplicate / delete, `playlist_add_items`,
itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the file and folder import doors (the files are on
the desktop), the listener door, the find-a-local-match repair panel, drag
reorder, the cover picker, export, Share on SoulStream.
the desktop), the listener door, drag reorder, the cover picker, export,
Share on SoulStream.
## Settings that reach the config
@@ -254,6 +298,18 @@ back from the config). You → Sources is the desktop's Source quality
section: per mode (streaming, downloads) the ceiling (no limit / lossy
only), the floor (any … lossless only) and the priority (best quality /
fastest transfer); the resolver re-reads them (`refreshSourcePrefs`).
You → Streaming is the desktop's Streaming section: how long the stream
cache keeps a track after its last play (or Forever — everything streamed
goes to the library) and the cache's size limit. Downloads (the album
folder format), Sharing (the library folder is always shared, the upload
slots, extra shared folders picked on the phone), Lyrics (word-by-word
fill) and Album artwork (canonical vs the uploader's art, the embedded
cover size, the folder cover, re-fetch all covers) follow the same
sections of the desktop's Settings. Not on the phone: the account (sign-in
lives on Community), the networks, updates (the APK comes from the build),
the lyric alignment models (a desktop runtime), artist artwork's upscaler.
Search's empty page lists the recent searches this phone used (the last
eight: Enter, or the screen left with a query in it), kept on the device.
## Background playback and the lock-screen card
@@ -430,9 +486,13 @@ shelf and a page with every row kind (a local row, the stream row through
the resolver, the filter, a removal and its undo, unpin, rename, a new
playlist, Add to playlist from a record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, a share from
another app) → You (the normalization switch and a source-quality chip,
each read back from the config) → the sleep timer, asserts what each step
should show, screenshots every step into `tests/mobile/out/`, and fails on
another app) → the playlist repair (an unknown row relinked from the
library) → the artwork viewer → a queue row's sheet → a catalog song's,
a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
of theirs streamed → a buddy's sheet → You (every switch and chip saved
and read back from the config) → recent searches and a spelling
suggestion → the sleep timer, 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
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
@@ -443,7 +503,11 @@ page after a confirmed delete was found).
The phone walk is the same driver on the real thing (plus a step kind of
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
key): a debug build's WebView
key; every CDP call times out after 45 s and the reload step re-attaches
to the page, since a WebView that stops answering once hung a walk for ten
minutes; its audio check counts SoulStream's OWN tracks' underruns from the
mixer dump — the thread-wide counters move with any app's notification
sound, and sent the walk chasing dropouts that were not ours): a debug build's WebView
exposes DevTools on `localabstract:webview_devtools_remote_<pid>`, `adb
forward` puts it on a local port, and the steps run against the real library
— real playback, real covers, the colour following the record — with the
@@ -464,11 +528,16 @@ paused) without the walk.
## Not done yet
- Import (the desktop's import flow) on the phone.
- Save and Transfers exist (You → Transfers, fed by `soulseek:download`; a
live row cancels on tap; a long press on a catalog row offers the track's
own Save).
- Community's private layer (Spaces, holdings, the sealed inbox, friends by
key) — Freenet on the phone is a later experiment; the network side is in.
key, Who has it) — Freenet on the phone is a later experiment; the
network side is in. Rooms and chat are built and mock-walked; a live room
is Kyle's to join.
- The desktop-only doors and tools: the file and folder playlist imports,
the listener door and the lyric alignment models (a desktop runtime),
playlist export and the cover picker, drag reorder, catalog track
previews, the full discography page, the artist-artwork upscaler,
updates and the account section (sign-in lives on Community).
- Follows and the play history are per library folder, so the phone's own
folder starts empty of both; they arrive with a shared library.
- The artwork viewer: the cover at full size, pinch to zoom, one finger
pans while zoomed, a tap on the unzoomed cover or the back gesture closes.
+62 -1
View File
@@ -124,6 +124,14 @@ function plTouch(p) {
setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30);
}
const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId));
/* every track has three timed lines; the sheet's tools have something to act on */
function mockLyrics(title) {
return {
status: "synced", source: "lrclib",
synced: [{ time_ms: 0, text: "Here is " + (title || "a song") }, { time_ms: 4000, text: "Every word in time" }, { time_ms: 9000, text: "And the last one, late" }],
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,
};
}
function mockPlaylists(cmd, args) {
if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80));
@@ -177,6 +185,19 @@ function mockPlaylists(cmd, args) {
plTouch(p);
return Promise.resolve(rows.map((r) => r.id));
}
if (cmd === "playlist_relink_item") {
const p = plOf(args);
const it = p ? (PL_ITEMS[p.id] || []).find((x) => x.id === Number(args?.itemId)) : null;
if (!it) return Promise.reject(new Error("playlist_relink_item: no such row"));
if (args.path != null) { it.path = args.path; it.kind = "local"; it.missing = false; }
if (args.artPath != null) it.art_path = args.artPath;
if (args.durationSecs != null) it.duration_secs = args.durationSecs;
if (args.title != null) it.title = args.title;
if (args.artist != null) it.artist = args.artist;
if (args.album != null) it.album = args.album;
plTouch(p);
return Promise.resolve();
}
return undefined;
}
@@ -462,6 +483,7 @@ function mockCatalog(cmd, args) {
}
if (cmd === "cancel_download") return Promise.resolve();
if (cmd === "rescan_library") return Promise.resolve();
if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
if (cmd === "preload_stream") {
@@ -495,6 +517,17 @@ let downloadSeq = 0;
const mockSettings = {
download_dir: "C:\\mock\\Library",
volume_normalization: true,
library_layout: "{artist}\\{album}",
shared_folders: [],
upload_slots: 2,
share_files: 1234,
word_timing: false,
replace_embedded_art: true,
embed_art_max_edge: 1500,
write_folder_cover: true,
stream_keep_forever: false,
stream_retention_days: 30,
stream_cache_max_mb: 2048,
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
download_source_prefs: { max: "any", min: "any", priority: "quality" },
};
@@ -533,6 +566,25 @@ function mockNet(cmd, args) {
later(900, () => mockEmit("soulseek:private-message", { username: args?.username, message: "sure, sending", timestamp: Math.floor(Date.now() / 1000) }));
return Promise.resolve();
}
if (cmd === "slsk_browse_user") {
/* a share: two records and a folder of scans, in the browse DTO's shape */
const dir = (album, ext, bitrate) => {
const folder = "@@shared\\Music\\Daft Punk\\Daft Punk - 2001 - " + album + (ext === "flac" ? " [FLAC]" : " [MP3 " + bitrate + "]");
return {
name: folder,
files: TITLES.slice(0, album === "Discovery" ? 14 : 8).map((t, i) => {
const name = String(i + 1).padStart(2, "0") + " - " + t + "." + ext;
const duration = 200 + ((i * 37) % 180);
return { name, path: folder + "\\" + name, size: ext === "flac" ? duration * 110000 : duration * bitrate * 125, ext, bitrate: ext === "flac" ? null : bitrate, duration };
}),
};
};
return new Promise((r) => setTimeout(() => r([
dir("Discovery", "flac"),
dir("Homework", "mp3", 320),
{ name: "@@shared\\Music\\Scans", files: [{ name: "front.jpg", path: "@@shared\\Music\\Scans\\front.jpg", size: 1200000, ext: "jpg", bitrate: null, duration: null }] },
]), 600));
}
if (cmd === "start_search") {
const t = ++token;
const q = String(args?.query || "");
@@ -548,7 +600,16 @@ function mockNet(cmd, args) {
const n = args?.buckets || 900;
return new Promise((r) => setTimeout(() => r(Array.from({ length: n }, (_, i) => 0.2 + 0.75 * Math.abs(Math.sin(i * 0.23) * Math.cos(i * 0.017)))), 120));
}
if (cmd === "lyrics_get") return Promise.resolve(null);
if (cmd === "lyrics_get") return new Promise((r) => setTimeout(() => r(mockLyrics(args?.title)), 150));
if (cmd === "lyrics_search") return new Promise((r) => setTimeout(() => r([
{ id: 901, title: args?.title || "Track", artist: args?.artist || "?", album: "The Mock Sessions", synced: true, instrumental: false, durationSecs: 214 },
{ id: 902, title: (args?.title || "Track") + " (Live)", artist: args?.artist || "?", album: null, synced: false, instrumental: false, durationSecs: 233 },
]), 200));
if (cmd === "lyrics_select") return Promise.resolve({ ...mockLyrics(args?.title), source: "lrclib #" + args?.candidateId });
if (cmd === "lyrics_set_offset") return Promise.resolve();
if (cmd === "lyrics_clear") return Promise.resolve();
if (cmd === "clear_play_history") return Promise.resolve();
if (cmd === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\mock\\cover.jpg"), 200));
if (cmd === "play_local") {
/* a file: the engine reports playing after a beat, then the position
from where it was asked to start */
+226 -5
View File
@@ -5,15 +5,21 @@
are key/value rows, not buttons. */
import Icon from "../components/Icon.svelte";
import Cover from "./Cover.svelte";
import Identicon from "../components/Identicon.svelte";
import { speedLabel, groupEntries } from "./net.svelte.js";
import * as api from "../lib/api.js";
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
import { catalog, yearOf } from "./catalog.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
@@ -29,6 +35,7 @@
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
if (t.n === "catalogAlbum") return "catalogAlbum-more";
return "home-more";
});
@@ -51,6 +58,15 @@
: null,
);
const trackArg = $derived(kind === "track" ? shown?.arg : null);
/* the catalog record page's sheet reads the page the store holds */
const catPage = $derived.by(() => {
if (kind === "catalogAlbum-more") return catalog.albums[top().c.id]?.page ?? null;
if (kind === "catalogAlbum" && shown?.arg?.album) {
const a = shown.arg.album;
return { album: { ...a, artist: a.artist || a.artistName || "" }, tracks: catalog.albums[a.id]?.page?.tracks ?? [] };
}
return null;
});
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
@@ -123,6 +139,80 @@
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* find a local match — the desktop's repair panel, on a sheet: the
library searched by the row's name, a pick relinks the row (an
unknown placeholder adopts the track's identity; the original line
survives in source_line) */
let matchQ = $state("");
let matchRows = $state([]);
let matchBusy = $state(false);
let matchGen = 0;
$effect(() => {
const h = nav.half;
if (h?.kind !== "plmatch" || !h.arg?.item) return;
matchQ = [h.arg.item.artist, h.arg.item.title].filter(Boolean).join(" ");
matchRows = [];
queueMicrotask(() => void runMatch());
});
async function runMatch() {
const g = ++matchGen;
const q = matchQ.trim();
if (!q) { matchRows = []; matchBusy = false; return; }
matchBusy = true;
try {
const rows = await api.libraryTrackSearch(q);
if (g === matchGen) matchRows = rows || [];
} catch {
if (g === matchGen) matchRows = [];
} finally {
if (g === matchGen) matchBusy = false;
}
}
async function applyMatch(p, item, c) {
const unknown = item.kind === "unresolved" && (!item.artist || item.title.trim().toLowerCase() === "id");
closeHalf();
try {
await api.playlistRelinkItem(p.id, item.id, {
path: c.path, artPath: c.art_path, durationSecs: c.duration_secs,
title: unknown ? c.title : null, artist: unknown ? c.artist : null, album: unknown ? c.album : null,
});
await refreshPlaylists();
note(`Linked to “${c.title}”`);
} catch (e) { oops(e); }
}
/* the desktop's Save menu: a streamed track filed into the library —
at once when its cache is byte-complete, else through the download
pipeline — and the whole streamed record the queue holds */
const streamMates = (track, album) => player.queue.filter((e) => e.t?.stream && e.t.path && e.album?.title === album?.title && e.album?.artist === album?.artist);
async function saveStreams(entries) {
const srcs = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path, size: e.t.stream.size }));
if (!srcs.length) return;
try {
const [organized, queued] = await api.saveToLibrary(srcs);
note(organized && !queued ? (organized === 1 ? "Saved to the library" : organized + " tracks saved to the library")
: queued ? queued + " downloading into the library" + (organized ? " · " + organized + " saved" : "")
: "Nothing to save");
} catch (e) { oops(e); }
}
/* a peer's sheet (a raw result's folder, or the browse screen's user) */
function playGroup(g) {
const es = groupEntries(g, 0);
if (es.length) playEntries(es, 0, g.folderName).catch(oops);
}
async function saveGroup(g) {
try {
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size);
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
} catch (e) { oops(e); }
}
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
async function refetchCover(album) {
try {
await api.refreshAlbumArt(album.id);
if (album.art_path) { delete art[album.art_path]; wantArt(album.art_path); }
note("Cover re-fetched for “" + album.title + "”");
} catch (e) { oops(e); }
}
/* the prompt's field, seeded when the prompt opens */
let promptVal = $state("");
$effect(() => {
@@ -188,6 +278,14 @@
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
</div>
<div class="sheet-rows">
{#if trackArg.queueIndex != null}
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
{#if trackArg.queueIndex > player.qi + 1}
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))}
{/if}
{@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))}
<div class="sheet-hair"></div>
{/if}
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
@@ -200,6 +298,13 @@
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{:else}
{#if track.stream}
{@render row("download", "Save to library", "this track, from " + track.stream.username, act(() => saveStreams([{ t: track, album }])))}
{#if streamMates(track, album).length > 1}
{@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))}
{/if}
<div class="sheet-hair"></div>
{/if}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
@@ -235,6 +340,10 @@
{/if}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
{#if album.id != null}
<div class="sheet-hair"></div>
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))}
{/if}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
@@ -293,6 +402,9 @@
{:else if item.missing}
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
{/if}
{#if item.kind !== "local" || item.missing}
{@render row("link", "Find a local match", "a track in your library takes this row's place", () => openHalf("plmatch", { playlist: p, items: rows, item }))}
{/if}
{#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if}
@@ -343,6 +455,115 @@
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
{:else if kind === "plmatch" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const item = shown.arg.item}
<div class="sheet-title">Find a local match <span class="t-sub">· {item.title}</span></div>
<div class="sfield plmatch">
<Icon name="search" size={18} />
<input value={matchQ} oninput={(e) => { matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" />
</div>
<div class="sheet-rows">
{#if matchBusy && !matchRows.length}
<div class="sec-note" style="padding-bottom: 12px">Searching…</div>
{:else if !matchRows.length}
<div class="sec-note" style="padding-bottom: 12px">No library tracks match. A named row still streams from the network when you tap it.</div>
{:else}
{#each matchRows.slice(0, 30) as c (c.path)}
<button class="row" onclick={() => applyMatch(p, item, c)}>
<span class="art"><Cover path={c.art_path} size={16} /></span>
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist} — {c.album}</span></span>
<span class="r"><span class="n">{mmss(c.duration_secs)}</span></span>
</button>
{/each}
{/if}
</div>
{:else if kind === "peer" && shown?.arg?.username}
{@const u = shown.arg.username}
{@const g = shown.arg.group || null}
{@const buddy = slsk.buddies.includes(u)}
<div class="sheet-head">
<span class="art idc"><Identicon seed={u} size={44} /></span>
<span class="b"><span class="t">{u}</span><span class="m">{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"}</span></span>
</div>
<div class="sheet-rows">
{#if g}
{@render row("play", "Play this folder", g.files.length + " file" + (g.files.length === 1 ? "" : "s") + ", streamed as a record", act(() => playGroup(g)))}
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
<div class="sheet-hair"></div>
{/if}
{#if !(top().n === "browse" && top().c.username === u)}
{@render row("folder-open", "Browse their files", "everything they share", act(() => push("browse", { username: u })))}
{/if}
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
{@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
</div>
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
{@const al = catPage.album}
{@const tracks = catPage.tracks || []}
<div class="sheet-head">
<span class="art">{#if al.cover}<img src={al.cover} alt="" />{:else}<Icon name="disc" size={22} />{/if}</span>
<span class="b"><span class="t">{al.title}</span><span class="m">{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play", "streams the record from one folder", act(() => streamCatalogAlbum(al, 0).catch(oops)))}
{@render row("download", "Save to library", "the best folder by your download rules", act(() => saveCatalogAlbum(al).then((r) => note(r ? `Saving ${r.files} from ${r.username}` : "No sources found")).catch(oops)))}
{#if tracks.length}
{@render row("playlist", "Add to playlist", tracks.length + " tracks, as streams", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t: { id: "cat:" + t.id, title: t.title, artist: t.artist || al.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: al.id, cover: al.cover ?? null } }, album: { id: null, catalogId: al.id, title: al.title, artist: al.artist, art_path: al.cover ?? null } })) }))}
{/if}
<div class="sheet-hair"></div>
{#if !(top().n === "catalogAlbum" && top().c.id === al.id)}
{@render row("disc", "Go to record", al.title, act(() => push("catalogAlbum", { id: al.id, artist: al.artist, title: al.title })))}
{/if}
{@render row("users", "Go to artist", al.artist, act(() => push("artist", { name: al.artist })))}
</div>
{:else if kind === "peerfile" && shown?.arg?.group}
{@const g = shown.arg.group}
{@const i = shown.arg.index || 0}
{@const f = g.files[i]}
<div class="sheet-head">
<span class="art idc"><Identicon seed={g.username} size={44} /></span>
<span class="b"><span class="t">{f ? titleFromName(f.name) : "—"}</span><span class="m">{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""}</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play from here", "the rest of the folder follows", act(() => { const es = groupEntries(g, i); if (es.length) playEntries(es, 0, g.folderName).catch(oops); }))}
{#if f}
{@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops)))}
{/if}
<div class="sheet-hair"></div>
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
</div>
{:else if kind === "lyrics"}
<div class="sheet-title">Lyrics <span class="t-sub">· {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}</span></div>
{#if lyr.candidates}
<div class="sheet-sec">Choose the right lyrics</div>
<div class="sheet-rows">
{#if lyr.searching}
<div class="sec-note" style="padding-bottom: 12px">Searching LRCLIB…</div>
{:else if !lyr.candidates.length}
<div class="sec-note" style="padding-bottom: 12px">Nothing found for this track's name.</div>
{:else}
{#each lyr.candidates as c (c.id)}
<button class="row" onclick={act(() => { void chooseCandidate(c.id); })}>
<span class="lead"><Icon name={c.synced ? "lyrics" : "file-text"} size={20} /></span>
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist}{c.album ? " — " + c.album : ""}{c.synced ? " · synced" : " · plain"}{c.instrumental ? " · instrumental" : ""}</span></span>
<span class="r"><span class="n">{c.durationSecs ? mmss(c.durationSecs) : ""}</span></span>
</button>
{/each}
{/if}
<div style="padding: 8px var(--pad) 14px"><button class="btn btn-ghost wide" onclick={closeCandidates}>Keep what is showing</button></div>
</div>
{:else}
<div class="sheet-rows">
{#if lyr.lines.length}
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
<div class="sheet-hair"></div>
{/if}
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
</div>
{/if}
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
@@ -388,7 +609,7 @@
<div class="sheet-rows">
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
{@const buddy = slsk.buddies.includes(u.username)}
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
</button>
+6
View File
@@ -15,6 +15,7 @@
import Cover from "./Cover.svelte";
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Viewer from "./Viewer.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
import Library from "./screens/Library.svelte";
@@ -23,6 +24,7 @@
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
import Browse from "./screens/Browse.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
@@ -68,6 +70,7 @@
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "browse": return { title: e.c.username, kicker: "Their files" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
@@ -224,6 +227,8 @@
<Playlist id={entry.c.id} />
{:else if entry.n === "import"}
<Import />
{:else if entry.n === "browse"}
<Browse username={entry.c.username} />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
@@ -285,6 +290,7 @@
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} role="presentation"></div>
<HalfSheet />
<Sheet />
<Viewer />
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
+12 -29
View File
@@ -16,7 +16,8 @@
import { art } from "./art.svelte.js";
import { albumsBy } from "./lib.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
import { nav, closeSheet, morphSource, openHalf, push } from "./nav.svelte.js";
import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import { mmss, drag } from "./util.js";
let frameEl = $state(null);
@@ -106,33 +107,12 @@
if (player.track) openHalf("track", { album: player.album, track: player.track });
}
/* ── lyrics: the app's own pipeline, per track ───────────────────── */
let lyr = $state({ key: null, status: "none", lines: [], plain: "", offset: 0 });
/* ── lyrics: the store (lyrics.svelte.js), asked for the playing track
whenever the face is up ───────────────────────────────────────── */
$effect(() => {
if (face !== "lyrics" || !player.track) return;
const t = player.track;
const key = t.path;
if (lyr.key === key) return;
lyr = { key, status: "loading", lines: [], plain: "", offset: 0 };
api.lyricsGet({
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
}).then(
(p) => {
if (lyr.key !== key) return;
lyr = {
key,
status: p?.status || "none",
lines: p?.synced || [],
plain: p?.plain || "",
offset: (p?.offset_ms || 0) + (p?.auto_offset_ms || 0),
};
},
() => { if (lyr.key === key) lyr = { key, status: "none", lines: [], plain: "", offset: 0 }; },
);
void player.track.path;
void loadLyrics();
});
const nowMs = $derived(player.position * 1000);
const activeIdx = $derived.by(() => {
@@ -171,7 +151,7 @@
<button class="iconbtn" onclick={more} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="np-art" class:small>
<div class="np-art" class:small onclick={() => openViewer(player.album?.art_path)} role="presentation">
<div class="np-frame" bind:this={frameEl}><Cover path={player.album.art_path} eager size={64} /></div>
</div>
@@ -188,7 +168,7 @@
{:else}
<div class="np-face" bind:this={faceEl}>
{#if face === "lyrics"}
<div class="lyr">
<div class="lyr" oncontextmenu={(e) => { e.preventDefault(); openHalf("lyrics"); }} role="presentation">
{#if lyr.status === "loading"}
<div class="lyr-note">Looking for the words…</div>
{:else if lyr.lines.length}
@@ -205,11 +185,14 @@
</button>
{/if}
{/each}
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Out of time, or wrong? ⋯</button>
{:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Wrong lyrics? ⋯</button>
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
<button class="lyr-tools" onclick={() => openHalf("lyrics")}>Not so? ⋯</button>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
{/if}
@@ -225,7 +208,7 @@
{#if e.album?.title !== prevAlbum}
<div class="q-break">{e.album?.artist} · {e.album?.title}</div>
{/if}
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t }); }}>
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t, queueIndex: player.qi + 1 + k }); }}>
<span class="art"><Cover path={e.album?.art_path} size={18} /></span>
<span class="b"><span class="t">{e.t.title}</span><span class="m">{e.t.artist || e.album?.artist}</span></span>
<span class="r">{mmss(e.t.duration_secs)}</span>
+75
View File
@@ -0,0 +1,75 @@
<script>
/* The artwork viewer — the desktop's lightbox: the cover alone, as large
as the screen allows, at the full resolution of the canonical cover
for a library record (the thumbnail shows first, the full one lands
over it). Two fingers zoom and pan; one finger pans a zoomed cover;
a tap on the unzoomed cover, or the back gesture, closes it. */
import { nav, closeViewer } from "./nav.svelte.js";
import { art } from "./art.svelte.js";
import * as api from "../lib/api.js";
const key = $derived(nav.viewerKey);
let full = $state("");
$effect(() => {
const k = key;
full = "";
scale = 1; tx = 0; ty = 0;
if (!k) return;
if (/^https?:/i.test(k)) { full = k; return; }
if (/^stream:/.test(k)) return; /* a stream's tag art: the thumbnail is all there is */
let live = true;
api.albumArt(k, 2000).then((u) => { if (live && u) full = u; }, () => { /* the thumbnail stays */ });
return () => { live = false; };
});
const src = $derived(full || (key ? art[key] : "") || "");
/* pinch to zoom, pan while zoomed — pointer events, both fingers tracked */
let scale = $state(1);
let tx = $state(0);
let ty = $state(0);
const pts = new Map();
let pinch = null; /* { d, s, cx, cy, tx, ty } at the pinch's start */
let pan = null; /* { x, y, tx, ty } at a one-finger pan's start */
let moved = false;
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
const center = () => { const [a, b] = [...pts.values()]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
function down(e) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
e.currentTarget.setPointerCapture(e.pointerId);
moved = false;
if (pts.size === 2) { const c = center(); pinch = { d: dist(), s: scale, cx: c.x, cy: c.y, tx, ty }; pan = null; }
else if (pts.size === 1 && scale > 1) pan = { x: e.clientX, y: e.clientY, tx, ty };
}
function move(e) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && pinch) {
moved = true;
scale = Math.min(6, Math.max(1, pinch.s * (dist() / pinch.d)));
const c = center();
tx = pinch.tx + (c.x - pinch.cx);
ty = pinch.ty + (c.y - pinch.cy);
} else if (pts.size === 1 && pan) {
const dx = e.clientX - pan.x;
const dy = e.clientY - pan.y;
if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
tx = pan.tx + dx;
ty = pan.ty + dy;
}
}
function up(e) {
pts.delete(e.pointerId);
if (pts.size < 2) pinch = null;
if (pts.size === 0) pan = null;
if (scale <= 1.04) { scale = 1; tx = 0; ty = 0; }
}
function tap() {
if (!moved && scale === 1) closeViewer();
}
</script>
{#if nav.viewer}
<div class="viewer" onclick={tap} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} role="presentation">
{#if src}<img src={src} alt="" draggable="false" style:transform={scale === 1 ? "" : `translate(${tx}px, ${ty}px) scale(${scale})`} />{/if}
</div>
{/if}
+107
View File
@@ -0,0 +1,107 @@
/* Lyrics on the phone — the app's own pipeline, per track, with the
desktop's tools: the offset nudged half a second at a time and kept for
the track, another LRCLIB candidate chosen when the words are wrong, the
lookup forgotten and done again. One store, read by the player sheet's
lyrics face and by the lyrics sheet. */
import * as api from "../lib/api.js";
import { player } from "./player.svelte.js";
export const lyr = $state({
key: null, // the track's path — the identity the words belong to
status: "none", // loading | synced | plain | instrumental | none
source: "",
lines: [], // [{ time_ms, text, words? }]
plain: "",
offset: 0, // the user's offset plus the automatic correction, ms
userOffset: 0,
autoOffset: 0,
candidates: null, // LRCLIB candidates while choosing, else null
searching: false,
});
let gen = 0;
export function identity() {
const t = player.track;
if (!t) return null;
return {
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
key: t.path,
};
}
function apply(p) {
lyr.status = p?.status || "none";
lyr.source = p?.source || "";
lyr.lines = p?.synced || [];
lyr.plain = p?.plain || "";
lyr.userOffset = p?.offset_ms || 0;
lyr.autoOffset = p?.auto_offset_ms || 0;
lyr.offset = lyr.userOffset + lyr.autoOffset;
}
/** The words for the playing track; `force` asks the network again. */
export async function loadLyrics(force = false) {
const id = identity();
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, candidates: null, searching: false });
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);
} catch {
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
}
}
/* the desktop's nudge: half a second at a time, kept for the track */
let persist = 0;
export function nudge(deltaMs) {
const id = identity();
if (!id || !lyr.lines.length) return;
lyr.userOffset = Math.max(-30_000, Math.min(30_000, lyr.userOffset + deltaMs));
lyr.offset = lyr.userOffset + lyr.autoOffset;
clearTimeout(persist);
const value = lyr.userOffset;
persist = setTimeout(() => { api.lyricsSetOffset(id.title, id.artist, id.durationSecs, value).catch(() => {}); }, 500);
}
/** Search LRCLIB by this track's name; the candidates wait on the store. */
export async function searchCandidates() {
const id = identity();
if (!id) return;
const g = gen;
lyr.searching = true;
lyr.candidates = [];
try {
const found = await api.lyricsSearch(id.title, id.artist);
if (g === gen) lyr.candidates = found || [];
} catch {
if (g === gen) lyr.candidates = [];
} finally {
if (g === gen) lyr.searching = false;
}
}
export function closeCandidates() { lyr.candidates = null; }
/** Pin a picked candidate as this track's lyrics. */
export async function chooseCandidate(candidateId) {
const id = identity();
if (!id) return;
const g = gen;
try {
const p = await api.lyricsSelect(id.title, id.artist, id.durationSecs, candidateId);
if (g !== gen) return;
apply(p);
lyr.candidates = null;
} catch { /* keep what was showing */ }
}
/** Forget the words and look again. */
export async function forgetLyrics() {
const id = identity();
if (!id) return;
try { await api.lyricsClear(id.title, id.artist, id.durationSecs); } catch { /* nothing kept */ }
await loadLyrics(true);
}
+19 -3
View File
@@ -34,6 +34,9 @@ export const nav = $state({
face: "art",
/* the half sheet: { kind, arg } */
half: null,
/* the artwork viewer, and the art key it shows */
viewer: false,
viewerKey: null,
});
/** Where the container transform starts — the mini bar's art, measured the
@@ -54,7 +57,7 @@ export function isRoot() {
}
function backables() {
return (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
return (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
}
@@ -93,7 +96,8 @@ export function bootNav() {
window.addEventListener("popstate", () => {
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
depth = Math.max(0, depth - 1);
if (nav.sheet) nav.sheet = false;
if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
@@ -145,7 +149,8 @@ export function push(n, c = {}, screenEl = null) {
/** The in-app back chevron. Same order as the system gesture. */
export function back() {
if (nav.sheet) nav.sheet = false;
if (nav.viewer) nav.viewer = false;
else if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
@@ -184,6 +189,17 @@ export function closeSheet() {
nav.sheet = false;
sync();
}
export function openViewer(key) {
if (!key) return;
nav.viewerKey = key;
nav.viewer = true;
sync();
}
export function closeViewer() {
if (!nav.viewer) return;
nav.viewer = false;
sync();
}
export function openHalf(kind, arg = null) {
nav.half = { kind, arg };
sync();
+12
View File
@@ -6,6 +6,7 @@
sees a sorted snapshot every quarter second. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { titleFromName, folderMeta } from "./util.js";
export const AUDIO = new Set(["flac", "mp3", "mp2", "m4a", "m4b", "aac", "ogg", "oga", "wav", "aiff", "aif", "opus", "wma", "ape", "wv", "mpc", "tta", "dsf", "dff", "alac"]);
export const LOSSLESS = new Set(["flac", "wav", "aiff", "aif", "ape", "wv", "alac", "dsf", "dff", "tta"]);
@@ -146,3 +147,14 @@ export function groupFormat(g) {
if (LOSSLESS.has(g.topExt)) return ext + (g.maxDepth ? " " + g.maxDepth + "-bit" : "");
return ext + (g.maxBitrate ? " " + g.maxBitrate : "");
}
/** A group's files as the player's entries: the stream engine plays each
* as it arrives; the folder's name is read for artist / record / year. */
export function groupEntries(g, from = 0) {
const meta = folderMeta(g.folder);
const album = { id: null, title: meta.album || g.folderName, artist: meta.artist || g.username, year: meta.year || null, art_path: "stream:" + g.username + "|" + g.folder };
return g.files.slice(from).map((f) => ({
t: { id: "stream:" + g.username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || g.username, path: f.path, duration_secs: f.duration ?? null, stream: { username: g.username, size: f.size } },
album,
}));
}
+19
View File
@@ -401,6 +401,25 @@ export function playQueueAt(i) {
player.qi = i;
return startEntry(player.queue[i]);
}
/** Drop an upcoming row from the queue (the shuffle's original order too). */
export function removeFromQueue(i) {
if (i <= player.qi || i >= player.queue.length) return;
const [gone] = player.queue.splice(i, 1);
if (original && gone) {
const k = original.indexOf(gone);
if (k >= 0) original.splice(k, 1);
}
armPreload();
writeSession();
}
/** Pull an upcoming row up to right after the current track. */
export function moveToNext(i) {
if (i <= player.qi + 1 || i >= player.queue.length) return;
const [row] = player.queue.splice(i, 1);
player.queue.splice(player.qi + 1, 0, row);
armPreload();
writeSession();
}
/** After the current track. */
export function playNext(entries) {
if (player.qi < 0) return playEntries(entries, 0, player.from);
+2 -2
View File
@@ -7,7 +7,7 @@
import Release from "../Release.svelte";
import { albumById, albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { push, openHalf, openViewer } from "../nav.svelte.js";
import { mmss, longDur, albumFmt } from "../util.js";
let { id, screenEl = null } = $props();
@@ -40,7 +40,7 @@
{#if album}
<div class="rec-head">
<div class="rec-cover"><Cover path={album.art_path} eager size={48} /></div>
<div class="rec-cover" onclick={() => openViewer(album.art_path)} role="presentation"><Cover path={album.art_path} eager size={48} /></div>
<div class="rec-meta">
<div class="rec-title">{album.title}</div>
<button class="rec-artist" onclick={() => push("artist", { name: album.artist }, screenEl)}>{album.artist}</button>
+1
View File
@@ -270,6 +270,7 @@
path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
+155
View File
@@ -0,0 +1,155 @@
<script>
/* A user's shares — the desktop hub's Browse tab: every folder they
share, its files with sizes and formats, the totals. A folder plays
as a record through the stream engine, a file plays from its place,
Save fetches a folder into the library as it is. One request for
the whole list; a big share takes its time. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { player, playEntries } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { username } = $props();
let dirs = $state(null);
let busy = $state(false);
let error = $state("");
let open = $state(new Set());
let gen = 0;
async function load() {
const g = ++gen;
busy = true;
error = "";
dirs = null;
try {
const d = await api.slskBrowseUser(username);
if (g === gen) dirs = (d || []).filter((x) => x.files?.length);
} catch (e) {
if (g === gen) error = String(e);
} finally {
if (g === gen) busy = false;
}
}
$effect(() => { void username; void load(); });
const totals = $derived.by(() => {
let files = 0;
let bytes = 0;
for (const d of dirs || []) {
files += d.files.length;
for (const f of d.files) bytes += f.size || 0;
}
return { folders: dirs?.length || 0, files, bytes };
});
const dirName = (d) => d.name.split(/[\\/]/).filter(Boolean).pop() || d.name;
/* a share can hold thousands of folders: a filter, and a cap that
extends on demand — never the whole list at once */
const CAP = 80;
let q = $state("");
let cap = $state(CAP);
const shown = $derived.by(() => {
const k = q.trim().toLowerCase();
const list = dirs || [];
return k ? list.filter((d) => d.name.toLowerCase().includes(k)) : list;
});
$effect(() => { void q; cap = CAP; });
const audio = (f) => /^(flac|mp3|m4a|aac|ogg|opus|wav|aiff?|alac|wma|ape|wv)$/i.test(f.ext || "");
const dirBytes = (d) => d.files.reduce((n, f) => n + (f.size || 0), 0);
/* the folder as the player's entries: the stream engine plays each file
as it arrives, the folder's name read for artist / record / year */
function entries(d, from = 0) {
const meta = folderMeta(d.name);
const album = { id: null, title: meta.album || dirName(d), artist: meta.artist || username, year: meta.year || null, art_path: "stream:" + username + "|" + d.name };
return d.files.filter(audio).slice(from).map((f) => ({
t: { id: "stream:" + username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || username, path: f.path, duration_secs: f.duration ?? null, stream: { username, size: f.size } },
album,
}));
}
function play(d, f = null) {
const list = d.files.filter(audio);
const es = entries(d, f ? Math.max(0, list.indexOf(f)) : 0);
if (es.length) playEntries(es, 0, dirName(d)).catch((e) => { player.error = String(e); });
}
async function save(d) {
const list = d.files.filter(audio);
if (!list.length) return;
try {
for (const f of list) await api.downloadFile(username, f.path, f.size);
note(`Saving ${list.length} file${list.length === 1 ? "" : "s"} from ${username}`);
} catch (e) { player.error = String(e); }
}
/* a file's sheet: the folder as the sheet's group shape */
function fileSheet(d, f) {
const files = d.files.filter(audio);
openHalf("peerfile", { group: { username, folder: d.name, folderName: dirName(d), files, slot: true, speed: 0, queue: 0 }, index: Math.max(0, files.indexOf(f)) });
}
function toggle(name) {
const s = new Set(open);
if (s.has(name)) s.delete(name); else s.add(name);
open = s;
}
const fmtLine = (f) => (f.bitrate ? f.bitrate + " kbps" : (f.ext || "").toUpperCase()) + (f.duration ? " · " + mmss(f.duration) : "") + " · " + fmtBytes(f.size);
</script>
<div class="brw-head">
<Identicon seed={username} size={44} />
<div class="b">
<div class="t">{username}</div>
<div class="m">{busy ? "Reading their shares…" : error ? "Couldn't read their shares" : dirs ? `${totals.folders} folder${totals.folders === 1 ? "" : "s"} · ${totals.files} file${totals.files === 1 ? "" : "s"} · ${fmtBytes(totals.bytes)}` : ""}</div>
</div>
<button class="iconbtn muted" onclick={() => openHalf("peer", { username })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
{#if busy}
<div class="imp-wait"><span class="ring"></span><b>Reading {username}'s shares</b><span>One request for the whole list; a big share takes a moment.</span></div>
{:else if error}
<div class="stub"><b>Couldn't read their shares.</b>{error}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={load}>Try again</button></div>
{:else if dirs && !dirs.length}
<div class="stub"><b>Nothing shared.</b>{username} shares no files, or keeps the share private.</div>
{:else if dirs}
{#if dirs.length > 8}
<div class="sfield">
<Icon name="search" size={18} />
<input value={q} oninput={(e) => (q = e.currentTarget.value)} type="search" placeholder="Filter {totals.folders} folders" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Filter folders" />
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
</div>
{#if q}<div class="sec-note">{shown.length} of {dirs.length} folders</div>{/if}
{/if}
{#each shown.slice(0, cap) as d (d.name)}
{@const files = d.files.filter(audio)}
<div class="peer brw-dir">
<button class="peer-folder" onclick={() => toggle(d.name)}>
<span class="peer-path">{dirName(d)}</span>
<span class="peer-meta">{d.files.length} file{d.files.length === 1 ? "" : "s"} · {fmtBytes(dirBytes(d))}{files.length && files.length !== d.files.length ? ` · ${files.length} audio` : ""}</span>
<span class="peer-get"><Icon name={open.has(d.name) ? "chevron-up" : "chevron-down"} size={14} />{open.has(d.name) ? "Less" : "Files"}</span>
</button>
{#if open.has(d.name)}
{#if files.length}
<div class="brw-acts">
<button class="btn btn-fill" onclick={() => play(d)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => save(d)}><Icon name="download" size={14} />Save to library</button>
</div>
{/if}
<div class="peer-files">
{#each d.files as f, i (f.path)}
<button class="peer-file" class:playing={player.track?.path === f.path} disabled={!audio(f)} onclick={() => play(d, f)} oncontextmenu={(e) => { e.preventDefault(); fileSheet(d, f); }}>
<span>{i + 1}</span>
<span class="pf-t">{audio(f) ? titleFromName(f.name) : f.name}</span>
<span class="pf-m">{fmtLine(f)}</span>
</button>
{/each}
</div>
{/if}
</div>
{/each}
{#if shown.length > cap}
<div style="padding: 0 var(--pad) 8px"><button class="btn btn-ghost wide brw-more" onclick={() => (cap += 200)}>+ {shown.length - cap} more folders</button></div>
{/if}
{/if}
<div class="endpad"></div>
+11 -2
View File
@@ -27,6 +27,10 @@
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
);
const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
/* the hub's browse-by-name: a typed name opens the peer sheet */
function lookUp() {
openHalf("prompt", { title: "Look up a user", placeholder: "Soulseek username", ok: "Open", onsubmit: (v) => { const u = v.trim(); if (u) openHalf("peer", { username: u }); } });
}
async function signIn() {
if (!user.trim() || !pass) return;
@@ -140,11 +144,16 @@
</button>
{/each}
{:else if tab === "buddies"}
<button class="row" onclick={lookUp}>
<span class="lead"><Icon name="search" size={20} /></span>
<span class="b"><span class="t">Look up a user</span><span class="m">a Soulseek name: browse their files, message them, add them</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if !buddies.length}
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one.</div>
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one, or look one up above.</div>
{/if}
{#each buddies as b (b.u)}
<button class="row" onclick={() => thread(b.u)}>
<button class="row" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
<span class="b">
<span class="t">{b.u}</span>
+2 -2
View File
@@ -219,7 +219,7 @@
<div class="sec"><h2>New from your artists <em>· {lib.follows.length} followed</em></h2></div>
<div class="rail">
{#each feedRest as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={kindLabel(r.record_type) + " · " + r.artistName} url={r.cover} badge={r.upcoming ? inDays(r.release_date) : age(r.release_date)} onclick={() => openRelease(r)} />
<Release title={r.title} sub={kindLabel(r.record_type) + " · " + r.artistName} url={r.cover} badge={r.upcoming ? inDays(r.release_date) : age(r.release_date)} onclick={() => openRelease(r)} onmore={() => { if (!r.mbOnly && r.id != null) openHalf("catalogAlbum", { album: r }); }} />
{/each}
</div>
{/if}
@@ -251,7 +251,7 @@
<div class="sec"><h2>From the back catalogue <em>· albums you may have missed</em></h2></div>
<div class="rail">
{#each backCatalogue as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={r.artistName} url={r.cover} badge={age(r.release_date)} onclick={() => openRelease(r)} />
<Release title={r.title} sub={r.artistName} url={r.cover} badge={age(r.release_date)} onclick={() => openRelease(r)} onmore={() => { if (!r.mbOnly && r.id != null) openHalf("catalogAlbum", { album: r }); }} />
{/each}
</div>
{/if}
+1 -1
View File
@@ -165,7 +165,7 @@
{#if gapOpen[key] && gapRows[key]?.length}
<div class="rail gaps">
{#each gapRows[key] as r (r.id)}
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} />
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} onmore={() => openHalf("catalogAlbum", { album: { ...r, artist: r.artist || ar.name } })} />
{/each}
</div>
{/if}
+85 -16
View File
@@ -17,7 +17,7 @@
import { push, 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 } from "../net.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";
@@ -29,6 +29,22 @@
let inputEl = $state(null);
/** the screen remembers its query across the tab switch (nav's entry) */
function setQ(v) { q = v; patch({ q: v }); }
/* recent searches — the desktop's empty page: the last eight queries
that were USED (the field committed with Enter, or the screen left
with a query in it), kept on the device */
const RECENT_KEY = "search.recent.v1";
let recents = $state((() => { try { return JSON.parse(localStorage.getItem(RECENT_KEY)) ?? []; } catch { return []; } })());
function keepRecents() { try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* storage off */ } }
function remember(v) {
const t = String(v || "").trim();
if (t.length < 2) return;
recents = [t, ...recents.filter((x) => x.toLowerCase() !== t.toLowerCase())].slice(0, 8);
keepRecents();
}
function forget(v) { recents = recents.filter((x) => x !== v); keepRecents(); }
let lastQ = "";
$effect(() => { lastQ = q.trim(); });
$effect(() => () => remember(lastQ));
const query = $derived(q.trim());
const asking = $derived(query.length >= 2);
@@ -60,6 +76,52 @@
const catAlbums = $derived(allAlbums.slice(0, 12));
const catTracks = $derived(allTracks.slice(0, 10));
const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
/* a spelling correction, when one is obvious — the desktop's rule: the
only trustworthy vocabulary offline is the names this phone knows
(the library, the searches that worked), a word is corrected only
when a real name is within two edits, nothing is guessed otherwise */
const vocabulary = $derived.by(() => {
const words = new Set();
const add = (text) => { for (const w of String(text ?? "").toLowerCase().split(/[^a-z0-9']+/)) if (w.length >= 3) words.add(w); };
for (const a of lib.albums) { add(a.artist); add(a.title); }
for (const r of recents) add(r);
return [...words];
});
const didYouMean = $derived.by(() => {
if (!catEmpty || catBusy) return null;
const typed = query.split(/\s+/);
if (!typed.length || !vocabulary.length) return null;
let corrected = false;
const fixed = typed.map((word) => {
const lower = word.toLowerCase();
if (lower.length < 3 || vocabulary.includes(lower)) return word;
let best = null;
let bestDist = 3;
for (const c of vocabulary) { const d = editDistance(lower, c, bestDist); if (d < bestDist) { bestDist = d; best = c; } }
if (!best) return word;
corrected = true;
return best;
});
return corrected ? fixed.join(" ") : null;
});
/** Levenshtein, abandoned as soon as every cell exceeds the limit. */
function editDistance(a, b, limit) {
if (Math.abs(a.length - b.length) >= limit) return limit;
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diag = prev[0];
prev[0] = i;
let row = i;
for (let j = 1; j <= b.length; j++) {
const tmp = prev[j];
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
diag = tmp;
row = Math.min(row, prev[j]);
}
if (row >= limit) return limit;
}
return prev[b.length];
}
/* ── the top result: the desktop's rule, verbatim ──────────────────
Each candidate scores as a SHARE OF ITS OWN KIND'S LEADER, never as a
@@ -146,6 +208,14 @@
}).catch((e) => { player.error = String(e); });
}
const current = (t) => player.track?.id === "cat:" + t.id;
/* a long press: the catalog track's sheet — play, save, add to a
playlist, the record, the artist */
function songSheet(t) {
openHalf("track", {
album: { id: null, catalogId: t.album_id ?? null, title: t.album || null, artist: t.artist, art_path: t.cover ?? null },
track: { id: "cat:" + t.id, title: t.title, artist: t.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: t.album_id ?? null, cover: t.cover ?? null } },
});
}
/* ── the library ───────────────────────────────────────────────── */
const lnorm = (s) => String(s || "").toLowerCase();
@@ -193,23 +263,15 @@
netSearch(query).catch(() => {});
inputEl?.blur();
}
function streamEntries(g, from) {
const meta = folderMeta(g.folder);
const artKey = "stream:" + g.username + "|" + g.folder;
const album = { id: null, title: meta.album || g.folderName, artist: meta.artist || g.username, year: meta.year || null, art_path: artKey };
return g.files.slice(from).map((f) => ({
t: { id: "stream:" + g.username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || g.username, path: f.path, duration_secs: f.duration ?? null, stream: { username: g.username, size: f.size } },
album,
}));
}
function playPeer(g, i) {
const es = streamEntries(g, i);
const es = groupEntries(g, i);
if (es.length) playEntries(es, 0, g.folderName).catch((e) => { player.error = String(e); });
}
function expand(key) { expanded = new Set([...expanded, key]); }
function enter(e) {
if (e.key !== "Enter") return;
e.preventDefault();
remember(q);
inputEl?.blur();
}
</script>
@@ -230,10 +292,16 @@
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if}
</div>
{#if recents.length}
<div class="sec"><h2>Recent</h2><button class="all" onclick={() => { recents = []; keepRecents(); }}>Clear</button></div>
<div class="chips recent-chips" style="padding-bottom: 12px">
{#each recents as r (r)}<button class="chip quiet" onclick={() => setQ(r)} oncontextmenu={(e) => { e.preventDefault(); forget(r); }}>{r}</button>{/each}
</div>
{/if}
{:else}
{#if catErr}<div class="sec-note">{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}”.</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}
{#if top}
<div class="top" class:round={top.kind === "artist"}>
@@ -292,7 +360,7 @@
<div class="sec"><h2>Records</h2></div>
<div class="rail">
{#each catAlbums as a (a.id)}
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} />
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} onmore={() => openHalf("catalogAlbum", { album: a })} />
{/each}
</div>
{/if}
@@ -300,7 +368,7 @@
{#if catTracks.length}
<div class="sec"><h2>Songs</h2></div>
{#each catTracks as t (t.id)}
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)}>
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)} oncontextmenu={(e) => { e.preventDefault(); songSheet(t); }}>
<span class="art">{#if t.cover}<img src={t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b"><span class="t">{t.title}</span><span class="m">{t.artist}{t.album ? " · " + t.album : ""}</span></span>
<span class="r">{current(t) && resolver.active ? "finding…" : mmss(t.duration_secs)}</span>
@@ -350,12 +418,13 @@
{/if}
{#each net.groups.slice(0, 40) as g (g.key)}
<div class="peer">
<div class="peer-head">
<div class="peer-head" oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: g.username, group: g }); }} role="presentation">
<span class="peer-who">
<Identicon seed={g.username} size={30} />
<span><b>{g.username}</b><i>{g.slot ? "slot free" : "queue " + g.queue} · {speedLabel(g.speed)}</i></span>
</span>
<span class="dot" class:warn={!g.slot}></span>
<button class="iconbtn sm muted peer-menu" onclick={() => openHalf("peer", { username: g.username, group: g })} aria-label="More"><Icon name="more" size={18} /></button>
</div>
<button class="peer-folder" onclick={() => playPeer(g, 0)}>
<span class="peer-path">{g.folderName}</span>
@@ -364,7 +433,7 @@
</button>
<div class="peer-files">
{#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)}
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)}>
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peerfile", { group: g, index: i }); }}>
<span>{i + 1}</span>
<span class="pf-t">{titleFromName(f.name)}</span>
<span class="pf-m">{f.bitrate ? f.bitrate + " kbps" : (f.extension || "").toUpperCase()}{f.duration ? " · " + mmss(f.duration) : ""}</span>
+153 -6
View File
@@ -3,8 +3,9 @@
running. Playback, storage and the network settings come next. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { lib, loadLibrary } from "../lib.svelte.js";
import { push } from "../nav.svelte.js";
import { lib, loadLibrary, refreshRecents } from "../lib.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js";
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
@@ -47,6 +48,16 @@
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally { picking = false; }
}
function clearHistory() {
openHalf("confirm", {
title: "Clear the play history?",
body: "Jump back in and the play counts start over. The library and the playlists stay.",
ok: "Clear", danger: true,
onconfirm: async () => {
try { await api.clearPlayHistory(); await refreshRecents(); note("Play history cleared"); } catch (e) { player.error = String(e); }
},
});
}
async function rescan() {
if (scanning) return;
scanning = true;
@@ -61,6 +72,22 @@
const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]];
const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
/* the desktop's Streaming settings: what happens to a streamed track */
const RETENTION = [["1", "1 day"], ["3", "3 days"], ["7", "1 week"], ["14", "2 weeks"], ["30", "1 month"], ["90", "3 months"], ["forever", "Forever — save to library"]];
const CACHE = [["512", "512 MB"], ["1024", "1 GB"], ["2048", "2 GB"], ["5120", "5 GB"], ["10240", "10 GB"], ["20480", "20 GB"], ["0", "Unlimited"]];
const retention = $derived(!cfg ? "30" : cfg.stream_keep_forever ? "forever" : String(cfg.stream_retention_days ?? 30));
const cacheMax = $derived(!cfg ? "2048" : String(cfg.stream_cache_max_mb ?? 2048));
async function setRetention(v) {
if (v === retention) return;
try {
await api.setSettings(v === "forever" ? { streamKeepForever: true } : { streamKeepForever: false, streamRetentionDays: Number(v) });
cfg = await api.getSettings();
} catch (e) { player.error = String(e); }
}
async function setCacheMax(v) {
if (v === cacheMax) return;
try { await api.setSettings({ streamCacheMaxMb: Number(v) }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) });
async function setPref(mode, field, value) {
if (prefsOf(mode)[field] === value) return;
@@ -72,6 +99,43 @@
} catch (e) { player.error = String(e); }
}
/* the desktop's Library & downloads / Sharing / Lyrics / Album artwork
settings, on the phone's components; every save camelCase */
const ART_MODE = [["replace", "Canonical cover"], ["keep", "Uploader's art"]];
const ART_EDGE = [["0", "Original"], ["1500", "1500 px"], ["1000", "1000 px"]];
const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace");
const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500));
let refetching = $state(false);
async function saveCfg(patch) {
try { await api.setSettings(patch); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
const setFlag = (key, v) => saveCfg({ [key]: !!v });
const setNumber = (key, v) => saveCfg({ [key]: v });
const setSlots = (n) => saveCfg({ uploadSlots: Math.max(1, Math.min(10, n)) });
function layoutPrompt() {
openHalf("prompt", {
title: "Album folder format", value: cfg?.library_layout || "", placeholder: "{artist}\\{album} ({year})", ok: "Save",
onsubmit: (v) => { if (v.trim()) void saveCfg({ libraryLayout: v.trim() }); },
});
}
async function addShared() {
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can share — pick one on this phone's storage."; return; }
const have = cfg?.shared_folders ?? [];
if (!have.includes(r.path)) await saveCfg({ sharedFolders: [...have, r.path] });
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
}
}
const removeShared = (folder) => saveCfg({ sharedFolders: (cfg?.shared_folders ?? []).filter((f) => f !== folder) });
async function refetchCovers() {
if (refetching) return;
refetching = true;
try { const n = await api.refreshAllAlbumArt(); note(`${n ?? ""} cover${n === 1 ? "" : "s"} re-fetched`.trim()); }
catch (e) { player.error = String(e); }
finally { refetching = false; }
}
const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
@@ -122,6 +186,10 @@
<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>
<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>
</button>
<button class="row" onclick={() => push("transfers", {})}>
<span class="lead"><Icon name="download" size={20} /></span>
<span class="b"><span class="t">Transfers</span><span class="m">{active().length ? `${active().length} in flight` : Object.keys(transfers.items).length ? "nothing in flight" : "what Save is fetching into the library"}</span></span>
@@ -129,6 +197,23 @@
</button>
</div>
<div class="sheet-sec">Streaming</div>
<div class="sec-note">What happens to a track after you streamed it. Streamed songs stay in a private cache for instant replay — not in your library; Forever adds everything you stream to the library instead. Retention counts from the last play; saved and downloaded music is never touched.</div>
<div class="group prefs" data-mode="streamcache">
<div class="pref">
<div class="pref-l"><span class="t">Keep streamed tracks</span><span class="m">how long the cache holds a track after its last play</span></div>
<div class="chips">
{#each RETENTION as [v, l] (v)}<button class="chip" class:on={retention === v} class:quiet={retention !== v} onclick={() => setRetention(v)}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Cache size limit</span><span class="m">the oldest streamed tracks go first past this size</span></div>
<div class="chips">
{#each CACHE as [v, l] (v)}<button class="chip" class:on={cacheMax === v} class:quiet={cacheMax !== v} onclick={() => setCacheMax(v)}>{l}</button>{/each}
</div>
</div>
</div>
<div class="sheet-sec">Sources</div>
<div class="sec-note">Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.</div>
{#each MODES as [key, label] (key)}
@@ -155,15 +240,77 @@
</div>
{/each}
<div class="sheet-sec">Downloads</div>
<div class="group">
<button class="row" onclick={layoutPrompt}>
<span class="lead"><Icon name="folder-open" size={20} /></span>
<span class="b"><span class="t">Album folder format</span><span class="m mono">{cfg?.library_layout || "{artist}\\{album}"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
<div class="sec-note" style="padding: 0 14px 12px">Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder. New downloads are organized into this format; existing files are not moved.</div>
</div>
<div class="sheet-sec">Sharing</div>
<div class="group">
<div class="kv"><span class="k">Your library folder</span><span class="v dim">always shared · {(cfg?.share_files ?? 0).toLocaleString()} files</span></div>
<div class="row slots">
<span class="lead"><Icon name="users" size={20} /></span>
<span class="b"><span class="t">Upload slots</span><span class="m">how many peers can download from you at once</span></span>
<span class="stepper"><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) - 1)} aria-label="Fewer slots"><Icon name="minus" size={16} /></button><span class="n">{cfg?.upload_slots ?? 2}</span><button class="iconbtn sm" onclick={() => setSlots((cfg?.upload_slots ?? 2) + 1)} aria-label="More slots"><Icon name="plus" size={16} /></button></span>
</div>
{#each cfg?.shared_folders ?? [] as folder (folder)}
<div class="row">
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t mono">{folder}</span><span class="m">also shared</span></span>
<button class="iconbtn sm muted" onclick={() => removeShared(folder)} aria-label="Stop sharing"><Icon name="x" size={18} /></button>
</div>
{/each}
<button class="row" onclick={addShared}>
<span class="lead"><Icon name="plus" size={20} /></span>
<span class="b"><span class="t">Add a shared folder</span><span class="m">on this phone's storage; the library folder needs no adding</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
</div>
<div class="sheet-sec">Lyrics</div>
<div class="group">
<button class="row" onclick={() => setFlag("wordTiming", !cfg?.word_timing)}>
<span class="lead"><Icon name="lyrics" size={20} /></span>
<span class="b"><span class="t">Word-by-word fill</span><span class="m">each word fills as it is sung; off keeps the line-by-line sync everywhere</span></span>
<span class="sw" class:on={!!cfg?.word_timing}><span></span></span>
</button>
</div>
<div class="sheet-sec">Album artwork</div>
<div class="group prefs" data-mode="albumart">
<div class="pref">
<div class="pref-l"><span class="t">Art inside downloaded files</span><span class="m">the canonical cover, or whatever the uploader embedded</span></div>
<div class="chips">
{#each ART_MODE as [v, l] (v)}<button class="chip" class:on={artMode === v} class:quiet={artMode !== v} onclick={() => setFlag("replaceEmbeddedArt", v === "replace")}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Embedded cover size</span><span class="m">the longest edge of the copy written into each file; the original is kept in the folder</span></div>
<div class="chips">
{#each ART_EDGE as [v, l] (v)}<button class="chip" class:on={artEdge === v} class:quiet={artEdge !== v} onclick={() => setNumber("embedArtMaxEdge", Number(v))}>{l}</button>{/each}
</div>
</div>
<button class="row" onclick={() => setFlag("writeFolderCover", !(cfg?.write_folder_cover ?? true))}>
<span class="lead"><Icon name="image" size={20} /></span>
<span class="b"><span class="t">Album folder cover</span><span class="m">cover.jpg and folder.jpg beside the files, so other players show the same art</span></span>
<span class="sw" class:on={cfg?.write_folder_cover ?? true}><span></span></span>
</button>
<button class="row" onclick={refetchCovers} disabled={refetching}>
<span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
</button>
</div>
<div class="sheet-sec">This phone</div>
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
</div>
<div class="stub">
<b>The network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components.
</div>
<div class="endpad"></div>
+30
View File
@@ -1335,3 +1335,33 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.bulkline .ring { width: 14px; height: 14px; flex: none; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
.bulkline .bulkx { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; color: var(--text-muted); }
.bulkline .bulkx:active { background: rgba(255, 255, 255, 0.12); }
/* the repair sheet's search field, inside the half sheet */
.halfsheet .sfield.plmatch { margin: 6px var(--pad) 8px; }
/* ── a peer's menu on a raw result, and the Browse screen ─────────────── */
.peer-head .peer-menu { margin: -6px -6px -6px 2px; }
.brw-head { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 10px; }
.brw-head .b { flex: 1; min-width: 0; }
.brw-head .t { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brw-head .m { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
.brw-acts { display: flex; gap: 8px; padding: 2px 12px 8px; }
.brw-acts .btn { flex: 1; height: 36px; font-size: 13px; gap: 6px; }
.peer-file:disabled { opacity: 0.45; }
/* You → Sharing: the upload-slot stepper; a shared folder's path */
.stepper { display: inline-flex; align-items: center; gap: 2px; flex: none; }
.stepper .n { min-width: 24px; text-align: center; font-weight: 800; font-size: 14px; font-variant-numeric: tabular-nums; }
.group .row .t.mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 12.5px; font-weight: 600; }
/* Search: the spelling suggestion, inline in the note */
.sec-note .dym { color: var(--accent-hover); font-weight: 750; padding: 0 2px; }
/* ── the artwork viewer: the cover alone, over everything ─────────────── */
.viewer { position: fixed; inset: 0; z-index: 200; background: #000; display: grid; place-items: center; animation: viewer-in 160ms ease both; touch-action: none; overflow: hidden; }
.viewer img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; will-change: transform; transform-origin: center; }
@keyframes viewer-in { from { opacity: 0; } to { opacity: 1; } }
/* the lyrics face's way to its tools */
.lyr-tools { display: block; margin: 18px auto 8px; padding: 7px 14px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
.lyr-tools:active { background: rgba(255, 255, 255, 0.14); }
+9 -2
View File
@@ -54,10 +54,17 @@ export class CDP {
throw new Error("could not attach to the page on port " + port
+ (phone ? " (a WebView in the background does not answer; is SoulStream on screen?)" : ""));
}
send(method, params = {}) {
/* a page that never answers (frozen, or replaced by a reload) must fail
the call, not the whole walk */
send(method, params = {}, timeoutMs = 45000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((res) => this.waits.set(id, res));
return new Promise((res, rej) => {
const t = setTimeout(() => {
if (this.waits.delete(id)) rej(new Error("the page did not answer " + method + " in " + Math.round(timeoutMs / 1000) + "s — a frozen or replaced WebView?"));
}, timeoutMs);
this.waits.set(id, (m) => { clearTimeout(t); res(m); });
});
}
async evaluate(expr) {
const r = await this.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
+226 -11
View File
@@ -54,6 +54,11 @@ window.__settle = function () {
if (s) { var c = s.querySelector('.np-head .iconbtn'); if (c) c.click(); }
var h = document.querySelector('.halfsheet.up');
if (h) { var sc = document.querySelector('.scrim'); if (sc) sc.click(); }
/* every tab back to its root, through the nav's own door (the history mirror stays true) */
if (window.__ss && __ss.go && __ss.nav) {
var tabs = Object.keys(__ss.nav.stacks);
for (var i = 0; i < tabs.length; i++) { if (__ss.nav.stacks[tabs[i]].length > 1) { __ss.go(tabs[i]); __ss.go(tabs[i]); } }
}
var home = document.querySelectorAll('.nav button')[0];
if (home) { home.click(); home.click(); }
return true;
@@ -114,6 +119,20 @@ const PROBE = `(function () {
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
brwDirs: document.querySelectorAll('.brw-dir').length,
qrows: document.querySelectorAll('.q-face .row').length,
viewer: !!document.querySelector('.viewer img'),
lyrLines: document.querySelectorAll('.lyr .ln').length,
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
recents: document.querySelectorAll('.recent-chips .chip').length,
dym: (document.querySelector('.dym') || {}).textContent || '',
sq: (document.querySelector('.sfield input') || {}).value || '',
slots: (document.querySelector('.slots .stepper .n') || {}).textContent || '',
wordfill: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Word-by-word/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
artEdge: (function () { var ps = document.querySelectorAll('.prefs[data-mode="albumart"] .pref'); var c = ps[1] && ps[1].querySelector('.chip.on'); return c ? c.textContent.trim() : ''; })(),
brwFiles: document.querySelectorAll('.brw-dir .peer-file').length,
brwHead: (document.querySelector('.brw-head .m') || {}).textContent || '',
keep: (function () { var g = document.querySelector('.prefs[data-mode="streamcache"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
rowArt: document.querySelectorAll('.screen .row .art img').length,
@@ -132,9 +151,12 @@ const MOCK_STEPS = [
{ name: "home", wait: 1600 },
/* a long press on a card is its sheet */
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.halfsheet .row', 'Re-fetch the cover')`, name: "cover-refetched", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 },
/* a misspelt library name: the catalog has nothing, the library's own names correct it */
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 },
{ do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
@@ -144,14 +166,35 @@ const MOCK_STEPS = [
{ do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 },
/* the art opens the viewer; the back gesture closes it */
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 900 },
{ do: `history.back()`, name: "viewer-closed", wait: 700 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
/* a queue row's sheet: the row removed */
{ do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Lyrics later')`, name: "lyrics-nudged", wait: 500 },
{ do: `__click('.halfsheet .row', 'Search again')`, name: "lyrics-candidates", wait: 900 },
{ do: `__click('.halfsheet .row', 'Live')`, name: "lyrics-chosen", wait: 700 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 },
{ do: `history.back()`, name: "after-back", wait: 900 },
{ do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 },
{ do: `history.back()`, wait: 700 },
{ do: `history.back()`, name: "library-again", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 },
/* a buddy's sheet: browse, message, remove */
{ do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 },
{ do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a user looked up by name: the peer sheet */
{ do: `__click('.screen .row', 'Look up a user')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'dusty_crates'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Open')`, name: "user-lookup", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.segtabs button', 'Rooms')`, wait: 500 },
/* the fake network: a room with a few lines in it */
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
{ do: `history.back()`, wait: 700 },
@@ -161,6 +204,11 @@ const MOCK_STEPS = [
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 },
/* the hero's artist: the desktop's page — tabs, Popular's bars, Follow,
the latest release, fans also like — then a fan-also-likes page */
{ do: `document.querySelector('.cat-song').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-song-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a Records-rail card's long press: the record's sheet */
{ do: `(function () { var sec = [].slice.call(document.querySelectorAll('.sec')).find(function (x) { return /^\\s*Records/.test(x.textContent); }); var rail = sec && sec.nextElementSibling; rail.querySelector('.release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, name: "cat-record-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 },
{ do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 },
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 },
@@ -170,12 +218,31 @@ const MOCK_STEPS = [
{ do: `document.querySelector('.cat-song').click()`, name: "net-playing", wait: 5500 },
{ do: `document.querySelector('.mini').click()`, name: "net-sheet", wait: 3000 },
{ name: "net-arrived", wait: 7000 },
/* the now-playing sheet's ⋯: the streamed track saved into the library
(its cache is complete by now, so it files at once) */
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, name: "np-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
/* the raw peers: a peer's sheet → their shares browsed → a folder opened
→ one of its files streams */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.net-ask', null)`, name: "net-peers", wait: 1800 },
{ do: `document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "peer-file-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelector('.peer-head .peer-menu').click()`, name: "peer-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Browse their files')`, name: "browse", wait: 1400 },
{ do: `__click('.brw-dir .peer-folder', 'Homework')`, name: "browse-dir", wait: 600 },
{ do: `__click('.brw-dir .peer-file', 'Aerodynamic')`, name: "browse-play", wait: 2500 },
{ do: `history.back()`, wait: 700 },
/* a catalog record: a row plays through the resolver with the rest of
the record queued behind it, the next track finds its own copy on
its turn, and the disc plays the whole record from one folder */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 },
{ do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 },
/* the record page's own sheet from the bar */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 },
{ do: `__ss.next()`, name: "cat-album-next", wait: 7000 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "cat-album-disc", wait: 6500 },
@@ -216,6 +283,11 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
/* the repair: the unknown row finds a library track and takes its identity */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "playlist-unknown-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, name: "playlist-match", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'caribou track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-match-results", wait: 700 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Track 4')`, name: "playlist-matched", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
@@ -263,6 +335,23 @@ const MOCK_STEPS = [
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 week')`, name: "you-keep", wait: 500 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 month')`, wait: 400 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 500 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, wait: 400 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 500 },
{ do: `__click('.screen .row', 'Word-by-word')`, wait: 400 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 500 },
/* the tab again pops its stack to the root */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.sfield .iconbtn', null)`, name: "search-recent", wait: 600 },
{ do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1400 },
{ do: `__settle()`, wait: 600 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
/* the page overflow → the sleep timer */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
@@ -272,24 +361,48 @@ const MOCK_STEPS = [
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
community: (i) => i.title.startsWith("Community"),
buddies: (i) => i.title.startsWith("Community"),
"buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText),
"user-lookup": (i) => i.half.includes("up") && /dusty_crates/.test(i.halfText) && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
"you-norm-off": (i) => i.norm === "off",
"you-norm-on": (i) => i.norm === "on",
"you-lossy": (i) => i.srcMax === "Lossy only",
"you-keep": (i) => i.keep === "1 week",
"you-slots": (i) => i.slots === "3",
"you-words": (i) => i.wordfill === "on",
"you-artedge": (i) => i.artEdge === "1000 px",
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1,
"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"),
home: (i) => /^(Good |Still)/.test(i.title),
"hold-sheet": (i) => i.half.includes("up"),
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
"search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library"),
album: (i) => i.screen.includes("tr-push") && !i.mini,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle),
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
"track-sheet": (i) => i.half.includes("up"),
sheet: (i) => i.sheet.includes("up"),
"after-back": (i) => !i.sheet.includes("up"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img"),
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
"library-again": (i) => i.screen.includes("tr-pop"),
/* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming
record leads), the news, the back catalogue, recommendations,
@@ -299,6 +412,8 @@ const MOCK_EXPECT = {
/* the artist with a picture shows it on the hero and its card; the
namesake under Deezer's empty-hash placeholder shows the app's own */
"cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1,
"cat-song-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to record/.test(i.halfText),
"cat-record-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Go to record/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2,
"cat-artist-follow": (i) => i.fb.includes("Following"),
"cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,
@@ -306,7 +421,16 @@ const MOCK_EXPECT = {
"net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live),
"net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
"net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"),
"np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
"browse-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save),
@@ -324,6 +448,10 @@ const MOCK_EXPECT = {
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"playlist-match": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText) && !/Remove from playlist/.test(i.halfText),
"playlist-match-results": (i) => /Track 4/.test(i.halfText) && /Caribou/.test(i.halfText),
"playlist-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice),
"playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
@@ -366,8 +494,14 @@ const PHONE_STEPS = [
{ do: `document.querySelector('.gbar .iconbtn').click()`, name: "library-ambience", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-ambience", wait: 1200 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1400 },
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 1200 },
{ do: `history.back()`, name: "viewer-closed", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()`, wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 },
{ do: `history.back()`, name: "after-back", wait: 1000 },
/* a reload keeps the audio thread: the session comes back LIVE */
@@ -403,9 +537,14 @@ const PHONE_STEPS = [
in the library, reviewed, created, deleted again */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 },
/* the unknown row repaired from the real library */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
@@ -422,6 +561,12 @@ const PHONE_STEPS = [
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, name: "you-slots-back", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words-off", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
@@ -438,6 +583,10 @@ const PHONE_STEPS = [
const PHONE_EXPECT = {
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img"),
"album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),
sheet: (i) => i.sheet.includes("up"),
"after-back": (i) => !i.sheet.includes("up"),
@@ -451,6 +600,12 @@ const PHONE_EXPECT = {
"you-norm-on": (i) => i.norm === "on" && !i.toast,
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,
"you-nolimit": (i) => i.srcMax === "No limit" && !i.toast,
"you-slots": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-slots-back": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-words": (i) => i.wordfill === "on" && !i.toast,
"you-words-off": (i) => i.wordfill === "off" && !i.toast,
"you-artedge": (i) => i.artEdge === "1000 px" && !i.toast,
"you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast,
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
@@ -459,8 +614,11 @@ const PHONE_EXPECT = {
"playlist-playing": (i) => i.mini && !!i.playing && !i.toast,
"playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 2 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2 && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 3 && !i.toast,
"import-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"import-match-results": (i) => /Aerodynamic/.test(i.halfText),
"import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast,
"import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
@@ -487,12 +645,29 @@ function mediaSessionState() {
/* The audio mixer's underrun count for the app's fast track: a dropout the
* ear hears is a number here. Read before and after the walk; a rise during
* it is a finding. */
let appUidCache = null;
function appUid() {
if (appUidCache) return appUidCache;
try { appUidCache = (adb("shell", "dumpsys", "package", "com.soulstream.app").toString().match(/\buid=(\d+)/) || [])[1] || null; } catch { appUidCache = null; }
return appUidCache;
}
/* SoulStream's own tracks only: the mixer's thread counters are system-wide
and any app's notification sound moves them. Each track row of the dump
carries its client "pid/ uid" and an Underruns column (third from the
end, before Flushed and BitPerfect). */
function audioUnderruns() {
try {
const uid = appUid();
if (!uid) return null;
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
let n = 0;
for (const m of dump.matchAll(/Fifo frame underruns:[^\n]*n:\s*(\d+)/g)) n += +m[1];
for (const m of dump.matchAll(/numTracks=\d+ writeErrors=\d+ underruns=(\d+)/g)) n += +m[1];
for (const line of dump.split("\n")) {
const m = line.match(/(\d+)\/\s+(\d+)\s+\d+\s+\d+\s/);
if (!m || m[2] !== uid) continue;
const t = line.trim().split(/\s+/);
const u = Number(t[t.length - 3]);
if (Number.isFinite(u)) n += u;
}
return n;
} catch { return null; }
}
@@ -502,6 +677,7 @@ async function main() {
const problems = [];
let cdp = null; // reassigned by the restart phase
let underrunsBefore = null;
let underrunRise = 0; /* summed across the restart: a relaunch starts fresh tracks at zero */
let chrome = null;
let steps, expect, still, prefix;
@@ -568,11 +744,25 @@ async function main() {
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
cdp.on["Runtime.consoleAPICalled"] = (p) => {
if (p.type !== "error" && p.type !== "warning") return;
/* the reload step's own consequence: a pending IPC answer with nobody left to receive it */
if (p.args.some((x) => /Couldn't find callback id/.test(String(x.value ?? "")))) return;
const frames = (p.stackTrace?.callFrames || []).slice(0, 5)
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
};
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
/* after a reload the WebView may present a NEW page target: the old
socket then never answers. Attach again, keeping the handlers. */
const reattach = async () => {
const on = cdp.on;
try { cdp.close(); } catch { /* already gone */ }
const a = PHONE ? await attachPhone(PORT) : { cdp: await CDP.attach(PORT) };
cdp = a.cdp;
cdp.on = on;
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Log.enable");
};
if (!PHONE) {
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
@@ -592,6 +782,9 @@ async function main() {
} catch (e) { problems.push("STEP " + (s.name || s.do) + ": " + e.message); }
}
await sleep(s.wait || 500);
if (s.do === "location.reload()") {
try { await reattach(); } catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
}
if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE);
@@ -608,9 +801,11 @@ async function main() {
const PLAYER = `(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, pos: Math.round(p.position), status: p.status, needs: !!p.needsResource } : null; })()`;
const before = await cdp.evaluate(PLAYER);
cdp.close();
if (underrunsBefore != null) { const now = audioUnderruns(); if (now != null) underrunRise += Math.max(0, now - underrunsBefore); }
adb("shell", "am", "force-stop", "com.soulstream.app");
await sleep(800);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
underrunsBefore = null; /* re-read once the relaunch plays */
/* a relaunch restores a PAUSED track, which starts the media service
as a foreground service that must promote itself at once — on
2026-09-13 it did not, and Android killed the app ten seconds
@@ -618,6 +813,7 @@ async function main() {
resuming, and the pid must be the same one afterwards. */
adb("logcat", "-c");
await sleep(13000);
underrunsBefore = audioUnderruns();
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
const alive = !!pid2 && !fatal;
@@ -754,6 +950,24 @@ async function main() {
const peersOk = !!netInfo && netInfo.peers >= 1;
if (!peersOk) problems.push("EXPECT net-peers " + JSON.stringify(netInfo));
console.log((peersOk ? " ok " : " FAIL ") + "net-peers".padEnd(16) + " " + JSON.stringify(netInfo));
/* a peer's sheet → their shares browsed (one request; a big
share takes seconds) → back. A share that will not open
is reported by the screen, not a failure of the port. */
try {
await cdp.evaluate(`document.querySelector('.peer-head .peer-menu').click()`);
await sleep(700);
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.halfsheet .row', 'Browse their files')`);
const BRW = `(function () { return { title: (document.querySelector('.gb-t') || {}).textContent || '', dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
let brw = null;
for (let i = 0; i < 40; i++) { await sleep(500); brw = await cdp.evaluate(BRW); if (brw.dirs > 0 || brw.err) break; }
await still(path.join(OUT, `${prefix}net-browse.png`));
const brwOk = !!brw && (brw.dirs > 0 || brw.err);
if (!brwOk) problems.push("EXPECT net-browse " + JSON.stringify(brw));
console.log((brwOk ? " ok " : " FAIL ") + "net-browse".padEnd(16) + " " + JSON.stringify(brw));
await cdp.evaluate(`history.back()`);
await sleep(800);
} catch (e) { problems.push("STEP net-browse: " + e.message); }
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__settle()`);
await sleep(800);
@@ -775,11 +989,12 @@ async function main() {
if (cdp) cdp.close();
if (chrome) chrome.kill();
}
if (PHONE && underrunsBefore != null) {
if (PHONE) {
const after = audioUnderruns();
const grew = after != null && after > underrunsBefore;
if (grew) problems.push(`AUDIO underruns rose during the walk: ${underrunsBefore} → ${after}`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` mixer underruns ${underrunsBefore} → ${after}`);
const rise = underrunRise + (underrunsBefore != null && after != null ? Math.max(0, after - underrunsBefore) : 0);
const grew = rise > 0;
if (grew) problems.push(`AUDIO SoulStream's tracks underran ${rise} time${rise === 1 ? "" : "s"} during the walk`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` SoulStream's tracks: ${rise} underrun${rise === 1 ? "" : "s"} during the walk`);
}
/* the two toasts a Tauri-less page always shows are not findings */
const real = PHONE ? problems : problems.filter((p) => !/transformCallback|reading 'invoke'/.test(p));