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

+282
View File
@@ -0,0 +1,282 @@
<script>
/* SoulStream on a phone — the shell.
research/mobile-mockup's phone layout, wired to the real Rust core
over the same IPC the desktop uses: the library from library.db,
covers from the thumbnail cache, playback on the audio thread, the
lane from the app's own waveform decode. The page ground is the
artwork in play, blurred; the accent follows it, per screen (a record
page wears the record's colour). Five destinations; Home and Library
are real, Search and Community say what they will be. */
import { onMount, tick, untrack } from "svelte";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte";
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
import Library from "./screens/Library.svelte";
import Album from "./screens/Album.svelte";
import Artist from "./screens/Artist.svelte";
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
import You from "./screens/You.svelte";
import Transfers from "./screens/Transfers.svelte";
import { transfers, bindTransfers } from "./transfers.svelte.js";
import { bindHome } from "./home.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
import { art, wantArt, accentFor, applyAccent } from "./art.svelte.js";
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import * as sources from "../lib/sources.js";
import { greeting, drag } from "./util.js";
let screenEl = $state(null);
let miniArt = $state(null);
let scrolled = $state(0);
const entry = $derived(top());
const root = $derived(isRoot());
/* what the glass bar says, and which artwork the page wears */
const meta = $derived.by(() => {
const e = entry;
switch (e.n) {
case "home": return { title: greeting(), kicker: "Home" };
case "search": return { title: "Search", kicker: "Search" };
case "library": return { title: "Your library", kicker: "Library" };
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true };
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
}
case "artist": {
const as = albumsBy(e.c.name);
const cat = catalog.artists[artistKey(e.c.name)]?.page;
const p = cat?.banner || cat?.artist?.picture
|| as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null;
return { title: e.c.name, kicker: "Artist", amb: p, hero: true };
}
case "catalogAlbum": {
const p = catalog.albums[e.c.id]?.page;
return { title: p?.album?.title ?? e.c.title ?? "", kicker: p?.album?.artist ?? e.c.artist ?? "", amb: p?.album?.cover ?? null, flush: true };
}
case "playlist": {
const p = lib.playlists.find((x) => x.id === e.c.id);
return { title: p?.name ?? "Playlist", kicker: "Playlist", flush: true };
}
default: return { title: e.n, kicker: "" };
}
});
const groundPath = $derived(meta.amb ?? player.album?.art_path ?? null);
const trigger = $derived(meta.hero ? 210 : 52);
const barOn = $derived(!root || scrolled > trigger);
const titled = $derived(scrolled > trigger);
const playing = $derived(player.status === "playing" || player.status === "buffering");
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
/* ── the ground: two layers, the incoming one fades up over the old ── */
let ambA = $state(null);
let ambB = $state(null);
let lit = $state("b");
let ambCur = null;
$effect(() => {
const p = groundPath;
const url = p ? art[p] : null;
if (p && url === undefined) { wantArt(p); return; }
if (!url || url === ambCur) return;
ambCur = url;
untrack(() => {
if (lit === "a") { ambB = url; requestAnimationFrame(() => { lit = "b"; }); }
else { ambA = url; requestAnimationFrame(() => { lit = "a"; }); }
});
});
/* ── the card gets the cover once the thumbnail has landed ───────── */
$effect(() => {
const k = player.album?.art_path;
const u = k ? art[k] : "";
if (u) syncMedia();
});
/* ── the accent follows the same artwork ─────────────────────────── */
let accentReq = 0;
$effect(() => {
const p = groundPath;
/* a streamed cover lands late: read it here so the accent follows */
void (p ? art[p] : null);
const req = ++accentReq;
accentFor(p).then((c) => { if (req === accentReq) applyAccent(c); });
});
/* ── a new screen: scroll to where it was (a pop) or the top (a push) ── */
$effect(() => {
void nav.seq;
scrolled = 0;
tick().then(() => {
if (!screenEl) return;
const s = untrack(() => top().scroll) || 0;
screenEl.scrollTop = s;
scrolled = s;
});
});
/* ── the mini bar's gestures: swipe to skip, drag up to open ─────── */
let miniMoved = false;
const miniDrag = {
start: () => { miniMoved = false; },
move: (dx, dy, el) => {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) miniMoved = true;
if (Math.abs(dx) > Math.abs(dy)) el.style.transform = `translateX(${dx * 0.6}px)`;
else if (dy < 0) el.style.transform = `translateY(${Math.max(dy, -44) * 0.6}px)`;
},
end: (dx, dy, el) => {
el.style.transform = "";
if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
else if (dy < -34) openSheet(miniArt);
},
};
function onMiniTap() {
if (miniMoved) { miniMoved = false; return; }
openSheet(miniArt);
}
onMount(() => {
loadPrefs();
bootNav();
let unlisten = null;
bindEvents()
.then((u) => { unlisten = u; }, (e) => { player.error = String(e); })
.then(() => restoreSession())
.catch(() => {});
hooks.recentsDirty = refreshRecents;
bindMedia();
loadLibrary();
/* the network: subscribe first, then the saved account signs in on its own */
bindSlsk().then(() => connectStored()).catch(() => {});
bindNet();
watchLibrary();
bindTransfers();
bindHome();
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, next, prev, toggle, seekSecs, playQueueAt, patch };
return () => { if (unlisten) unlisten(); };
});
</script>
<div class="app" class:has-mini={!!player.track}>
<!-- the ground: the artwork in play, blurred, under everything -->
<div class="app-bg" aria-hidden="true">
<div class="amb" class:on={lit === "a"} style:background-image={ambA ? `url("${ambA}")` : null}></div>
<div class="amb" class:on={lit === "b"} style:background-image={ambB ? `url("${ambB}")` : null}></div>
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="pane">
<div class="viewport">
{#key nav.seq}
<div
class="screen tr-{nav.tr} stagger"
class:under-bar={!root && !meta.hero && !meta.flush}
class:has-hero={meta.hero}
bind:this={screenEl}
onscroll={(e) => { scrolled = e.currentTarget.scrollTop; }}
>
{#if entry.n === "home"}
<Home {screenEl} />
{:else if entry.n === "search"}
<Search {entry} {screenEl} />
{:else if entry.n === "library"}
<Library {entry} {screenEl} />
{:else if entry.n === "album"}
<Album id={entry.c.id} {screenEl} />
{:else if entry.n === "artist"}
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />
{:else if entry.n === "catalogAlbum"}
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
{:else if entry.n === "playlist"}
<Playlist id={entry.c.id} />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
<Room name={entry.c.name} {screenEl} />
{:else if entry.n === "thread"}
<Thread username={entry.c.username} {screenEl} />
{:else if entry.n === "you"}
<You />
{:else if entry.n === "transfers"}
<Transfers />
{:else}
<Stub kind={entry.n} />
{/if}
</div>
{/key}
</div>
<!-- the glass bar: GlassBar.svelte's recipe; anonymous until the
page's own large title has scrolled under it -->
<div class="gbar" class:on={barOn} class:titled>
<button class="iconbtn" style:visibility={root ? "hidden" : "visible"} onclick={back} aria-label="Back"><Icon name="chevron-left" size={24} /></button>
<span class="gb-t">{meta.title}{#if meta.kicker && meta.kicker !== meta.title}<small>{meta.kicker}</small>{/if}</span>
<button class="iconbtn muted" onclick={() => openHalf("more")} aria-label="More"><Icon name="more" size={21} /></button>
</div>
</div>
{#if player.track}
<div class="mini" class:hidden={nav.sheet} use:drag={miniDrag} onclick={onMiniTap} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Now playing">
<span class="art" bind:this={miniArt}><Cover path={player.album?.art_path} eager size={20} /></span>
<span class="b">
<span class="t">{player.track.title}</span>
{#if resolver.active && !player.track.stream}
<span class="m live">Finding the best source…</span>
{:else if player.track.stream && (player.phase === "hold" || player.phase === "loading")}
<span class="m live">Finding {player.track.stream.username}’s copy…</span>
{:else if player.track.stream}
<span class="m live">{player.track.artist || player.album?.artist || ""} · streaming from {player.track.stream.username}</span>
{:else}
<span class="m">{player.track.artist || player.album?.artist || ""}</span>
{/if}
</span>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); prev(); }} aria-label="Previous"><Icon name="skip-back" size={19} /></button>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); toggle(); }} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={21} /></button>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); next(); }} aria-label="Next"><Icon name="skip-forward" size={19} /></button>
<Lane class="mini-lane" height={16} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} {played} />
</div>
{/if}
<nav class="nav" class:gone={nav.sheet}>
{#each TABS as [id, icon, label] (id)}
<button class:on={nav.tab === id} onclick={() => go(id)}>
<span class="pill"></span>
<span class="gl"><Icon name={icon} size={22} /></span>
<span class="lb">{label}</span>
</button>
{/each}
</nav>
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} role="presentation"></div>
<HalfSheet />
<Sheet />
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
{/if}
</div>