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

+160
View File
@@ -0,0 +1,160 @@
<script>
/* Community — the Soulseek side: rooms, chat, buddies. Signed out it is
the sign-in card; signed in it is the hub, the desktop's seats drawn
with the phone's components. The private layer (Spaces, holdings, the
sealed inbox) waits for the Freenet story on the phone. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
import { push, patch, openHalf } from "../nav.svelte.js";
import { player } from "../player.svelte.js";
import { ago } from "../util.js";
let { entry, screenEl = null } = $props();
const tab = $derived(entry.c.tab || "rooms");
let user = $state("");
let pass = $state("");
let showPass = $state(false);
$effect(() => { if (!user && slsk.savedUser) user = slsk.savedUser; });
const online = $derived(slsk.connection === "online");
const joinedNames = $derived(Object.keys(slsk.joined));
const otherRooms = $derived(slsk.rooms.filter((r) => !slsk.joined[r.name]).slice(0, 80));
const threads = $derived(
Object.entries(slsk.chats)
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
);
const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
async function signIn() {
if (!user.trim() || !pass) return;
await connect(user, pass);
if (slsk.connection !== "offline") pass = "";
}
async function open(room) {
if (!slsk.joined[room]) {
try { await joinRoom(room); } catch (e) { player.error = String(e); return; }
}
push("room", { name: room }, screenEl);
}
function thread(username) {
openThread(username);
push("thread", { username }, screenEl);
}
function status(st) {
if (st.status === 2 || st.status === "online") return "online";
if (st.status === 1 || st.status === "away") return "away";
return "offline";
}
</script>
<div class="phead">
<div class="row">
<h1>Community</h1>
{#if online}
<div class="acts">
<button class="iconbtn" onclick={refreshRooms} aria-label="Refresh rooms"><Icon name="refresh" size={21} /></button>
<button class="iconbtn muted" onclick={() => openHalf("community")} aria-label="More"><Icon name="more" size={21} /></button>
</div>
{/if}
</div>
{#if online}
<div class="online-line"><span class="dot"></span><span class="t-sub" style="font-weight:650">online as <b>{slsk.username}</b> · Soulseek</span></div>
{:else if slsk.connection === "connecting"}
<div class="online-line"><span class="dot warn"></span><span class="t-sub" style="font-weight:650">signing in…</span></div>
{/if}
</div>
{#if !online}
<div class="signin">
<div class="signin-t">Sign in to Soulseek</div>
<div class="signin-m">The network's rooms, its people and, next, its music. Your account is kept on this phone only.</div>
<input class="field" bind:value={user} placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false" enterkeyhint="next" />
<div class="field-row">
<input class="field" type={showPass ? "text" : "password"} bind:value={pass} placeholder="Password" autocomplete="current-password" enterkeyhint="go" onkeydown={(e) => { if (e.key === "Enter") signIn(); }} />
<button class="iconbtn sm muted" onclick={() => (showPass = !showPass)} aria-label="Show password"><Icon name="eye" size={18} /></button>
</div>
{#if slsk.loginError}<div class="signin-err">{slsk.loginError}</div>{/if}
<button class="btn btn-fill wide" style="height:46px" disabled={slsk.connection === "connecting" || !user.trim() || !pass} onclick={signIn}>
{slsk.connection === "connecting" ? "Signing in…" : "Sign in"}
</button>
{#if slsk.savedUser && slsk.hasPassword && slsk.connection === "offline"}
<button class="btn btn-ghost wide" style="margin-top:8px" onclick={connectStored}>Reconnect as {slsk.savedUser}</button>
{/if}
<div class="sec-note" style="padding: 12px 0 0">No account? Soulseek makes one from the first sign-in with a new name.</div>
</div>
<div class="stub">
<b>Spaces, holdings and the sealed inbox come later on the phone.</b>
They live on the private layer; the network side is here first.
</div>
{:else}
<div class="segtabs" style="padding-top:12px">
{#each [["rooms", "Rooms"], ["chat", "Chat"], ["buddies", "Buddies"]] as [k, l] (k)}
<button class:on={tab === k} onclick={() => patch({ tab: k })}>
{l}{#if k === "chat" && unreadTotal()} <span class="count">{unreadTotal()}</span>{/if}
</button>
{/each}
</div>
{#if tab === "rooms"}
{#if joinedNames.length}
<div class="sec" style="padding-top:8px"><h2>Joined <em>· {joinedNames.length}</em></h2></div>
{#each joinedNames as name (name)}
{@const r = slsk.joined[name]}
{@const last = r.messages[r.messages.length - 1]}
<button class="row" onclick={() => open(name)} oncontextmenu={(e) => { e.preventDefault(); leaveRoom(name); }}>
<span class="art hash"><Icon name="hash" size={20} /></span>
<span class="b">
<span class="t">{name}</span>
<span class="m">{last ? `${last.username}: ${last.message}` : `${r.users.length} here`}</span>
</span>
<span class="r"><span class="n">{r.users.length}</span><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
<div class="sec" style="padding-top:{joinedNames.length ? 18 : 8}px"><h2>Rooms <em>· {slsk.rooms.length} on the server</em></h2></div>
{#if !slsk.rooms.length}
<div class="sec-note">Asking the server for its rooms…</div>
{/if}
{#each otherRooms as r (r.name)}
<button class="row" onclick={() => open(r.name)}>
<span class="art hash"><Icon name="hash" size={20} /></span>
<span class="b"><span class="t">{r.name}</span><span class="m">{r.users} in the room</span></span>
<span class="r"><Icon name="plus" size={18} /></span>
</button>
{/each}
{:else if tab === "chat"}
{#if !threads.length}
<div class="stub"><b>No conversations yet.</b>A name in a room, or a buddy, opens one.</div>
{/if}
{#each threads as t (t.u)}
<button class="row" onclick={() => thread(t.u)}>
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
<span class="b">
<span class="t">{t.u}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
<span class="m">{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
</span>
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
</button>
{/each}
{:else if tab === "buddies"}
{#if !buddies.length}
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one.</div>
{/if}
{#each buddies as b (b.u)}
<button class="row" onclick={() => thread(b.u)}>
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
<span class="b">
<span class="t">{b.u}</span>
<span class="m">{status(b.st)}{b.st.file_count ? ` · ${b.st.file_count.toLocaleString()} files` : ""}</span>
</span>
<span class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
</button>
{/each}
{/if}
<div style="padding: 18px var(--pad) 0"><button class="btn btn-ghost wide" onclick={disconnect}>Sign out</button></div>
{/if}
<div class="endpad"></div>