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,160 @@
|
||||
/* The catalog on the phone: the desktop's lookups (Deezer through the
|
||||
metadata crate, the art proxy for pictures), cached for the session.
|
||||
|
||||
An artist page opens cold — the library's own shell, instantly — and the
|
||||
lookup fills the rest in: the picture, Popular, the discography. The
|
||||
identity rule is the desktop's (`openArtistByName`): among the search's
|
||||
exact-name matches, the most-followed wins, so a shared name resolves to
|
||||
the canonical artist and never to a namesake the search listed first. */
|
||||
import * as api from "../lib/api.js";
|
||||
|
||||
export const catalog = $state({
|
||||
/** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */
|
||||
artists: {},
|
||||
/** catalog album id → { status, page, error } */
|
||||
albums: {},
|
||||
/** artist id → { status, related, profile, bio } — fans also like, the
|
||||
* Last.fm profile (listeners, plays, tags), the bio; once per artist */
|
||||
extras: {},
|
||||
/** artist id → { status, listeners, plays, tags } — the profile alone,
|
||||
* for the search hero */
|
||||
profiles: {},
|
||||
/** lowercase name → CatalogArtist | null — a portrait for a byline */
|
||||
seeds: {},
|
||||
/** "artist\u0000title" → { status, info: { description, genre, label, source } } */
|
||||
albumInfo: {},
|
||||
});
|
||||
|
||||
export function artistKey(name) {
|
||||
return String(name || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** The most-followed exact match for a name, or the best the search has. */
|
||||
async function bestArtistFor(name) {
|
||||
const k = artistKey(name);
|
||||
const res = await api.catalogSearch(name);
|
||||
const artists = res?.artists ?? [];
|
||||
const exact = artists.filter((a) => artistKey(a.name) === k);
|
||||
const pool = exact.length ? exact : artists;
|
||||
return pool.reduce((top, a) => ((a.fans ?? 0) > (top?.fans ?? -1) ? a : top), null);
|
||||
}
|
||||
|
||||
/** Open an artist by name — or by the search's own row (`seed`, a
|
||||
* CatalogArtist with its id), which skips the identity search: the row
|
||||
* IS the identity, namesake-safe, and its portrait is on screen at once. */
|
||||
export function lookupArtist(name, seed = null) {
|
||||
const k = artistKey(name);
|
||||
if (!k) return;
|
||||
const cur = catalog.artists[k];
|
||||
if (cur && cur.status !== "error") return;
|
||||
catalog.artists[k] = { status: "loading", id: seed?.id ?? null, seed: seed ?? null, page: null };
|
||||
(async () => {
|
||||
try {
|
||||
const best = seed?.id != null ? seed : await bestArtistFor(name);
|
||||
if (!best) { catalog.artists[k] = { status: "none", id: null, seed: null, page: null }; return; }
|
||||
catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null };
|
||||
const page = await api.catalogArtist(best.id);
|
||||
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
|
||||
} catch (e) {
|
||||
/* transport failures stay retryable — the next visit asks again */
|
||||
catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/** Fans also like, the Last.fm profile and the bio — the desktop's
|
||||
* ArtistExtras, asked once per artist after the page is on screen. */
|
||||
export function loadExtras(id, name) {
|
||||
if (id == null || catalog.extras[id]) return;
|
||||
catalog.extras[id] = { status: "loading", related: [], profile: null, bio: "", socials: [] };
|
||||
Promise.all([
|
||||
api.catalogRelated(id).catch(() => []),
|
||||
api.catalogArtistProfile(id).catch(() => null),
|
||||
api.catalogArtistBio(id, name).catch(() => null),
|
||||
/* the socials are a deferred MusicBrainz pass on the desktop too:
|
||||
rate-limited upstream, asked after the page is on screen */
|
||||
api.catalogArtistSocials(id).catch(() => []),
|
||||
]).then(([r, p, b, l]) => {
|
||||
const text = typeof b === "string" ? b : (b?.text ?? b?.bio ?? "");
|
||||
catalog.extras[id] = { status: "ready", related: r ?? [], profile: p ?? null, bio: text ?? "", socials: Array.isArray(l) ? l : [] };
|
||||
if (p) catalog.profiles[id] = { status: "ready", ...p };
|
||||
});
|
||||
}
|
||||
|
||||
/** The profile alone: the search hero's tags and totals. */
|
||||
export function loadProfile(id) {
|
||||
if (id == null || catalog.profiles[id]) return;
|
||||
catalog.profiles[id] = { status: "loading" };
|
||||
api.catalogArtistProfile(id).then(
|
||||
(p) => { catalog.profiles[id] = { status: "ready", ...(p || {}) }; },
|
||||
() => { catalog.profiles[id] = { status: "error" }; },
|
||||
);
|
||||
}
|
||||
|
||||
/** A portrait for a byline — the same identity rule, search only. */
|
||||
export function seedFor(name) {
|
||||
const k = artistKey(name);
|
||||
if (!k || k in catalog.seeds) return;
|
||||
catalog.seeds[k] = null;
|
||||
const known = catalog.artists[k]?.seed;
|
||||
if (known) { catalog.seeds[k] = known; return; }
|
||||
bestArtistFor(name).then((a) => { catalog.seeds[k] = a || null; }, () => {});
|
||||
}
|
||||
|
||||
export const infoKey = (artist, title) => String(artist || "").toLowerCase() + "\u0000" + String(title || "").toLowerCase();
|
||||
/** About this record: Wikipedia's words, the genre and the label, through
|
||||
* the desktop's own lookup. Once per record. */
|
||||
export function loadAlbumInfo(artist, title) {
|
||||
const k = infoKey(artist, title);
|
||||
if (!artist || !title || catalog.albumInfo[k]) return;
|
||||
catalog.albumInfo[k] = { status: "loading", info: null };
|
||||
api.catalogAlbumInfo(null, artist, title).then(
|
||||
(info) => { catalog.albumInfo[k] = { status: "ready", info: info || null }; },
|
||||
() => { catalog.albumInfo[k] = { status: "error", info: null }; },
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupAlbum(id) {
|
||||
if (id == null) return;
|
||||
const cur = catalog.albums[id];
|
||||
if (cur && cur.status !== "error") return;
|
||||
catalog.albums[id] = { status: "loading", page: null };
|
||||
api.catalogAlbum(id).then(
|
||||
(page) => { catalog.albums[id] = { status: "ready", page }; },
|
||||
(e) => { catalog.albums[id] = { status: "error", page: null, error: String(e) }; },
|
||||
);
|
||||
}
|
||||
|
||||
/** Deezer answers a missing artist photo with a grey silhouette IMAGE under
|
||||
* the hash of the empty string (the metadata crate's
|
||||
* DEEZER_EMPTY_PICTURE_HASH), which reads as a light disc against the dark
|
||||
* UI. Treat it as no picture, so the app's own placeholder shows. */
|
||||
const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e";
|
||||
export function picture(url) {
|
||||
if (!url) return null;
|
||||
const u = String(url);
|
||||
return u.includes(DEEZER_EMPTY_PICTURE_HASH) || /\/images\/[a-z]+\/\//.test(u) ? null : u;
|
||||
}
|
||||
|
||||
/** "1.2M fans", the desktop's stat chip */
|
||||
export function fans(n) {
|
||||
if (!n) return "";
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M fans";
|
||||
if (n >= 1000) return Math.round(n / 1000) + "k fans";
|
||||
return n + " fans";
|
||||
}
|
||||
|
||||
export function yearOf(date) {
|
||||
const m = /^(\d{4})/.exec(String(date || ""));
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
/** Titles compared loosely: case, punctuation and bracketed suffixes
|
||||
* ("(Remastered)") do not make a different record. */
|
||||
export function looseTitle(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/\s*[([].*?[)\]]\s*/g, " ")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
Reference in new issue
Block a user