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.
280 lines
13 KiB
JavaScript
280 lines
13 KiB
JavaScript
/* The phone's resolver: a catalog track or record → a peer's copy, through
|
|
the desktop's own source rules (src/lib/sources.js — one copy, shared).
|
|
The order is the desktop's: the library first, then the remembered source
|
|
if its bytes are still complete in the cache, then the network — the
|
|
query ladder, the candidates, and up to four peers tried in turn.
|
|
|
|
A record queues every track from the ONE folder that wins. A song played
|
|
off a record queues the rest of the record behind it as UNRESOLVED
|
|
entries, each finding its own copy when its turn comes (the player calls
|
|
hooks.resolveEntry). Failover lives in the player: an entry carries its
|
|
runner-up peers in `t.alts`, and a stream that has not sent a byte in
|
|
time moves to the next one. */
|
|
import * as api from "../lib/api.js";
|
|
import {
|
|
resolveSources, songCandidates, albumCandidates, simplifyTitle, albumQueryTitle,
|
|
cachedStreamSourceFor, rememberStreamSource, catalogTrackFor, cleanTrackName,
|
|
hqCover, qualityOf, refreshSourcePrefs, libraryAlbumIn, libraryTrackIn,
|
|
} from "../lib/sources.js";
|
|
import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
|
|
import { lib, tracksOf } from "./lib.svelte.js";
|
|
import { slsk } from "./slsk.svelte.js";
|
|
import { yearOf } from "./catalog.svelte.js";
|
|
|
|
export const resolver = $state({ active: false, label: "", error: "" });
|
|
|
|
void refreshSourcePrefs();
|
|
|
|
const online = () => slsk.connection === "online";
|
|
const OFFLINE = "Sign in on Community to play from the network.";
|
|
|
|
/* ── shapes ────────────────────────────────────────────────────────── */
|
|
/** The record a streamed entry belongs to, as the player's `album`. No
|
|
* library id: the catalog id rides in `catalogId`, and the cover URL is the
|
|
* art key (art.svelte.js routes https through the catalog art proxy). */
|
|
function albumMeta(o, artist) {
|
|
return {
|
|
id: null,
|
|
catalogId: o.albumId ?? null,
|
|
title: o.album ?? null,
|
|
artist: o.albumArtist ?? artist,
|
|
year: o.year ?? null,
|
|
art_path: hqCover(o.cover) ?? null,
|
|
};
|
|
}
|
|
function streamEntry(title, artist, src, o, album) {
|
|
return {
|
|
t: {
|
|
id: o.trackId != null ? "cat:" + o.trackId : "stream:" + src.username + "|" + src.path,
|
|
title,
|
|
artist,
|
|
path: src.path,
|
|
duration_secs: src.duration ?? o.expectSecs ?? null,
|
|
stream: { username: src.username, size: src.size },
|
|
catalog: { trackId: o.trackId ?? null, albumId: o.albumId ?? null, expectSecs: o.expectSecs ?? null },
|
|
alts: [],
|
|
},
|
|
album,
|
|
};
|
|
}
|
|
/** The rest of a record, unresolved: a path-less entry the player resolves
|
|
* on arrival. */
|
|
function lazyEntries(rest, artist, o, album) {
|
|
return (rest || []).map((ct) => ({
|
|
t: {
|
|
id: "cat:" + ct.id,
|
|
title: ct.title,
|
|
artist: ct.artist || artist,
|
|
path: null,
|
|
duration_secs: ct.duration_secs ?? null,
|
|
stream: null,
|
|
catalog: { trackId: ct.id ?? null, albumId: o.albumId ?? null, expectSecs: ct.duration_secs ?? null },
|
|
alts: [],
|
|
},
|
|
album,
|
|
}));
|
|
}
|
|
const alt = (c) => ({ username: c.username, path: c.file.path, size: c.file.size, duration: c.file.duration ?? null });
|
|
function remember(e, quality) {
|
|
rememberStreamSource({ stream: e.t.stream, path: e.t.path, title: e.t.title, artist: e.t.artist, trackId: e.t.catalog?.trackId ?? null, duration: e.t.duration_secs, quality });
|
|
}
|
|
|
|
/* the bar says what is being looked for while the ladder runs */
|
|
function placeholder(title, artist, album) {
|
|
player.track = { id: "resolving", title, artist, path: null, stream: null };
|
|
player.album = album;
|
|
player.position = 0;
|
|
player.duration = null;
|
|
player.status = "buffering";
|
|
player.phase = "loading";
|
|
player.error = "";
|
|
}
|
|
function fail(msg) {
|
|
resolver.error = msg;
|
|
player.error = msg;
|
|
if (player.track?.id === "resolving") { player.track = null; player.status = "idle"; player.phase = "ready"; }
|
|
}
|
|
|
|
/* ── a song ────────────────────────────────────────────────────────── */
|
|
/** Play a catalog track: `o` carries what the row knows — trackId, albumId,
|
|
* album (title), cover, expectSecs, year, and `rest` (the record's later
|
|
* tracks, queued behind it). */
|
|
export async function streamSong(artist, title, o = {}) {
|
|
if (resolver.active) return;
|
|
const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title);
|
|
if (local) {
|
|
await playAlbum(local.album, local.tracks, local.index);
|
|
return;
|
|
}
|
|
const album = albumMeta(o, artist);
|
|
const from = album.title || artist;
|
|
const cached = await cachedStreamSourceFor(o.trackId ?? null, artist, title);
|
|
if (cached) {
|
|
await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from);
|
|
return;
|
|
}
|
|
if (!online()) { fail(OFFLINE); return; }
|
|
placeholder(title, artist, album);
|
|
const { responses, artistIncluded } = await resolveSources(
|
|
`${artist} — ${title}`,
|
|
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
|
|
(rs) => songCandidates(rs, title, true, o.album, o.expectSecs, artist).length,
|
|
artist,
|
|
resolver,
|
|
);
|
|
const cands = songCandidates(responses, title, true, o.album, o.expectSecs, artist, !artistIncluded);
|
|
if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; }
|
|
const [best, ...alts] = cands.slice(0, 4);
|
|
const e = streamEntry(title, artist, alt(best), o, album);
|
|
e.t.alts = alts.map(alt);
|
|
remember(e, qualityOf(best.file));
|
|
await playEntries([e, ...lazyEntries(o.rest, artist, o, album)], 0, from);
|
|
}
|
|
|
|
/* ── a record ──────────────────────────────────────────────────────── */
|
|
/** Play a catalog record from track `at`: the library's copy if it holds
|
|
* one, else the best folder on the network, every track from that one
|
|
* peer, the runner-up folders as the first track's failover. */
|
|
export async function streamCatalogAlbum(album, at = 0) {
|
|
if (resolver.active) return;
|
|
const local = libraryAlbumIn(lib.albums, album.artist, album.title, album.release_date ?? null);
|
|
if (local) {
|
|
const tracks = await tracksOf(local.id).catch(() => []);
|
|
if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; }
|
|
}
|
|
if (!online()) { fail(OFFLINE); return; }
|
|
const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist);
|
|
placeholder(album.title, album.artist, meta);
|
|
const catalogTracks = album.id
|
|
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
|
|
: null;
|
|
const trackCount = catalogTracks?.length || album.track_count || null;
|
|
if (trackCount === 1) {
|
|
const t = catalogTracks?.[0];
|
|
player.track = null;
|
|
await streamSong(album.artist, t?.title ?? album.title, {
|
|
cover: t?.cover ?? album.cover, trackId: t?.id ?? null, albumId: album.id ?? null,
|
|
album: album.title || null, expectSecs: t?.duration_secs ?? null, year: meta.year,
|
|
});
|
|
return;
|
|
}
|
|
const { responses, artistIncluded } = await resolveSources(
|
|
`${album.artist} — ${album.title}`,
|
|
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
|
(rs) => albumCandidates(rs, album, true, trackCount).length,
|
|
album.artist,
|
|
resolver,
|
|
);
|
|
const folders = albumCandidates(responses, album, true, trackCount, !artistIncluded);
|
|
if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; }
|
|
const c = folders[0];
|
|
const entries = c.audio.map((f, i) => {
|
|
const ct = catalogTrackFor(f, i, c.audio, catalogTracks);
|
|
const o = { trackId: ct?.id ?? null, albumId: album.id ?? null, expectSecs: ct?.duration_secs ?? null };
|
|
const e = streamEntry(ct?.title ?? cleanTrackName(f.name), album.artist, { username: c.username, path: f.path, size: f.size, duration: f.duration ?? null }, o, meta);
|
|
remember(e, qualityOf(f));
|
|
return e;
|
|
});
|
|
const start = Math.min(Math.max(0, at), entries.length - 1);
|
|
entries[start].t.alts = folders.slice(1, 4).map((g) => {
|
|
const f = g.audio[start] || g.audio[0];
|
|
return { username: g.username, path: f.path, size: f.size, duration: f.duration ?? null };
|
|
});
|
|
await playEntries(entries, start, album.title);
|
|
}
|
|
|
|
/* ── saving ────────────────────────────────────────────────────────── */
|
|
/** Fetch a record into the library: the best folder by the DOWNLOAD rules
|
|
* (quality first, a queue is fine), every file in it — the pipeline files
|
|
* art and logs beside the finished album and says soulseek:library-changed.
|
|
* Returns { username, files } or false. */
|
|
export async function saveCatalogAlbum(album) {
|
|
if (resolver.active) return false;
|
|
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
|
|
const catalogTracks = album.id
|
|
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
|
|
: null;
|
|
const trackCount = catalogTracks?.length || album.track_count || null;
|
|
if (trackCount === 1) {
|
|
const t = catalogTracks?.[0];
|
|
return saveSong(album.artist, t?.title ?? album.title);
|
|
}
|
|
const { responses, artistIncluded } = await resolveSources(
|
|
`${album.artist} — ${album.title}`,
|
|
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
|
(rs) => albumCandidates(rs, album, false, trackCount).length,
|
|
album.artist,
|
|
resolver,
|
|
);
|
|
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
|
|
if (!best) {
|
|
resolver.error = `No album sources found for “${album.title}”`;
|
|
player.error = resolver.error;
|
|
return false;
|
|
}
|
|
for (const f of best.files) await api.downloadFile(best.username, f.path, f.size);
|
|
return { username: best.username, files: best.files.length };
|
|
}
|
|
/** One song into the library, by the download rules. */
|
|
export async function saveSong(artist, title) {
|
|
if (resolver.active) return false;
|
|
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
|
|
const { responses, artistIncluded } = await resolveSources(
|
|
`${artist} — ${title}`,
|
|
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
|
|
(rs) => songCandidates(rs, title, false, null, null, artist).length,
|
|
artist,
|
|
resolver,
|
|
);
|
|
const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0];
|
|
if (!best) {
|
|
resolver.error = `No sources found for “${title}”`;
|
|
player.error = resolver.error;
|
|
return false;
|
|
}
|
|
await api.downloadFile(best.username, best.file.path, best.file.size);
|
|
return { username: best.username, files: 1 };
|
|
}
|
|
|
|
/* ── an entry's turn ───────────────────────────────────────────────── */
|
|
/** Fill in a path-less catalog entry when the queue reaches it: the
|
|
* library, the cache, then a search of its own. Returns whether it can
|
|
* play. Mutates the entry (a queue proxy) in place. */
|
|
async function resolveEntry(e, quiet = false) {
|
|
const { title, artist, catalog } = e.t;
|
|
const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title);
|
|
if (local) {
|
|
const t = local.tracks[local.index];
|
|
e.t.path = t.path;
|
|
e.t.duration_secs = t.duration_secs ?? e.t.duration_secs;
|
|
e.t.stream = null;
|
|
return true;
|
|
}
|
|
const cached = await cachedStreamSourceFor(catalog?.trackId ?? null, artist, title);
|
|
if (cached) {
|
|
e.t.path = cached.path;
|
|
e.t.stream = { username: cached.username, size: cached.size };
|
|
e.t.duration_secs = cached.duration ?? e.t.duration_secs;
|
|
return true;
|
|
}
|
|
if (!online()) return false;
|
|
const { responses, artistIncluded } = await resolveSources(
|
|
`${artist} — ${title}`,
|
|
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
|
|
(rs) => songCandidates(rs, title, true, e.album?.title, catalog?.expectSecs, artist).length,
|
|
artist,
|
|
quiet ? null : resolver,
|
|
);
|
|
const cands = songCandidates(responses, title, true, e.album?.title, catalog?.expectSecs, artist, !artistIncluded);
|
|
if (!cands.length) return false;
|
|
const [best, ...alts] = cands.slice(0, 4);
|
|
const src = alt(best);
|
|
e.t.path = src.path;
|
|
e.t.stream = { username: src.username, size: src.size };
|
|
e.t.duration_secs = src.duration ?? e.t.duration_secs;
|
|
e.t.alts = alts.map(alt);
|
|
remember(e, qualityOf(best.file));
|
|
return true;
|
|
}
|
|
hooks.resolveEntry = resolveEntry;
|