android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
1 parent
972dd103cb
commit
bb828ff2dc
102 files changed
+12258
-830
No files matched your search
@@ -0,0 +1,334 @@
|
||||
<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, 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 } 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 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));
|
||||
});
|
||||
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 ─────────────────────────────────────────────────────── */
|
||||
const popularAll = $derived(page?.popular_tracks ?? []);
|
||||
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 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">
|
||||
{#each tabs as t (t.key)}
|
||||
<button class:on={tab === t.key} onclick={() => (tab = t.key)}>{t.label}<span class="count">{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)}
|
||||
/>
|
||||
{/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>
|
||||
<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" 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>
|
||||
Reference in new issue
Block a user