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} + diff --git a/src/mobile/catalog.svelte.js b/src/mobile/catalog.svelte.js index 3c6c499..049e9ed 100644 --- a/src/mobile/catalog.svelte.js +++ b/src/mobile/catalog.svelte.js @@ -26,6 +26,14 @@ export const catalog = $state({ albumInfo: {}, /** artist id → { status, tracks } — Popular on its own, ahead of the page */ popular: {}, + /** artist id → { status, banner, custom } — the banner alone, ahead of + * the page: a hero Kyle picked on this phone comes back from the + * override with no network at all */ + heroSeeds: {}, + /** artist id → { status, artist, albums } — the desktop's one-round + * content stage, so the discography can paint while the exhaustive + * classification pass is still running */ + overviews: {}, /** catalog album id → { status, counts: { trackId: listeners } } — the * deferred Last.fm pass for the rows the record's page had no count for */ listeners: {}, @@ -111,6 +119,33 @@ export function loadPopular(id) { ); } +/** The banner on its own (`catalog_artist_hero_seed`), ahead of the page. + * Three sources in the Rust, cheapest first: the hero Kyle picked on this + * phone (a file, no network at all), the art manifest, then the provider. + * The full page still owns the final artwork — this only paints first. */ +export function loadHeroSeed(id) { + if (id == null || catalog.heroSeeds[id]) return; + catalog.heroSeeds[id] = { status: "loading", banner: null, custom: false }; + api.catalogArtistHeroSeed(id).then( + (s) => { catalog.heroSeeds[id] = s?.banner ? { status: "ready", banner: s.banner, custom: !!s.banner_custom } : { status: "none", banner: null, custom: false }; }, + () => { catalog.heroSeeds[id] = { status: "error", banner: null, custom: false }; }, + ); +} +/** The artist and the first page of releases in one round + * (`catalog_artist_overview`) — the desktop's fast content stage, for the + * desktop's own reason: the identity chain inside the full page rides + * MusicBrainz's gate and is measured there at several seconds. Its + * `popular_tracks` are deliberately dropped: Popular has a ranked lane of + * its own already, and the overview's list is budgeted and unranked. */ +export function loadOverview(id) { + if (id == null || catalog.overviews[id]) return; + catalog.overviews[id] = { status: "loading", artist: null, albums: [] }; + api.catalogArtistOverview(id).then( + (o) => { catalog.overviews[id] = { status: "ready", artist: o?.artist ?? null, albums: o?.albums ?? [] }; }, + () => { catalog.overviews[id] = { status: "error", artist: null, albums: [] }; }, + ); +} + /** A portrait for a byline — the same identity rule, search only. */ export function seedFor(name) { const k = artistKey(name); @@ -161,6 +196,9 @@ export function forgetArtist(name) { const cur = catalog.artists[k]; if (!cur) return; delete catalog.artists[k]; + /* the fast lanes belong to the page, not to the name: a new hero or a + cleared one must not come back from what the last visit measured */ + if (cur.id != null) { delete catalog.heroSeeds[cur.id]; delete catalog.overviews[cur.id]; } lookupArtist(name, cur.seed ?? null); } diff --git a/src/mobile/lyrics.svelte.js b/src/mobile/lyrics.svelte.js index d9b8428..acec7da 100644 --- a/src/mobile/lyrics.svelte.js +++ b/src/mobile/lyrics.svelte.js @@ -7,6 +7,7 @@ import * as api from "../lib/api.js"; import { player, current, playQueueAt } from "./player.svelte.js"; import { note } from "./notice.svelte.js"; import { openTiming, closeTiming } from "./nav.svelte.js"; +import { activeSegments, distributeLines } from "../lib/lyric-sync.js"; export const lyr = $state({ key: null, // the track's path — the identity the words belong to @@ -219,6 +220,12 @@ export const tapSync = $state({ identity: null, // the track the rows belong to, captured at open saving: false, error: "", + /* the envelope is the desktop's, measured per track on open and thrown + away with the room; `estimated` says the times came from it rather + than from a thumb, and every label in the room reads it */ + activity: null, // { frameMs, frames } — the measured vocal envelope + reading: "idle", // idle | reading | ready | none + estimated: false, }); const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim()) @@ -232,7 +239,13 @@ export function openTapSync() { if (!id) return; /* peeled by the back gesture mid-tap: the same track's stamps come back rather than the room starting over */ - if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; } + if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { + /* the envelope went with the last close, or never landed — re-arm it + rather than leaving the resumed room with an empty strip */ + if (!tapSync.activity && tapSync.reading !== "reading") void loadActivity(id); + openTiming(); + return; + } const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain)) .filter((t) => String(t || "").trim()); if (!texts.length) return; @@ -241,7 +254,11 @@ export function openTapSync() { tapSync.identity = id; tapSync.saving = false; tapSync.error = ""; + tapSync.activity = null; + tapSync.reading = "idle"; + tapSync.estimated = false; openTiming(); + void loadActivity(id); } export function closeTapSync() { closeTiming(); @@ -250,6 +267,9 @@ export function closeTapSync() { tapSync.identity = null; tapSync.saving = false; tapSync.error = ""; + tapSync.activity = null; + tapSync.reading = "idle"; + tapSync.estimated = false; } /** What the lyrics sheet says under the row when a set was left mid-tap. */ export function tapProgress() { @@ -279,3 +299,61 @@ export async function saveTapSync() { tapSync.saving = false; } } + +/* ── the measured envelope, and the rough pass over it ─────────────────── + The desktop's editor offers the same two things (LyricsSyncEditor.svelte): + a strip showing where the recording has voice in it, and a fill that lays + the lines across the ACTIVE stretches only. src/lib/lyric-sync.js is the + desktop's own generator — pure, shared, unit-tested — so the phone imports + it rather than keeping a second copy of the arithmetic. It refuses instead + of guessing when the measurement shows nothing usable: an estimate is a + starting point for the tap, never finished timing, and the room says so + everywhere it shows. The envelope belongs to the track, not to the store — + it is measured on open and thrown away with the room. */ +async function loadActivity(id) { + /* a streamed track is a cache file the engine may still be filling, and + the reducer refuses one on the same decode path for the same reason */ + if (!id?.path || player.track?.stream) { tapSync.reading = "none"; return; } + tapSync.reading = "reading"; + try { + const a = await api.lyricsVocalActivity(id.path); + if (tapSync.identity?.key !== id.key) return; // the room moved on + tapSync.activity = a?.frames?.length ? a : null; + tapSync.reading = tapSync.activity ? "ready" : "none"; + } catch { + if (tapSync.identity?.key === id.key) tapSync.reading = "none"; + } +} +/** Lay every line across the stretches the envelope says are sung, then put + * the cursor back at the top: the taps CORRECT the estimate from there. */ +export function estimateTimings() { + const a = tapSync.activity; + if (!a || !tapSync.rows.length) return false; + /* the room's rows ARE the lines: openTapSync built them from the document + the sheet is showing. So the generator's two halves are called directly + rather than roughSync's own text parse, which drops a section label a + SYNCED document still carries and would hand back a shorter list that + no longer lines up with the rows the room is asking about. */ + const filled = distributeLines(tapSync.rows.map((r) => String(r.text ?? "").trim()), activeSegments(a.frames, a.frameMs)); + /* it refuses rather than guesses when there is too little measured voice + to lay the lines across — and that is the only reason it refuses, so + the room can say which one it is */ + if (!filled) { + tapSync.error = "There is not enough voice in this song to estimate from."; + return false; + } + for (let i = 0; i < filled.length; i++) tapSync.rows[i].time_ms = filled[i].time_ms; + tapSync.cursor = 0; + tapSync.estimated = true; + tapSync.error = ""; + return true; +} +/** Drop it: every line back to no time, ready for the tap. A rough fill + * stamps every row at once, so Undo has nothing left to take back — this + * is the way out of an estimate that is wrong. */ +export function clearEstimate() { + for (const r of tapSync.rows) r.time_ms = null; + tapSync.cursor = 0; + tapSync.estimated = false; + tapSync.error = ""; +} diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index 5456295..8545cd1 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -19,7 +19,7 @@ import { art, wantArt } from "../art.svelte.js"; import { player, playEntries, playAlbum } from "../player.svelte.js"; import { push, openHalf } from "../nav.svelte.js"; - import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, picture } from "../catalog.svelte.js"; + import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js"; import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js"; import { shuffled, mmss, ago } from "../util.js"; @@ -51,8 +51,15 @@ const entry = $derived(catalog.artists[artistKey(name)] ?? null); const page = $derived(entry?.page ?? null); const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null); - const shownName = $derived(page?.artist?.name || seed?.name || name); - const heroUrl = $derived(page?.banner || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null); + /* the two fast lanes the page does not wait for: the banner on its own + (a hero chosen on this phone is read off the file, so it is up before + any network answers) and the one-round content stage. Both sit strictly + BELOW the page everywhere they are read — what the exhaustive pass + returns always wins. */ + const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null); + const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null); + const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name); + const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null); const logoUrl = $derived(page?.logos?.[0] ?? null); /* once per name — untracked, or the entry the lookup writes would re-arm this effect, and a lookup that fails would fail forever, hot */ @@ -64,10 +71,11 @@ const nm = page?.artist?.name || name; if (id != null) untrack(() => loadExtras(id, nm)); }); - /* Popular's own lane, asked the moment an id is known — from the - search's own row, or from the identity search — so the section can - paint before the full page is built */ - $effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); }); + /* three lanes off the same id, all of them ahead of the page: Popular's + ranked rows, the banner on its own, and the first page of releases. + Asked the moment an id is known — from the search's own row, or from + the identity search — so the page can paint before it is built */ + $effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); }); const seedSrc = $derived(seedPath ? art[seedPath] : ""); const heroSrc = $derived(heroUrl ? art[heroUrl] : ""); const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null); @@ -104,7 +112,10 @@ const fmtCount = (n) => (!n ? "" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n)); /* ── the discography, filed ──────────────────────────────────────── */ - const discoAll = $derived(page?.albums ?? []); + /* the overview's raw first page holds the discography up until the full + page lands with the classified one — never the other way round: the + page's list is a strict upgrade, folded and filed */ + const discoAll = $derived(page?.albums ?? overview?.albums ?? []); const tabs = $derived(discoTabs(discoAll)); let tab = $state("album"); let tabFor = null; @@ -216,7 +227,7 @@ {followed ? "Following" : "Follow"} {#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),