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:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
/* The Soulseek hub on the phone: the connection, the rooms, the chats, the
buddies. The desktop's `slsk` store (state.svelte.js), the same events,
the same rules — the server echoes a room line back so it is not appended
locally; a direct message is not echoed so our half is. The private layer
(Freenet: Spaces, holdings, the sealed inbox) is not on the phone yet. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const slsk = $state({
connection: "offline", // offline | connecting | online
username: "",
loginError: "",
/** the saved username, for the sign-in card */
savedUser: "",
hasPassword: false,
/** [{ name, users }] — the server's public list */
rooms: [],
/** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */
joined: {},
/** username → { messages: [{ me, text, ts }], unread } */
chats: {},
buddies: [],
buddyState: {},
/** the room or the person whose screen is open, so its unread stays 0 */
openChat: null,
});
const FEED_MAX = 300;
function trim(arr) {
if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX);
}
export async function loadCredentials() {
try {
const c = await api.getCredentials();
slsk.savedUser = c?.username || "";
slsk.hasPassword = !!c?.has_password;
} catch { /* fine */ }
}
/** Sign in. The phone always remembers: there is no credential manager to
* ask, only the app's own sealed data directory. */
export async function connect(username, password) {
slsk.loginError = "";
slsk.connection = "connecting";
try {
await api.connectAndLogin(username.trim(), password, true);
slsk.savedUser = username.trim();
slsk.hasPassword = true;
} catch (e) {
slsk.connection = "offline";
slsk.loginError = String(e).replace(/^Error:\s*/, "");
}
}
/** At launch: the saved account signs in on its own, quietly. */
export async function connectStored() {
await loadCredentials();
if (!slsk.savedUser || !slsk.hasPassword) return;
slsk.connection = "connecting";
try {
await api.connectStored();
} catch (e) {
slsk.connection = "offline";
slsk.loginError = String(e).replace(/^Error:\s*/, "");
}
}
export async function disconnect() {
try { await api.disconnect(); } catch { /* fine */ }
slsk.connection = "offline";
slsk.joined = {};
}
export async function forgetAccount() {
try { await api.forgetCredentials(); } catch { /* fine */ }
slsk.savedUser = "";
slsk.hasPassword = false;
}
export function refreshRooms() {
api.slskRoomList().catch(() => {});
}
export async function joinRoom(name) {
await api.slskJoinRoom(name);
/* the roster arrives as soulseek:room-joined; nothing optimistic here */
}
export async function leaveRoom(name) {
await api.slskLeaveRoom(name);
delete slsk.joined[name];
}
/** The server echoes our line back to everyone, us included — send only. */
export async function sayRoom(room, text) {
const t = text.trim();
if (t) await api.slskSayRoom(room, t);
}
export function openThread(username) {
slsk.chats[username] ??= { messages: [], unread: 0 };
slsk.chats[username].unread = 0;
}
/** A direct message is not echoed back; our half is appended here. */
export async function sendDm(username, text) {
const t = text.trim();
if (!t) return;
await api.slskMessageUser(username, t);
openThread(username);
slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() });
trim(slsk.chats[username].messages);
}
export async function loadBuddies() {
try { slsk.buddies = (await api.buddyList()) || []; } catch { /* offline */ }
}
export async function addBuddy(username) {
slsk.buddies = (await api.buddyAdd(username)) || slsk.buddies;
}
export async function removeBuddy(username) {
slsk.buddies = (await api.buddyRemove(username)) || slsk.buddies;
delete slsk.buddyState[username];
}
export function unreadTotal() {
return Object.values(slsk.chats).reduce((n, c) => n + (c.unread || 0), 0);
}
let bound = false;
/** Subscribe to the hub's events. Returns the unlisteners. */
export async function bindSlsk() {
if (bound) return [];
bound = true;
const subs = [];
const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } };
await on("soulseek:state", (ev) => {
if (ev?.kind === "connected") {
slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser;
slsk.loginError = "";
void loadBuddies();
refreshRooms();
} else if (ev?.kind === "disconnected") {
slsk.connection = "offline";
}
});
await on("soulseek:room-list", (rooms) => {
slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));
});
await on("soulseek:room-joined", (p) => {
const prev = slsk.joined[p.room];
slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] };
});
await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; });
await on("soulseek:room-user", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
if (p.joined) {
if (!room.users.some((u) => u.username === p.user?.username)) room.users.push(p.user);
} else {
room.users = room.users.filter((u) => u.username !== p.username);
}
});
await on("soulseek:room-message", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() });
trim(room.messages);
});
await on("soulseek:private-message", (p) => {
const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 });
chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() });
trim(chat.messages);
if (slsk.openChat !== p.username) chat.unread += 1;
});
await on("soulseek:user-state", (p) => {
const s = (slsk.buddyState[p.username] ??= {});
Object.assign(s, p);
});
return subs;
}