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

+124
View File
@@ -0,0 +1,124 @@
<script>
/* You — Appearance (real, kept on the device) and what this phone is
running. Playback, storage and the network settings come next. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { lib, loadLibrary } from "../lib.svelte.js";
import { push } from "../nav.svelte.js";
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
let cfg = $state(null);
let scanning = $state(false);
$effect(() => { api.getSettings().then((s) => { cfg = s; }, () => {}); });
/* the desktop's Playback setting: every track brought to −14 LUFS (the
player crate's BS.1770 meter; on by default) */
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
async function toggleNormalization() {
const next = !normalization;
try { await api.setSettings({ volume_normalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
/* the library folder: picked on the phone, set as the app's download
dir (the folder IS the library), rescanned. A folder in shared storage
needs all-files access, granted by Kyle on the system screen. */
let access = $state(null);
async function checkAccess() { try { access = (await api.allFilesAccess())?.granted ?? null; } catch { access = null; } }
/* re-checked when the app comes back from the system's permission screen */
$effect(() => {
checkAccess();
const onShow = () => { if (!document.hidden) checkAccess(); };
document.addEventListener("visibilitychange", onShow);
return () => document.removeEventListener("visibilitychange", onShow);
});
const privateFolder = $derived(!cfg?.download_dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(cfg.download_dir));
let picking = $state(false);
async function pickFolder() {
if (picking) return;
picking = true;
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
await api.setSettings({ download_dir: r.path });
cfg = await api.getSettings();
await rescan();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally { picking = false; }
}
async function rescan() {
if (scanning) return;
scanning = true;
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
scanning = false;
}
const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
["motion", "layers", "Motion", "springs on screens, sheets and the nav"],
["glass", "display", "Glass blur", "the desktop's frosted chrome — costs frames on this phone"],
];
</script>
<div class="phead"><div class="row"><h1>You</h1></div></div>
<div class="sheet-sec" style="padding-top: 4px">Appearance</div>
<div class="group">
{#each ROWS as [k, icon, t, m] (k)}
<button class="row" onclick={() => setSetting(k, !settings[k])}>
<span class="lead"><Icon name={icon} size={20} /></span>
<span class="b"><span class="t">{t}</span><span class="m">{m}</span></span>
<span class="sw" class:on={settings[k]}><span></span></span>
</button>
{/each}
</div>
<div class="sheet-sec">Playback</div>
<div class="group">
<button class="row" onclick={toggleNormalization}>
<span class="lead"><Icon name="volume" size={20} /></span>
<span class="b"><span class="t">Volume normalization</span><span class="m">every track at about the same loudness (−14 LUFS); a track is measured on its first play and matched from then on</span></span>
<span class="sw" class:on={normalization}><span></span></span>
</button>
</div>
<div class="sheet-sec">Library</div>
<div class="group">
<button class="row" onclick={pickFolder}>
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t">{picking ? "Picking…" : "Library folder"}</span><span class="m mono">{cfg?.download_dir || "—"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if !privateFolder && access === false}
<button class="row" onclick={() => api.requestAllFilesAccess().catch((e) => { player.error = String(e); })}>
<span class="lead"><Icon name="alert" size={20} /></span>
<span class="b"><span class="t">Allow all files access</span><span class="m">a folder on the phone's storage needs it — the system screen opens, come back after</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
<div class="kv"><span class="k">Records</span><span class="v">{lib.albums.length}</span></div>
<div class="kv"><span class="k">Playlists</span><span class="v">{lib.playlists.length}</span></div>
<button class="row" onclick={rescan}>
<span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
</button>
<button class="row" onclick={() => push("transfers", {})}>
<span class="lead"><Icon name="download" size={20} /></span>
<span class="b"><span class="t">Transfers</span><span class="m">{active().length ? `${active().length} in flight` : Object.keys(transfers.items).length ? "nothing in flight" : "what Save is fetching into the library"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
</div>
<div class="sheet-sec">This phone</div>
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
</div>
<div class="stub">
<b>Playback, storage, the network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components; the library folder you pick and Import land with them.
</div>
<div class="endpad"></div>