Files
SoulStream/src/mobile/screens/Artist.svelte
T
KeelrLeek 4fc6e9c630 android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the
desktop's word where it already has one; the nav is a tab set whose badge
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
read out before it. The two sheets are dialogs with somewhere sensible for
the focus to land, and the half sheet's grab handle is finally a real
Close — the first dismissal a screen reader could reach, because the scrim
never was one. A sheet that is down is inert rather than sitting
off-screen in the accessibility tree with the hidden mini bar and the nav
under it, and one live region is mounted for the life of the app so that
an undo offer is actually announced; switches say on or off, chips say
pressed, and the waveform is a slider where it can seek. Nothing visible
changed. The walk counts the controls with no name, with a junk name, in a
hidden layer, or with unsaid state — all four held at zero on ten screens,
and the nameless count on a room and on both prompts besides, which is
where the six fields named here actually live.
2026-09-14 00:28:28 -05:00

359 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/* The artist page, the desktop's (CatalogArtist.svelte + ArtistExtras)
at phone scale: the banner with the wordmark or the name on it, the
identity row — Play popular, Follow, releases, fans, years active,
the genre, the socials — Popular with its listener bars, the latest
release, the discography filed the desktop's way (tabs, editions
folded into families, what the library holds marked), releases by
year, fans also like, what is on this phone, and About.
Opens cold: the library's own shell at once (its portrait blurred as
a seed when it is only a cover), the catalog lookup dissolving the
rest in — dissolve, never pop. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import * as api from "../../lib/api.js";
import { openUrl } from "@tauri-apps/plugin-opener";
import { albumsBy, tracksOf, isFollowed, toggleFollow } from "../lib.svelte.js";
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 { 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";
/** `seed`: the search's own row (a CatalogArtist), when the page was
* opened from one — the identity is then known, no name search. */
let { name, seed = null, screenEl = null } = $props();
/* the desktop's play-stats chips: this phone's own listening to the artist */
let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
$effect(() => {
const n = name;
if (plays.key === n) return;
plays = { key: n, seconds: 0, last: null, topTitle: null, topCount: 0 };
api.artistPlayStats(n).then((st) => {
if (plays.key !== n) return;
plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 };
}, () => { /* the chips stay hidden */ });
});
const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours");
/* ── the library's shell ─────────────────────────────────────────── */
const albums = $derived(albumsBy(name));
const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null);
const seedPath = $derived(portrait || albums[0]?.art_path || null);
const tracks = $derived(albums.reduce((n, a) => n + (a.track_count || 0), 0));
/* ── the catalog ─────────────────────────────────────────────────── */
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);
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 */
$effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); });
$effect(() => { if (seedPath) wantArt(seedPath); });
$effect(() => { if (heroUrl) wantArt(heroUrl); });
$effect(() => {
const id = page?.artist?.id;
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)); });
const seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
/* the library's rows for this artist, by loose title, so Popular and the
discography can say what is already here */
let libByTitle = $state(new Map());
$effect(() => {
const as = albums;
let live = true;
(async () => {
const m = new Map();
for (const a of as) {
let tr = [];
try { tr = await tracksOf(a.id); } catch { tr = []; }
for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t });
}
if (live) libByTitle = m;
})();
return () => { live = false; };
});
/* ── Popular ─────────────────────────────────────────────────────── */
/* the page's own list once it lands (the same Last.fm order, from the
same inputs); the fast lane's until then, and for an artist whose
page came back with no Popular at all */
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);
let showAllPopular = $state(false);
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null })));
const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
const share = (t) => (popPeak ? Math.max(0.04, (t.listeners || 0) / popPeak) : 0);
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 ?? []);
const tabs = $derived(discoTabs(discoAll));
let tab = $state("album");
let tabFor = null;
$effect(() => {
const id = artistId;
const ts = tabs;
if (!ts.length) return;
untrack(() => {
const albumsFirst = () => ts.find((t) => t.key === "album")?.key ?? ts[0].key;
if (tabFor !== id) { tabFor = id; tab = albumsFirst(); }
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst();
});
});
const families = $derived(tabs.find((t) => t.key === tab)?.items ?? []);
const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null;
const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null;
const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id;
const latest = $derived(latestOf(discoAll));
const timeline = $derived(timelineOf(discoAll));
const years = $derived(activeYears(discoAll));
const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length);
/* ── the identity row ────────────────────────────────────────────── */
const followed = $derived(isFollowed(shownName));
let followBusy = $state(false);
async function follow() {
if (artistId == null || followBusy) return;
followBusy = true;
try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); }
followBusy = false;
}
/** The opener plugin (an Android intent); the desktop command as the
* fallback for a build without it. */
function openLink(url) {
openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); }));
}
/* ── playing ─────────────────────────────────────────────────────── */
const current = (t) => player.track?.id === "cat:" + t.id;
async function playPopular(p) {
try {
if (p.hit) {
const tr = await tracksOf(p.hit.album.id);
await playAlbum(p.hit.album, tr, Math.max(0, tr.findIndex((x) => x.path === p.hit.track.path)));
return;
}
await streamSong(shownName, p.t.title, {
trackId: p.t.id, albumId: p.t.album_id ?? null, album: p.t.album || null,
cover: p.t.cover ?? null, expectSecs: p.t.duration_secs ?? null,
});
} catch (e) { player.error = String(e); }
}
function playTop() { const p = popular[0]; if (p) playPopular(p); }
function morePopular(p) {
if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; }
const t = p.t;
openHalf("track", {
album: { id: null, catalogId: t.album_id ?? null, title: t.album || null, artist: shownName, art_path: t.cover ?? null },
track: { id: "cat:" + t.id, title: t.title, artist: shownName, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: t.album_id ?? null, cover: t.cover ?? null } },
});
}
async function playRecord(a) {
try {
const l = inLib(a);
if (l) { const tr = await tracksOf(l.id); await playAlbum(l, tr, 0); return; }
await streamCatalogAlbum({ ...a, artist: shownName }, 0);
} catch (e) { player.error = String(e); }
}
async function shuffleAll() {
try {
const es = [];
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
if (es.length) await playEntries(shuffled(es), 0, shownName);
} catch (e) { player.error = String(e); }
}
function openRecord(a) {
const l = inLib(a);
if (l) push("album", { id: l.id }, screenEl);
else push("catalogAlbum", { id: a.id, artist: shownName, title: a.title }, screenEl);
}
function openFamily(f) {
const l = familyLib(f);
if (l) push("album", { id: l.id }, screenEl);
else push("catalogAlbum", { id: f.id, artist: shownName, title: f.title }, screenEl);
}
function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); }
let bioOpen = $state(false);
</script>
<div class="hero">
<div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div>
{#if heroSrc}
<div class="hero-img dissolve" style:background-image={`url("${heroSrc}")`}></div>
{/if}
<div class="hero-scrim"></div>
<div class="hero-in">
{#if logoUrl}
<img class="hero-logo" src={logoUrl} alt={shownName} />
{:else}
<div class="hero-name">{shownName}</div>
{/if}
</div>
</div>
<div class="hero-acts">
<button class="disc lg" onclick={playTop} disabled={!popular.length} aria-label="Play popular songs"><Icon name="play" size={26} /></button>
<div class="stats">
<button class="s fb" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
<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 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}
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
{#each socials as link (link.kind + link.url)}
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
{/each}
</div>
</div>
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 12px">Looking up the rest on the catalog…</div>
{:else if entry?.status === "error"}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if popular.length}
<div class="sec"><h2>Popular</h2></div>
{#each popular as p, i (p.t.id)}
<button class="row pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="rank">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album}{p.hit ? " · in your library" : ""}{current(p.t) && resolver.active ? " · finding the best source…" : ""}</span>
{#if popPeak}<span class="lbar"><i style="transform: scaleX({share(p.t).toFixed(3)})"></i><em>{fmtCount(p.t.listeners)}</em></span>{/if}
</span>
<span class="r">{mmss(p.t.duration_secs)}</span>
</button>
{/each}
{#if popularAll.length > 5}
<button class="more-link" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Show less" : "Show more"}</button>
{/if}
{/if}
{#if latest}
<div class="sec"><h2>Latest release</h2></div>
<div class="feat" style="margin: 0 var(--pad)">
<div class="feat-bg" style:background-image={latest.cover ? `url("${latest.cover}")` : null}></div>
<div class="feat-in">
<button class="feat-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
</button>
<div class="feat-copy">
<div class="k">Latest · {(latest.record_type || "album").toUpperCase()}</div>
<div class="feat-name">{latest.title}</div>
<div class="m">{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}</div>
<div class="feat-acts">
<button class="btn btn-fill" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => openRecord(latest)}>Open</button>
</div>
</div>
</div>
</div>
{/if}
{#if tabs.length}
<div class="sec"><h2>Discography <em>· {releasedCount} on the catalog</em></h2></div>
{#if tabs.length > 1}
<div class="segtabs disco-tabs" role="tablist" aria-label="Discography">
{#each tabs as t (t.key)}
<button class:on={tab === t.key} onclick={() => (tab = t.key)} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button>
{/each}
</div>
{/if}
<div class="grid2">
{#each families as f (familyKeyOf(f))}
{@const l = familyLib(f)}
<Release
title={f.title}
sub={tab === "upcoming" ? "Upcoming" : [yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
url={f.cover}
path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
onclick={() => openFamily(f)}
onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{/if}
{#if timeline}
<div class="sec"><h2>Releases by year <em>· {timeline.last - timeline.first + 1} years · {timeline.total} releases</em></h2></div>
<div class="tl">
{#each timeline.cols as c (c.year)}
<div class="tl-col" class:empty={!c.n} title="{c.year} · {c.n ? c.n + (c.n === 1 ? ' release' : ' releases') : 'nothing released'}">
<span class="tl-bar" style="height:{c.h}px"></span>
<span class="tl-yr">{String(c.year).slice(2)}</span>
</div>
{/each}
</div>
{/if}
{#if extras?.related?.length}
<div class="sec"><h2>Fans also like</h2></div>
<div class="rail">
{#each extras.related.slice(0, 12) as a (a.id)}
<button class="acard" onclick={() => openRelated(a)}>
<span class="por">{#if picture(a.picture)}<img src={picture(a.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{a.name}</span>
<span class="kd">{fans(a.fans) || "Artist"}</span>
</button>
{/each}
</div>
{/if}
{#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="grid2">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each}
</div>
{/if}
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length}
<div class="sec"><h2>About</h2></div>
<div class="about">
{#if extras.profile?.listeners || extras.profile?.plays}
<div class="stats">
{#if extras.profile.listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(extras.profile.listeners)} listeners</span>{/if}
{#if extras.profile.plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(extras.profile.plays)} plays</span>{/if}
</div>
{/if}
{#if extras.bio}
<p class="bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" aria-expanded={bioOpen} onkeydown={() => {}}>{extras.bio}</p>
{/if}
{#if extras.profile?.tags?.length}
<div class="tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
{/if}
</div>
{/if}
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"}
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog had nothing to add.</div>
{/if}
<div class="endpad"></div>