diff --git a/ANDROID.md b/ANDROID.md
index 69764a0..d8c7892 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -66,9 +66,12 @@ the desktop's own `color.js` and crossfaded by the compositor through the
registered `--accent`. The glass bar is `GlassBar.svelte`'s recipe and stays
anonymous until the page's large title has scrolled under it. The mini bar
is the desktop's 62px row over a 16px lane strip; tapping it is a container
-transform (the small art flies to the big one). The player's scrubber is the
-lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into
-`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off.
+transform (the small art flies to the big one); the big art opens 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. The player's scrubber
+is the lane itself, no knob. Motion is Material 3 Expressive's springs,
+sampled into `linear()`. Reduce motion (You → Appearance, or the OS setting)
+turns it off.
The activity declares no `screenOrientation` and handles `orientation` and
`screenSize` itself, so turning the phone over is a resize and nothing else —
@@ -141,6 +144,21 @@ into families, the library's copies marked), releases by year, fans also
like, what is on this phone, About. Opened from a search row it carries the
row's identity (`seed`), so a namesake can never take the page.
+A cold page no longer waits on one call for everything. Three lanes run off
+the artist's id the moment it is known: Popular's ranked rows
+(`catalog_artist_popular`), the banner alone (`catalog_artist_hero_seed`) and
+the first page of releases (`catalog_artist_overview`) — the desktop's own
+staging, for the desktop's own reason, since the identity pass inside the
+full page is a MusicBrainz chain behind a one-request-a-second gate. The
+overview's releases hold the discography up until the exhaustive page lands
+with the classified one, and never the other way round; its `popular_tracks`
+are dropped, because Popular already has a ranked lane of its own. The hero
+seed pays for itself twice: its first source is the hero Kyle picked on this
+phone, read off the file with no network at all, so **Choose hero photo**
+shows on the next visit at once instead of behind a whole catalog page — and
+both lanes are forgotten with the page when a hero is set or removed, or the
+old banner would answer while the new one was still being fetched.
+
The **catalog record page** is the desktop's album page: the kind, the
artist byline with a portrait, year · tracks · runtime, Last.fm's totals,
Play / Save / Shuffle, the record's other editions (the artist's page folded
@@ -290,7 +308,26 @@ and play/pause under the thumb, and Save writing the track's own document
(`lyrics_save_lines`, the top of the waterfall, where a paste lands too).
Lines never tapped are left out of it and the button says how many are kept;
a set left mid-tap by the back gesture is offered back by the row that
-opened it. "Play with reduced vocals" is the desktop's offline DSP
+opened it.
+
+The room also measures the recording. `lyrics_vocal_activity` is the
+desktop's coarse vocal-band envelope — the centre channel band-passed to
+180 Hz – 7.5 kHz, one value per 100 ms, read on a blocking thread — and it
+draws as a strip under the head, so the singing ahead is visible while the
+thumb waits for it. **Estimate the timing** lays every line across the
+stretches the envelope says are sung, weighted by line length, through the
+desktop's own generator (`src/lib/lyric-sync.js`, shared and unit-tested):
+never an even spread over the duration, and a refusal rather than a guess
+when the measurement shows nothing usable. The result is labelled an
+estimate everywhere it shows — the hint under the line, the Save button —
+the cursor goes back to the top so the taps correct it from there, and one
+more tap on the button drops the whole thing back to an empty set (a rough
+fill stamps every line at once, so Undo has nothing left to take back).
+A streamed track has no finished file to measure, so the button says there
+is no estimate for that one — the same rule the reduced-vocals copy follows,
+and the same whole-file decode behind both.
+
+"Play with reduced vocals" is the desktop's offline DSP
(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a
separator, and the wording stays honest about that): the copy plays under
the same title, artist and duration, so the lyric identity is still the
@@ -842,17 +879,46 @@ paused) without the walk.
## Not done yet
-- Community's private layer (Spaces, holdings, the sealed inbox, friends by
- 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 playlist-file import and export (a
- file picker the phone's storage plugin has still to grow), the listener
- door and the lyric alignment models (a desktop runtime), drag reorder
- (Move up / down / to the top stand in), 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.
+Thirty rounds in, this is the honest map: what is deliberately absent, what
+is waiting on Rust, and what is simply smaller than the rest. Most of what
+the backend exposes and the phone never calls is a whole module it has no
+business calling, so the list is by module — that is the reason, not the
+symptom.
+
+- **Off by design — the private layer.** Spaces, holdings, the sealed inbox,
+ friends by key, shared playlists, Who has it, the persona and its profile,
+ and the Soulseek↔persona link: sixty-six commands across ten modules
+ (`freenet` 7, `friends` 7, `holdings` 8, `inbox` 3, `persona` 6, `profile`
+ 4, `proposals` 3, `shared_playlists` 13, `slsk_link` 5, `spaces` 10).
+ Freenet on the phone is a later experiment; the network side is in, and
+ rooms and chat are built and mock-walked. A live room is Kyle's to join.
+- **Off by design — the desktop's own machinery.** The tag-import pipeline
+ (all sixteen of `import.rs`: its audition, its cover report, its portable
+ root — which is the import session's root, not the library folder); hover
+ previews (all eight of `preview.rs`), and with them the three pause/fade
+ acknowledgements that live there because they exist only to hand silence
+ between the main player and the preview player; the mini window, the glass
+ mode, Show in folder and SMTC; the updater (the phone's own build of it
+ answers honestly that there is no channel here, and the version is in You,
+ from `app_status`); the hero GPU upscaler, the cover-wordmark OCR and its
+ debug tools. The phone shows a wordmark the page carries; it does not run
+ the pipeline that makes one — and it does not fetch a 3000px cover upgrade
+ over mobile data for a screen that cannot show it, which is why
+ `catalog_cover` and `invalidate_catalog_cover` stay here too.
+- **Off by design — external runtimes.** yt-dlp and deno, the vocal
+ separator, the lyric alignment models, and the listener door that needs
+ them.
+- **Waiting on Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog`
+ does open Android's create-document sheet from Rust, but it answers with a
+ `content://` URI and `playlist_export` writes with `std::fs::write`, which
+ cannot take one. It needs a ContentResolver write, or the bytes handed to
+ an outbound share intent — and `crates/tauri-plugin-share` is inbound-only
+ today. Playlist-FILE import wants a `pick_file` in the storage plugin,
+ beside the `pick_folder` and `pick_image` it already has.
+- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
+ stand in), catalog track previews, the full discography page, and the
+ fused-artist portrait verdict — that last one needs the identity merge the
+ phone does not run, so porting it means porting the merge first.
+- **Per library folder.** Follows and the play history live in the library
+ folder, so the phone's own folder starts empty of both; they arrive with a
+ shared library.
diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js
index 6691cd1..2b5669f 100644
--- a/src/lib/mockLibrary.js
+++ b/src/lib/mockLibrary.js
@@ -497,21 +497,39 @@ const NEWS = [
{ title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 },
];
const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));
+/* An artist whose exhaustive page the mock deliberately keeps for four
+ seconds. Everything on screen before then came from the two fast lanes —
+ which is the only way the walk can tell that they ran. The titles are
+ deliberately plain, so the classifier's title heuristics leave them alone
+ and the tabs are exactly what the counts below say. */
+const CAT_SLOW = { id: 415, name: "Halcyon Drift", picture: null, album_count: 7, fans: 48000 };
+const SLOW_ALBUMS = [
+ { id: 4150, title: "Halcyon Drift", release_date: "2014-04-08", record_type: "album", track_count: 11 },
+ { id: 4151, title: "Second Light", release_date: "2016-09-23", record_type: "album", track_count: 10 },
+ { id: 4152, title: "Slow Ascent", release_date: "2019-02-15", record_type: "album", track_count: 12 },
+ { id: 4153, title: "First Signal", release_date: "2013-06-01", record_type: "single", track_count: 2 },
+ { id: 4154, title: "Fourth Wall", release_date: "2021-05-07", record_type: "album", track_count: 9 },
+ { id: 4155, title: "Long Winter", release_date: "2023-01-20", record_type: "album", track_count: 10 },
+ { id: 4156, title: "Open Water", release_date: "2024-08-30", record_type: "album", track_count: 11 },
+].map((a) => ({ ...a, artist: CAT_SLOW.name, cover: svgArt((a.id * 17) % 360, a.title[0]), edition_group: null, is_soundtrack: false }));
/* the heroes chosen this session — pick_image → set_artist_hero */
const heroes = {};
let follows = [];
function mockCatalog(cmd, args) {
if (cmd === "catalog_artist") {
const id = Number(args?.artistId);
- const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
+ const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST;
+ /* the exhaustive page, kept four seconds on purpose: whatever is on
+ screen before then came from the fast lanes and nowhere else */
+ const slow = a === CAT_SLOW;
return new Promise((r) => setTimeout(() => r({
artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
- albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
+ albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
top_genre: "Electronic", socials: [],
- }), 220));
+ }), slow ? 4000 : 220));
}
if (cmd === "catalog_artist_socials") {
const id = Number(args?.artistId);
@@ -527,7 +545,7 @@ function mockCatalog(cmd, args) {
page gives none (the related ones), the only Popular there is */
if (cmd === "catalog_artist_popular") {
const id = Number(args?.artistId);
- const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
+ const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.resolve([]);
const rows = id === CAT_ARTIST.id
? CAT_TRACKS.slice(0, 7)
@@ -551,6 +569,9 @@ function mockCatalog(cmd, args) {
if (cmd === "catalog_search") {
const q = String(args?.query || "").toLowerCase();
const hit = /daft|discovery|one more|punk/.test(q);
+ /* the slow artist answers the identity search at once — it is only
+ the exhaustive page that is kept waiting */
+ if (/halcyon/.test(q)) return new Promise((r) => setTimeout(() => r({ artists: [CAT_SLOW], albums: [], tracks: [] }), 120));
return new Promise((r) => setTimeout(() => r(hit
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
: { artists: [], albums: [], tracks: [] }), 120));
@@ -587,6 +608,26 @@ function mockCatalog(cmd, args) {
if (cmd === "library_art_prewarm") return Promise.resolve();
if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
if (cmd === "refresh_artist_art") return new Promise((r) => setTimeout(() => r(26), 300));
+ /* the banner ahead of the page. A hero picked on this phone is answered
+ from the override with no network at all, exactly as the Rust does —
+ and the field really is snake_case there (no rename_all on it) */
+ if (cmd === "catalog_artist_hero_seed") {
+ const id = Number(args?.artistId);
+ if (heroes[id]) return Promise.resolve({ banner: heroes[id], banner_custom: true });
+ const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
+ if (!a) return Promise.resolve(null);
+ return new Promise((r) => setTimeout(() => r({ banner: svgArt(210, a.name[0]), banner_custom: false }), 200));
+ }
+ /* the one-round content stage: the raw first page of releases, before the
+ exhaustive pass classifies them. It goes through checkedArtistPayload,
+ so `artist.id` must equal the argument or the wrapper throws */
+ if (cmd === "catalog_artist_overview") {
+ const id = Number(args?.artistId);
+ const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
+ if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist"));
+ const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : [];
+ return new Promise((r) => setTimeout(() => r({ artist: a, popular_tracks: [], albums }), 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") {
@@ -832,6 +873,15 @@ function mockNet(cmd, args) {
}
/* the vocal-reduced copy: a render takes a beat, and it is a new file */
if (cmd === "lyrics_prepare_instrumental") return new Promise((r) => setTimeout(() => r({ path: String(args?.path || "") + ".instrumental.wav", cached: false, durationSecs: 214 }), 400));
+ /* 214 s at a frame per 100 ms: three sung stretches with quiet between
+ them, which is exactly what activeSegments is built to find — 150 s of
+ active time, far over its 4 s floor, so the rough pass lays all three
+ lines and the first one lands at 10 s */
+ if (cmd === "lyrics_vocal_activity") {
+ const sung = [[10, 60], [80, 140], [160, 200]];
+ const frames = Array.from({ length: 2140 }, (_, i) => (sung.some(([a, b]) => i >= a * 10 && i < b * 10) ? 0.9 : 0.04));
+ return new Promise((r) => setTimeout(() => r({ frameMs: 100, frames }), 250));
+ }
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));
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index 798630d..cfa6449 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -233,7 +233,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
- window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
+ window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
return () => { if (unlisten) unlisten(); };
});
diff --git a/src/mobile/SyncRoom.svelte b/src/mobile/SyncRoom.svelte
index a71f8b0..e283bde 100644
--- a/src/mobile/SyncRoom.svelte
+++ b/src/mobile/SyncRoom.svelte
@@ -5,11 +5,17 @@
large, the line behind and the line ahead faint around it, and the
whole middle of the screen is the tap. Saving writes the track's own
document; lines never tapped are left out of it, and the button says
- how many are kept. */
+ how many are kept.
+
+ The strip under the head is the recording's own measured vocal band,
+ and the button under it lays every line across the stretches it says
+ are sung — the desktop's generator (src/lib/lyric-sync.js), never an
+ even spread, and never presented as finished timing: the estimate is
+ labelled one wherever it shows, and one more tap drops it. */
import Icon from "../components/Icon.svelte";
import { nav } from "./nav.svelte.js";
import { player, toggle, seekSecs } from "./player.svelte.js";
- import { tapSync, saveTapSync, closeTapSync } from "./lyrics.svelte.js";
+ import { tapSync, saveTapSync, closeTapSync, estimateTimings, clearEstimate } from "./lyrics.svelte.js";
import { mmss } from "./util.js";
/* the ✕ asks twice once there is work to lose; the back gesture keeps
@@ -44,10 +50,36 @@
/* playback moved on: a stamp would land on another song's clock */
const wrong = $derived(!!tapSync.identity && !!player.track && player.track.title !== tapSync.identity.title);
+ /* the envelope at the width the strip draws: 64 columns, each the
+ loudest frame in its slice — the shape of the singing, not the wave */
+ const cols = $derived.by(() => {
+ const f = tapSync.activity?.frames;
+ if (!f?.length) return [];
+ const per = f.length / 64;
+ return Array.from({ length: 64 }, (_, i) => {
+ let m = 0;
+ for (let j = Math.floor(i * per); j < Math.floor((i + 1) * per) && j < f.length; j++) m = Math.max(m, f[j]);
+ return m;
+ });
+ });
+ const spanMs = $derived((tapSync.activity?.frames?.length ?? 0) * (tapSync.activity?.frameMs ?? 100));
+ const pct = (ms) => (spanMs ? Math.max(0, Math.min(100, (ms / spanMs) * 100)) : 0);
+ /* one button, four states: reading the file, nothing worth reading, the
+ estimate, and the way back out of it */
+ const estLabel = $derived(
+ tapSync.reading === "ready"
+ ? (tapSync.estimated ? "Clear the estimate" : "Estimate the timing")
+ : tapSync.reading === "none" ? "No estimate for this one" : "Reading the song…",
+ );
+
function stamp() {
if (wrong || tapSync.saving || at >= rows.length) return;
tapSync.rows[at].time_ms = nowMs();
tapSync.cursor = at + 1;
+ /* the cursor only ever walks forward from where the estimate put it,
+ so reaching the end means every line has been tapped over — there
+ is none of the estimate left, and nothing may still call it one */
+ if (tapSync.estimated && tapSync.cursor >= rows.length) tapSync.estimated = false;
confirmDiscard = false;
}
function undo() {
@@ -84,12 +116,22 @@
{tapSync.error}
{/if}
+ {#if cols.length}
+
+ {#each cols as v, i (i)}{/each}
+ {#each rows as r, i (i)}
+ {#if r.time_ms != null}{/if}
+ {/each}
+
+
+ {/if}
+
{#if releasedCount}{releasedCount} releases{/if}
- {#if page?.artist?.fans || seed?.fans}{fans(page?.artist?.fans || seed?.fans)}{/if}
+ {#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}{/if}
{#if years}{years}{/if}
{#if page?.top_genre}{page.top_genre}{/if}
{#if playTime}{playTime} played{/if}
diff --git a/src/mobile/shell.css b/src/mobile/shell.css
index 82ac276..4deb09f 100644
--- a/src/mobile/shell.css
+++ b/src/mobile/shell.css
@@ -1404,6 +1404,14 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.sy-who i { display: block; font-style: normal; font-size: 13px; font-weight: 700; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sy-count { flex: none; min-width: 54px; text-align: right; font-size: 13px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.sy-warn { margin: 4px var(--pad) 0; padding: 9px 13px; border-radius: 10px; background: rgba(232, 163, 61, 0.14); color: #e8a33d; font-size: 12.5px; font-weight: 700; flex: none; }
+/* the measured vocal band under the head: where the singing is, the lines
+ that already have a time, and the playhead over both */
+.sy-strip { position: relative; display: flex; align-items: flex-end; gap: 1px; height: 40px; margin: 8px var(--pad) 0; padding: 0 2px; border-radius: 8px; background: rgba(255, 255, 255, 0.04); flex: none; overflow: hidden; }
+.sy-col { flex: 1; min-width: 0; border-radius: 1px; background: color-mix(in srgb, var(--accent) 55%, transparent); }
+.sy-tick { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: #fff; opacity: 0.75; }
+.sy-play { position: absolute; top: -2px; bottom: -2px; width: 2px; margin-left: -1px; background: var(--accent-hover); box-shadow: 0 0 8px var(--accent-hover); }
+.sy-est { align-self: center; margin: 8px 0 0; padding: 7px 16px; border-radius: var(--r-pill); font-size: 12.5px; font-weight: 750; color: var(--accent-hover); background: color-mix(in srgb, var(--accent) 14%, transparent); flex: none; }
+.sy-est:disabled { opacity: 0.45; color: var(--text-muted); background: rgba(255, 255, 255, 0.05); }
.sy-pad { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px var(--pad); text-align: center; }
.sy-pad:active { background: rgba(255, 255, 255, 0.05); }
.sy-pad:disabled { opacity: 0.5; }
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index 5acdc63..9937ca6 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -251,6 +251,24 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
+ syStrip: document.querySelectorAll('.sy-strip .sy-col').length,
+ syTicks: document.querySelectorAll('.sy-strip .sy-tick').length,
+ syEst: (document.querySelector('.sy-est') || {}).textContent || '',
+ syHint: (document.querySelector('.sy-hint') || {}).textContent || '',
+ disco: document.querySelectorAll('.grid2 .release').length,
+ /* the artist page's three lanes at once — the page, the hero seed and
+ the overview — so a step can say what painted and what had not
+ landed yet: ":[+art]::" */
+ artFast: (function () {
+ try {
+ var t = __ss.top();
+ if (!t || t.n !== 'artist') return '';
+ var a = __ss.catalog.artists[String(t.c.name || '').trim().toLowerCase()];
+ if (!a) return 'no-entry';
+ var h = __ss.catalog.heroSeeds[a.id] || {}, o = __ss.catalog.overviews[a.id] || {};
+ return a.status + ':' + (h.status || '-') + (h.banner ? '+art' : '') + ':' + (o.status || '-') + ':' + ((o.albums || []).length);
+ } catch (e) { return ''; }
+ })(),
/* the number this round exists to hold at zero, and who broke it */
a11yAnon: a11yNone.length,
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
@@ -376,6 +394,10 @@ const MOCK_STEPS = [
/* the tap-to-sync room: taps become the timing, one undone, the room
peeled by the back gesture and the set offered back, then saved */
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
+ /* the envelope is measured on open; the estimate lays every line across
+ the sung stretches, and dropping it puts the room back to the tap */
+ { do: `__click('.sy-est', null)`, name: "sync-estimated", wait: 700 },
+ { do: `__click('.sy-est', null)`, name: "sync-cleared", wait: 600 },
{ do: `__click('.sy-pad', null)`, wait: 400 },
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
@@ -467,6 +489,11 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 },
{ do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 },
+ /* an artist whose exhaustive page the mock keeps for four seconds: what
+ is on screen a second and a half in can only be the two fast lanes */
+ { do: `__ss.push('artist', { name: 'Halcyon Drift' })`, name: "artist-lanes", wait: 1200 },
+ { name: "artist-lanes-page", wait: 4200 },
+ { do: `history.back()`, wait: 700 },
{ do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
{ do: `history.back()`, wait: 600 },
{ do: `history.back()`, wait: 800 },
@@ -800,7 +827,9 @@ const MOCK_EXPECT = {
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
- "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow,
+ "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow && i.syStrip === 64 && /Estimate the timing/.test(i.syEst),
+ "sync-estimated": (i) => i.sync && i.syCount === "3 / 3" && i.syTicks === 3 && /Estimated/.test(i.syHint) && /Clear the estimate/.test(i.syEst),
+ "sync-cleared": (i) => i.sync && i.syCount === "0 / 3" && i.syTicks === 0 && /Estimate the timing/.test(i.syEst),
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
@@ -843,6 +872,11 @@ const MOCK_EXPECT = {
"artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice),
"artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix,
"artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),
+ /* the exhaustive page has NOT landed; the banner and the first four
+ releases have — three albums on the Albums tab, a single on its own */
+ "artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2,
+ /* and the page wins when it does land: the classified six */
+ "artist-lanes-page": (i) => /^ready:/.test(i.artFast) && i.heroPix && i.disco === 6 && i.tabs === 2,
"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"),
@@ -962,6 +996,9 @@ const PHONE_STEPS = [
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
+ /* the real DSP on a real file: the envelope is a whole-file decode, so
+ give it room. Estimating writes nothing — the room is discarded next */
+ { do: `(function () { var b = document.querySelector('.sy-est'); if (b && !b.disabled) b.click(); })()`, name: "sync-estimated", wait: 2500 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
@@ -1108,6 +1145,10 @@ const PHONE_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
"artist-cold": (i) => i.screen.includes("has-hero"),
+ "sync-estimated": (i) => !i.toast && (!i.sync
+ || (/Clear the estimate/.test(i.syEst)
+ ? i.syTicks > 0 && i.syStrip === 64
+ : /Estimate the timing|Reading the song|No estimate/.test(i.syEst))),
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax && !i.a11yAnon && !i.a11yGhost && !i.a11yState,
"you-norm-off": (i) => i.norm === "off" && !i.toast,
"you-norm-on": (i) => i.norm === "on" && !i.toast,
@@ -1152,7 +1193,8 @@ const PHONE_EXPECT = {
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
- artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,
+ artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1
+ && /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast),
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
end: (i) => /^(Good |Still)/.test(i.title),