diff --git a/ANDROID.md b/ANDROID.md index 2005239..6a76c28 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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_`, `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. diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 966293f..2494a1c 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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 */ diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index e07f45e..6346804 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -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 @@ {track.title}{track.artist || album?.artist} · {album?.title}
+ {#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)))} +
+ {/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} +
+ {/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}
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))} + {#if album.id != null} +
+ {@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))} + {/if}
Format{albumFmt(tracks) || "—"}
@@ -293,6 +402,9 @@ {:else if item.missing}
The file is no longer where the library last saw it.
{/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}
A playlist file or a folder of audio files imports on the desktop, where the files are.
+ {:else if kind === "plmatch" && shown?.arg?.item} + {@const p = shown.arg.playlist} + {@const item = shown.arg.item} +
Find a local match · {item.title}
+
+ + { matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" /> +
+
+ {#if matchBusy && !matchRows.length} +
Searching…
+ {:else if !matchRows.length} +
No library tracks match. A named row still streams from the network when you tap it.
+ {:else} + {#each matchRows.slice(0, 30) as c (c.path)} + + {/each} + {/if} +
+ {:else if kind === "peer" && shown?.arg?.username} + {@const u = shown.arg.username} + {@const g = shown.arg.group || null} + {@const buddy = slsk.buddies.includes(u)} +
+ + {u}{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"} +
+
+ {#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)))} +
+ {/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)))} +
+ {:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage} + {@const al = catPage.album} + {@const tracks = catPage.tracks || []} +
+ {#if al.cover}{:else}{/if} + {al.title}{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks +
+
+ {@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} +
+ {#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 })))} +
+ {:else if kind === "peerfile" && shown?.arg?.group} + {@const g = shown.arg.group} + {@const i = shown.arg.index || 0} + {@const f = g.files[i]} +
+ + {f ? titleFromName(f.name) : "—"}{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""} +
+
+ {@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} +
+ {@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))} +
+ {:else if kind === "lyrics"} +
Lyrics · {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}
+ {#if lyr.candidates} +
Choose the right lyrics
+
+ {#if lyr.searching} +
Searching LRCLIB…
+ {:else if !lyr.candidates.length} +
Nothing found for this track's name.
+ {:else} + {#each lyr.candidates as c (c.id)} + + {/each} + {/if} +
+
+ {:else} +
+ {#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))} +
+ {/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(); }))} +
+ {/if} {:else if kind === "librarysort"}
Sort & view
Order
@@ -388,7 +609,7 @@
{#each (room?.users ?? []).slice(0, 120) as u (u.username)} {@const buddy = slsk.buddies.includes(u.username)} - diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 6a8d913..06740ea 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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 @@ {:else if entry.n === "import"} + {:else if entry.n === "browse"} + {:else if entry.n === "community"} {:else if entry.n === "room"} @@ -285,6 +290,7 @@ + {#if player.error || lib.error}
{ player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}
diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte index 2490df2..5ab98c5 100644 --- a/src/mobile/Sheet.svelte +++ b/src/mobile/Sheet.svelte @@ -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 @@
-
+
openViewer(player.album?.art_path)} role="presentation">
@@ -188,7 +168,7 @@ {:else}
{#if face === "lyrics"} -
+
{ e.preventDefault(); openHalf("lyrics"); }} role="presentation"> {#if lyr.status === "loading"}
Looking for the words…
{:else if lyr.lines.length} @@ -205,11 +185,14 @@ {/if} {/each} + {:else if lyr.plain}
{lyr.plain}
Plain lyrics — nothing timed for this recording.
+ {:else if lyr.status === "instrumental"}
Instrumental.
+ {:else}
No lyrics found for this track.
{/if} @@ -225,7 +208,7 @@ {#if e.album?.title !== prevAlbum}
{e.album?.artist} · {e.album?.title}
{/if} - diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index f8dd574..ba30154 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -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}
diff --git a/src/mobile/screens/Browse.svelte b/src/mobile/screens/Browse.svelte new file mode 100644 index 0000000..d52178d --- /dev/null +++ b/src/mobile/screens/Browse.svelte @@ -0,0 +1,155 @@ + + +
+ +
+
{username}
+
{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)}` : ""}
+
+ +
+ +{#if busy} +
Reading {username}'s sharesOne request for the whole list; a big share takes a moment.
+{:else if error} +
Couldn't read their shares.{error}
+
+{:else if dirs && !dirs.length} +
Nothing shared.{username} shares no files, or keeps the share private.
+{:else if dirs} + {#if dirs.length > 8} +
+ + (q = e.currentTarget.value)} type="search" placeholder="Filter {totals.folders} folders" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Filter folders" /> + {#if q}{/if} +
+ {#if q}
{shown.length} of {dirs.length} folders
{/if} + {/if} + {#each shown.slice(0, cap) as d (d.name)} + {@const files = d.files.filter(audio)} +
+ + {#if open.has(d.name)} + {#if files.length} +
+ + +
+ {/if} +
+ {#each d.files as f, i (f.path)} + + {/each} +
+ {/if} +
+ {/each} + {#if shown.length > cap} +
+ {/if} +{/if} + +
diff --git a/src/mobile/screens/Community.svelte b/src/mobile/screens/Community.svelte index 1711f34..d5a88b3 100644 --- a/src/mobile/screens/Community.svelte +++ b/src/mobile/screens/Community.svelte @@ -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 @@ {/each} {:else if tab === "buddies"} + {#if !buddies.length} -
No buddies on this account yet.Hold a name in a room to add one.
+
No buddies on this account yet.Hold a name in a room to add one, or look one up above.
{/if} {#each buddies as b (b.u)} -
+ {#if recents.length} +

Recent

+
+ {#each recents as r (r)}{/each} +
+ {/if} {:else} {#if catErr}
{catErr}
{/if} {#if catBusy && !cat}
Asking the catalog…
{/if} - {#if catEmpty && !catBusy}
Nothing on the catalog for “{query}”.
{/if} + {#if catEmpty && !catBusy}
Nothing on the catalog for “{query}”.{#if didYouMean} Did you mean ?{/if}
{/if} {#if top}
@@ -292,7 +360,7 @@

Records

{#each catAlbums as a (a.id)} - openAlbum(a)} /> + openAlbum(a)} onmore={() => openHalf("catalogAlbum", { album: a })} /> {/each}
{/if} @@ -300,7 +368,7 @@ {#if catTracks.length}

Songs

{#each catTracks as t (t.id)} -
{#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)} - +
+
Streaming
+
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.
+
+
+
Keep streamed trackshow long the cache holds a track after its last play
+
+ {#each RETENTION as [v, l] (v)}{/each} +
+
+
+
Cache size limitthe oldest streamed tracks go first past this size
+
+ {#each CACHE as [v, l] (v)}{/each} +
+
+
+
Sources
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.
{#each MODES as [key, label] (key)} @@ -155,15 +240,77 @@
{/each} +
Downloads
+
+ +
Use {"{artist}"}, {"{album}"}, {"{date}"} or {"{year}"}; "\" starts a subfolder. New downloads are organized into this format; existing files are not moved.
+
+ +
Sharing
+
+
Your library folderalways shared · {(cfg?.share_files ?? 0).toLocaleString()} files
+
+ + Upload slotshow many peers can download from you at once + {cfg?.upload_slots ?? 2} +
+ {#each cfg?.shared_folders ?? [] as folder (folder)} +
+ + {folder}also shared + +
+ {/each} + +
+ +
Lyrics
+
+ +
+ +
Album artwork
+
+
+
Art inside downloaded filesthe canonical cover, or whatever the uploader embedded
+
+ {#each ART_MODE as [v, l] (v)}{/each} +
+
+
+
Embedded cover sizethe longest edge of the copy written into each file; the original is kept in the folder
+
+ {#each ART_EDGE as [v, l] (v)}{/each} +
+
+ + +
+
This phone
SoulStream{lib.version || "—"}
Soulseek{lib.username || "not signed in"}
-
- The network and the account come next. - They are the desktop's settings pages, drawn with the phone's components. -
diff --git a/src/mobile/shell.css b/src/mobile/shell.css index 6211dc3..cf2db57 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -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); } diff --git a/tests/mobile/cdp.mjs b/tests/mobile/cdp.mjs index 36962c2..df448af 100644 --- a/tests/mobile/cdp.mjs +++ b/tests/mobile/cdp.mjs @@ -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 }); diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index c40187d..9690b32 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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));