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:
KeelrLeek committed 2026-09-14 00:58:14 -05:00
1 parent 5738c2d7bf
commit 44eda8dff5
9 files changed
+374 -39

No files matched your search

+54 -4
View File
@@ -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));