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:
1 parent
6e39dc43f3
commit
26928b706d
21 files changed
+1294
-92
No files matched your search
+81
-12
@@ -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
@@ -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
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user