android: the room can measure the song, and a cold artist page stops waiting on one call
The tap-to-sync room draws the recording's own vocal band (lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the head, and one button lays every line across the stretches it says are sung — the desktop's own generator, shared and unit-tested, which refuses rather than guesses when there is too little voice to read. The result is labelled an estimate everywhere it shows, the cursor goes back to the top so the taps correct it, and one more tap drops the whole thing back to an empty set, because a rough fill stamps every line at once and leaves Undo nothing to take back. The artist page now fires the hero seed and the overview beside the full page, the way the desktop stages them: a hero Kyle picked on this phone comes back off the file with no network at all, the discography paints from the overview's first page until the exhaustive pass lands with the classified one, and both lanes are dropped with the page when a hero changes. ANDROID.md's "Not done yet" is rewritten as the true map of the port — what is absent by design and why, what is waiting on Rust rather than on a file picker, and one bullet about the artwork viewer that had been done for rounds.
This commit is contained in:
1 parent
5738c2d7bf
commit
44eda8dff5
9 files changed
+374
-39
No files matched your search
+84
-18
@@ -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
|
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
|
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
|
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
|
transform (the small art flies to the big one); the big art opens the artwork
|
||||||
lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into
|
viewer — the cover at full size, pinch to zoom, one finger pans while zoomed,
|
||||||
`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off.
|
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
|
The activity declares no `screenOrientation` and handles `orientation` and
|
||||||
`screenSize` itself, so turning the phone over is a resize and nothing else —
|
`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
|
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.
|
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
|
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,
|
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
|
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).
|
(`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;
|
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
|
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
|
(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a
|
||||||
separator, and the wording stays honest about that): the copy plays under
|
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
|
the same title, artist and duration, so the lyric identity is still the
|
||||||
@@ -842,17 +879,46 @@ paused) without the walk.
|
|||||||
|
|
||||||
## Not done yet
|
## Not done yet
|
||||||
|
|
||||||
- Community's private layer (Spaces, holdings, the sealed inbox, friends by
|
Thirty rounds in, this is the honest map: what is deliberately absent, what
|
||||||
key, Who has it) — Freenet on the phone is a later experiment; the
|
is waiting on Rust, and what is simply smaller than the rest. Most of what
|
||||||
network side is in. Rooms and chat are built and mock-walked; a live room
|
the backend exposes and the phone never calls is a whole module it has no
|
||||||
is Kyle's to join.
|
business calling, so the list is by module — that is the reason, not the
|
||||||
- The desktop-only doors and tools: the playlist-file import and export (a
|
symptom.
|
||||||
file picker the phone's storage plugin has still to grow), the listener
|
|
||||||
door and the lyric alignment models (a desktop runtime), drag reorder
|
- **Off by design — the private layer.** Spaces, holdings, the sealed inbox,
|
||||||
(Move up / down / to the top stand in), catalog track previews, the full
|
friends by key, shared playlists, Who has it, the persona and its profile,
|
||||||
discography page, the artist-artwork upscaler, updates and the account
|
and the Soulseek↔persona link: sixty-six commands across ten modules
|
||||||
section (sign-in lives on Community).
|
(`freenet` 7, `friends` 7, `holdings` 8, `inbox` 3, `persona` 6, `profile`
|
||||||
- Follows and the play history are per library folder, so the phone's own
|
4, `proposals` 3, `shared_playlists` 13, `slsk_link` 5, `spaces` 10).
|
||||||
folder starts empty of both; they arrive with a shared library.
|
Freenet on the phone is a later experiment; the network side is in, and
|
||||||
- The artwork viewer: the cover at full size, pinch to zoom, one finger
|
rooms and chat are built and mock-walked. A live room is Kyle's to join.
|
||||||
pans while zoomed, a tap on the unzoomed cover or the back gesture closes.
|
- **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.
|
||||||
+54
-4
@@ -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 },
|
{ 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 }));
|
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 */
|
/* the heroes chosen this session — pick_image → set_artist_hero */
|
||||||
const heroes = {};
|
const heroes = {};
|
||||||
let follows = [];
|
let follows = [];
|
||||||
function mockCatalog(cmd, args) {
|
function mockCatalog(cmd, args) {
|
||||||
if (cmd === "catalog_artist") {
|
if (cmd === "catalog_artist") {
|
||||||
const id = Number(args?.artistId);
|
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"));
|
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
|
||||||
const own = a === CAT_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({
|
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: [],
|
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 })) : [],
|
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: [],
|
top_genre: "Electronic", socials: [],
|
||||||
}), 220));
|
}), slow ? 4000 : 220));
|
||||||
}
|
}
|
||||||
if (cmd === "catalog_artist_socials") {
|
if (cmd === "catalog_artist_socials") {
|
||||||
const id = Number(args?.artistId);
|
const id = Number(args?.artistId);
|
||||||
@@ -527,7 +545,7 @@ function mockCatalog(cmd, args) {
|
|||||||
page gives none (the related ones), the only Popular there is */
|
page gives none (the related ones), the only Popular there is */
|
||||||
if (cmd === "catalog_artist_popular") {
|
if (cmd === "catalog_artist_popular") {
|
||||||
const id = Number(args?.artistId);
|
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([]);
|
if (!a) return Promise.resolve([]);
|
||||||
const rows = id === CAT_ARTIST.id
|
const rows = id === CAT_ARTIST.id
|
||||||
? CAT_TRACKS.slice(0, 7)
|
? CAT_TRACKS.slice(0, 7)
|
||||||
@@ -551,6 +569,9 @@ function mockCatalog(cmd, args) {
|
|||||||
if (cmd === "catalog_search") {
|
if (cmd === "catalog_search") {
|
||||||
const q = String(args?.query || "").toLowerCase();
|
const q = String(args?.query || "").toLowerCase();
|
||||||
const hit = /daft|discovery|one more|punk/.test(q);
|
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
|
return new Promise((r) => setTimeout(() => r(hit
|
||||||
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
|
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
|
||||||
: { artists: [], albums: [], tracks: [] }), 120));
|
: { artists: [], albums: [], tracks: [] }), 120));
|
||||||
@@ -587,6 +608,26 @@ function mockCatalog(cmd, args) {
|
|||||||
if (cmd === "library_art_prewarm") return Promise.resolve();
|
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_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
|
||||||
if (cmd === "refresh_artist_art") return new Promise((r) => setTimeout(() => r(26), 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 === "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 === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
|
||||||
if (cmd === "preload_stream") {
|
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 */
|
/* 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));
|
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 === "lyrics_clear") return Promise.resolve();
|
||||||
if (cmd === "clear_play_history") 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 === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\mock\\cover.jpg"), 200));
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||||
tests/mobile/smoke.mjs drives the real app through it */
|
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(); };
|
return () => { if (unlisten) unlisten(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,11 +5,17 @@
|
|||||||
large, the line behind and the line ahead faint around it, and the
|
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
|
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
|
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 Icon from "../components/Icon.svelte";
|
||||||
import { nav } from "./nav.svelte.js";
|
import { nav } from "./nav.svelte.js";
|
||||||
import { player, toggle, seekSecs } from "./player.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";
|
import { mmss } from "./util.js";
|
||||||
|
|
||||||
/* the ✕ asks twice once there is work to lose; the back gesture keeps
|
/* 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 */
|
/* 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);
|
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() {
|
function stamp() {
|
||||||
if (wrong || tapSync.saving || at >= rows.length) return;
|
if (wrong || tapSync.saving || at >= rows.length) return;
|
||||||
tapSync.rows[at].time_ms = nowMs();
|
tapSync.rows[at].time_ms = nowMs();
|
||||||
tapSync.cursor = at + 1;
|
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;
|
confirmDiscard = false;
|
||||||
}
|
}
|
||||||
function undo() {
|
function undo() {
|
||||||
@@ -84,12 +116,22 @@
|
|||||||
<div class="sy-warn">{tapSync.error}</div>
|
<div class="sy-warn">{tapSync.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if cols.length}
|
||||||
|
<div class="sy-strip">
|
||||||
|
{#each cols as v, i (i)}<i class="sy-col" style="height:{Math.max(3, v * 100).toFixed(0)}%"></i>{/each}
|
||||||
|
{#each rows as r, i (i)}
|
||||||
|
{#if r.time_ms != null}<span class="sy-tick" style="left:{pct(r.time_ms).toFixed(2)}%"></span>{/if}
|
||||||
|
{/each}
|
||||||
|
<span class="sy-play" style="left:{pct((player.position ?? 0) * 1000).toFixed(2)}%"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button class="sy-est" onclick={() => { if (tapSync.estimated) clearEstimate(); else estimateTimings(); }} disabled={tapSync.reading !== "ready"}>{estLabel}</button>
|
||||||
<button class="sy-pad" onclick={stamp} disabled={wrong}>
|
<button class="sy-pad" onclick={stamp} disabled={wrong}>
|
||||||
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
|
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
|
||||||
{#if at < rows.length}
|
{#if at < rows.length}
|
||||||
<span class="sy-now">{rows[at].text}</span>
|
<span class="sy-now">{rows[at].text}</span>
|
||||||
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
|
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
|
||||||
<span class="sy-hint">Tap anywhere as the line begins</span>
|
<span class="sy-hint">{tapSync.estimated ? "Estimated — tap to correct each line" : "Tap anywhere as the line begins"}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="sy-now">That’s every line — save the timing.</span>
|
<span class="sy-now">That’s every line — save the timing.</span>
|
||||||
<span class="sy-next"></span>
|
<span class="sy-next"></span>
|
||||||
@@ -103,7 +145,7 @@
|
|||||||
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
|
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
|
||||||
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
|
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
|
||||||
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
|
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
|
||||||
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : "Save timing")}
|
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : (tapSync.estimated && done === rows.length ? "Save the estimate" : "Save timing"))}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const catalog = $state({
|
|||||||
albumInfo: {},
|
albumInfo: {},
|
||||||
/** artist id → { status, tracks } — Popular on its own, ahead of the page */
|
/** artist id → { status, tracks } — Popular on its own, ahead of the page */
|
||||||
popular: {},
|
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
|
/** catalog album id → { status, counts: { trackId: listeners } } — the
|
||||||
* deferred Last.fm pass for the rows the record's page had no count for */
|
* deferred Last.fm pass for the rows the record's page had no count for */
|
||||||
listeners: {},
|
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. */
|
/** A portrait for a byline — the same identity rule, search only. */
|
||||||
export function seedFor(name) {
|
export function seedFor(name) {
|
||||||
const k = artistKey(name);
|
const k = artistKey(name);
|
||||||
@@ -161,6 +196,9 @@ export function forgetArtist(name) {
|
|||||||
const cur = catalog.artists[k];
|
const cur = catalog.artists[k];
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
delete catalog.artists[k];
|
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);
|
lookupArtist(name, cur.seed ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as api from "../lib/api.js";
|
|||||||
import { player, current, playQueueAt } from "./player.svelte.js";
|
import { player, current, playQueueAt } from "./player.svelte.js";
|
||||||
import { note } from "./notice.svelte.js";
|
import { note } from "./notice.svelte.js";
|
||||||
import { openTiming, closeTiming } from "./nav.svelte.js";
|
import { openTiming, closeTiming } from "./nav.svelte.js";
|
||||||
|
import { activeSegments, distributeLines } from "../lib/lyric-sync.js";
|
||||||
|
|
||||||
export const lyr = $state({
|
export const lyr = $state({
|
||||||
key: null, // the track's path — the identity the words belong to
|
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
|
identity: null, // the track the rows belong to, captured at open
|
||||||
saving: false,
|
saving: false,
|
||||||
error: "",
|
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())
|
const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim())
|
||||||
@@ -232,7 +239,13 @@ export function openTapSync() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
/* peeled by the back gesture mid-tap: the same track's stamps come back
|
/* peeled by the back gesture mid-tap: the same track's stamps come back
|
||||||
rather than the room starting over */
|
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))
|
const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain))
|
||||||
.filter((t) => String(t || "").trim());
|
.filter((t) => String(t || "").trim());
|
||||||
if (!texts.length) return;
|
if (!texts.length) return;
|
||||||
@@ -241,7 +254,11 @@ export function openTapSync() {
|
|||||||
tapSync.identity = id;
|
tapSync.identity = id;
|
||||||
tapSync.saving = false;
|
tapSync.saving = false;
|
||||||
tapSync.error = "";
|
tapSync.error = "";
|
||||||
|
tapSync.activity = null;
|
||||||
|
tapSync.reading = "idle";
|
||||||
|
tapSync.estimated = false;
|
||||||
openTiming();
|
openTiming();
|
||||||
|
void loadActivity(id);
|
||||||
}
|
}
|
||||||
export function closeTapSync() {
|
export function closeTapSync() {
|
||||||
closeTiming();
|
closeTiming();
|
||||||
@@ -250,6 +267,9 @@ export function closeTapSync() {
|
|||||||
tapSync.identity = null;
|
tapSync.identity = null;
|
||||||
tapSync.saving = false;
|
tapSync.saving = false;
|
||||||
tapSync.error = "";
|
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. */
|
/** What the lyrics sheet says under the row when a set was left mid-tap. */
|
||||||
export function tapProgress() {
|
export function tapProgress() {
|
||||||
@@ -279,3 +299,61 @@ export async function saveTapSync() {
|
|||||||
tapSync.saving = false;
|
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 = "";
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import { art, wantArt } from "../art.svelte.js";
|
import { art, wantArt } from "../art.svelte.js";
|
||||||
import { player, playEntries, playAlbum } from "../player.svelte.js";
|
import { player, playEntries, playAlbum } from "../player.svelte.js";
|
||||||
import { push, openHalf } from "../nav.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 { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
|
||||||
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
|
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
|
||||||
import { shuffled, mmss, ago } from "../util.js";
|
import { shuffled, mmss, ago } from "../util.js";
|
||||||
@@ -51,8 +51,15 @@
|
|||||||
const entry = $derived(catalog.artists[artistKey(name)] ?? null);
|
const entry = $derived(catalog.artists[artistKey(name)] ?? null);
|
||||||
const page = $derived(entry?.page ?? null);
|
const page = $derived(entry?.page ?? null);
|
||||||
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
|
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
|
||||||
const shownName = $derived(page?.artist?.name || seed?.name || name);
|
/* the two fast lanes the page does not wait for: the banner on its own
|
||||||
const heroUrl = $derived(page?.banner || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);
|
(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);
|
const logoUrl = $derived(page?.logos?.[0] ?? null);
|
||||||
/* once per name — untracked, or the entry the lookup writes would re-arm
|
/* once per name — untracked, or the entry the lookup writes would re-arm
|
||||||
this effect, and a lookup that fails would fail forever, hot */
|
this effect, and a lookup that fails would fail forever, hot */
|
||||||
@@ -64,10 +71,11 @@
|
|||||||
const nm = page?.artist?.name || name;
|
const nm = page?.artist?.name || name;
|
||||||
if (id != null) untrack(() => loadExtras(id, nm));
|
if (id != null) untrack(() => loadExtras(id, nm));
|
||||||
});
|
});
|
||||||
/* Popular's own lane, asked the moment an id is known — from the
|
/* three lanes off the same id, all of them ahead of the page: Popular's
|
||||||
search's own row, or from the identity search — so the section can
|
ranked rows, the banner on its own, and the first page of releases.
|
||||||
paint before the full page is built */
|
Asked the moment an id is known — from the search's own row, or from
|
||||||
$effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); });
|
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 seedSrc = $derived(seedPath ? art[seedPath] : "");
|
||||||
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
|
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
|
||||||
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
|
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));
|
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 ──────────────────────────────────────── */
|
/* ── 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));
|
const tabs = $derived(discoTabs(discoAll));
|
||||||
let tab = $state("album");
|
let tab = $state("album");
|
||||||
let tabFor = null;
|
let tabFor = null;
|
||||||
@@ -216,7 +227,7 @@
|
|||||||
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
|
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
|
||||||
</button>
|
</button>
|
||||||
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
|
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
|
||||||
{#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if}
|
{#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}</span>{/if}
|
||||||
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
|
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
|
||||||
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
|
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
|
||||||
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
|
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
|
||||||
|
|||||||
@@ -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-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-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; }
|
.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 { 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:active { background: rgba(255, 255, 255, 0.05); }
|
||||||
.sy-pad:disabled { opacity: 0.5; }
|
.sy-pad:disabled { opacity: 0.5; }
|
||||||
|
|||||||
+44
-2
@@ -251,6 +251,24 @@ const PROBE = `(function () {
|
|||||||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
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('|'),
|
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 ''; } })(),
|
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: "<page>:<seed>[+art]:<overview>:<releases>" */
|
||||||
|
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 */
|
/* the number this round exists to hold at zero, and who broke it */
|
||||||
a11yAnon: a11yNone.length,
|
a11yAnon: a11yNone.length,
|
||||||
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
|
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
|
/* 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 */
|
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 },
|
{ 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)`, wait: 400 },
|
||||||
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
|
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
|
||||||
{ do: `__click('.sy-undo', null)`, name: "sync-undone", 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: `__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: `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 },
|
{ 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: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
|
||||||
{ do: `history.back()`, wait: 600 },
|
{ do: `history.back()`, wait: 600 },
|
||||||
{ do: `history.back()`, wait: 800 },
|
{ do: `history.back()`, wait: 800 },
|
||||||
@@ -800,7 +827,9 @@ const MOCK_EXPECT = {
|
|||||||
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
|
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
|
||||||
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
||||||
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
|
"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-tapped": (i) => i.sync && i.syCount === "2 / 3",
|
||||||
"sync-undone": (i) => i.sync && i.syCount === "1 / 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),
|
"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-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-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),
|
"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-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-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
|
||||||
"net-arrived": (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(); })()`, wait: 700 },
|
||||||
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", 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 },
|
{ 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(); })()`, wait: 500 },
|
||||||
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
|
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
|
||||||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||||||
@@ -1108,6 +1145,10 @@ const PHONE_EXPECT = {
|
|||||||
tracks: (i) => i.title.startsWith("Your library"),
|
tracks: (i) => i.title.startsWith("Your library"),
|
||||||
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
|
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
|
||||||
"artist-cold": (i) => i.screen.includes("has-hero"),
|
"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: (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-off": (i) => i.norm === "off" && !i.toast,
|
||||||
"you-norm-on": (i) => i.norm === "on" && !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,
|
"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-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
|
||||||
"share-cancel": (i) => i.title.startsWith("Your library"),
|
"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),
|
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),
|
||||||
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
|
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
|
||||||
end: (i) => /^(Good |Still)/.test(i.title),
|
end: (i) => /^(Good |Still)/.test(i.title),
|
||||||
|
|||||||
Reference in new issue
Block a user