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));
+1 -1
View File
@@ -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(); };
});
</script>
+46 -4
View File
@@ -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 @@
<div class="sy-warn">{tapSync.error}</div>
{/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}>
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
{#if at < rows.length}
<span class="sy-now">{rows[at].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}
<span class="sy-now">That’s every line — save the timing.</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="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}>
{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>
</div>
</div>
+38
View File
@@ -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);
}
+79 -1
View File
@@ -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 = "";
}
+20 -9
View File
@@ -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 @@
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
</button>
{#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 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}
+8
View File
@@ -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; }