android: the first run — a folder to keep music in, and an account to reach the network

Nobody had seen this app on a phone that has just installed it. On Android
the library root is never missing — config.rs falls back to the app's own
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
a null check, and setup_done has never been written by the phone, so every
phone in existence reads as unstamped, Kyle's included. The tour therefore
opens on three clauses, not one: no flag, no saved account, and no records,
read only after the library and the credentials have both answered. Two
cards, skippable, and no password anywhere in it — the Soulseek step goes
to the card on Community and stops.

Every screen that is empty because of those two things now says so with the
row that fixes it: Home (whose copy predated the folder picker), Library →
Albums, Library → Artists, which had no empty state at all, and Search,
where a flat sentence about signing in became the row that does it. A row
goes away because the config moved, so the two doors onto the same folder
tell each other — You re-reads when the tour moves it, the tour's store is
told when You's own picker does — and the all-files row re-reads the grant
when the system screen hands the app back, the way You already did.

You → This phone opens the tour again as a preview that reads the config
and writes nothing, which is the door the phone walk drives on Kyle's own
device. The mock can answer as a fresh install now — an empty database, no
account, no permission, the sandbox root — so the whole thing is walked end
to end, and two commands that had no mock at all (player_control,
open_external) got one on the way past. It is the mock walk's first reload,
which turned up two things a reload does not clear by itself: the player
writes the session back on pagehide, so the fresh block clears it from a
pagehide listener of its own, and the viewport emulation lives on the
DevTools session, so the harness hands it back after the re-attach.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:21:37 -05:00
1 parent 46cf6a98fe
commit 95c2bfeeba
13 files changed
+633 -14

No files matched your search

+34
View File
@@ -21,6 +21,7 @@
import { art, wantArt } from "./art.svelte.js";
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
import { setup, needsFolder, needsAccount, chooseFolder, requestAccess, finishSetup } from "./setup.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
@@ -778,6 +779,39 @@
{/each}
</div>
<div style="padding: 6px var(--pad) 16px"><button class="btn btn-ghost wide" onclick={act(() => { leaveRoom(shown.arg.name).catch(() => {}); back(); })}>Leave #{shown.arg.name}</button></div>
{:else if kind === "setup"}
<!-- the first run: one sheet, two cards, and a foot that closes
it. Every row here is a row that exists somewhere else —
the folder picker is You's, the sign-in is Community's —
because the tour's job is to say WHICH two things matter,
not to become a third place they can be changed. -->
<div class="setup">
<div class="sheet-title">{setup.reason === "preview" ? "Set up this phone" : "Welcome to SoulStream"}</div>
<div class="sec-note">Your library, the catalog and the network, in one place. Two things make the rest of it work — neither is required now, and both live on a screen you can come back to.</div>
<div class="sheet-sec">Where your music lives</div>
<div class="sec-note">One folder is the library: everything saved or downloaded lands in it, and everything in it is in the library. Until you pick one it is a folder inside the app that nothing else on this phone can see.</div>
<div class="setup-path">{setup.folder || "—"}</div>
<div class="sheet-rows">
{@render row("folder", setup.picking ? "Picking…" : needsFolder() ? "Choose a folder" : "Choose a different folder", needsFolder() ? "somewhere on this phone's own storage" : "this one is yours", () => void chooseFolder())}
{#if !needsFolder() && setup.access === false}
{@render row("alert", "Allow all files access", "a folder on the phone's storage needs it — the system screen opens, come back after", () => void requestAccess())}
{/if}
</div>
<div class="sheet-sec">Soulseek</div>
<div class="sec-note">Anything the library does not hold comes off the network: what Search finds, a song played from someone's copy, Save. The card asks for a username and a password and keeps them on this phone — nothing here does.</div>
{#if needsAccount()}
<div class="sheet-rows">
{@render row("users", "Sign in on Community", "the card is on the Community tab", act(() => go("community")))}
</div>
{:else}
<div class="sheet-info"><div class="kv"><span class="k">Signed in</span><span class="v">{slsk.username || slsk.savedUser}</span></div></div>
{/if}
<div class="setup-foot"><button class="btn btn-fill wide" onclick={() => void finishSetup()}>Start listening</button></div>
<div class="sec-note" style="padding-bottom:16px">Nothing above is required. You → Set up this phone again opens it back up.</div>
</div>
{:else if kind === "sleep"}
<div class="sheet-title">Sleep timer</div>
<div class="sheet-sec">Pause after</div>
+30 -3
View File
@@ -42,6 +42,7 @@
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 { setup, loadSetup, maybeOpenFirstRun, openSetup, finishSetup, setupState } from "./setup.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
@@ -159,6 +160,24 @@
which is why that record is a plain object. */
$effect(() => { syncShade(); });
/* ── the first run stamps when it goes away, whatever closed it ────
The foot's button calls finishSetup itself; the scrim, the back
gesture and the Soulseek row (which navigates, and navigating clears
the half sheet) do not — and a tour that ran is a tour that ran.
finishSetup writes only in boot mode and only once, so all four
doors land in the same place, and a PREVIEW opened from You writes
nothing at all.
untrack is not optional here: finishSetup calls closeHalf, which
reads nav.half, which is exactly what this effect tracks. Read your
own write in a Svelte 5 effect and the subtree dies painted. */
let setupWasUp = false;
$effect(() => {
const up = nav.half?.kind === "setup";
if (setupWasUp && !up) untrack(() => { void finishSetup(); });
setupWasUp = up;
});
/* ── a library read that failed gets a Try again ──────────────────
The library is the one thing every screen stands on, and a rejected
read left the app empty with a Rust string over it. The retry is wired
@@ -248,9 +267,17 @@
.catch(() => {});
hooks.recentsDirty = refreshRecents;
bindMedia();
loadLibrary();
/* the first run. The config is read at boot because the notes on
the screens that are empty BECAUSE of it read this store — and
the tour itself is offered only once the library and the saved
account have both answered. Asked any earlier they are `[]` and
`""` on every phone in existence, Kyle's included, and the sheet
that must never open on his would open on his. */
const configRead = loadSetup();
const libRead = loadLibrary();
/* the network: subscribe first, then the saved account signs in on its own */
bindSlsk().then(() => connectStored()).catch(() => {});
const netRead = bindSlsk().then(() => connectStored()).catch(() => {});
Promise.all([configRead, libRead, netRead]).then(() => maybeOpenFirstRun()).catch(() => {});
bindNet();
watchLibrary();
bindTransfers();
@@ -278,7 +305,7 @@
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* 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, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<script>
/* What a screen says when it is empty because of something the phone
has not been told yet — with the row that tells it. Only the things
actually missing are drawn, and nothing at all when both are
answered, so a phone that is set up never sees this component. It
does not ask for a password either: the Soulseek row goes to the
card on Community and stops there.
No props: it says the same thing on all four screens, because it is
the same thing. The stub above it is each screen's own words. */
import Icon from "../components/Icon.svelte";
import { go } from "./nav.svelte.js";
import { setup, needsFolder, needsAccount, chooseFolder } from "./setup.svelte.js";
const folder = $derived(needsFolder());
const account = $derived(needsAccount());
</script>
{#if folder || account}
<div class="stub setup-note">
<b>Two things make the rest of this work.</b>
<div class="setup-rows">
{#if folder}
<button class="row" onclick={() => void chooseFolder()}>
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t">{setup.picking ? "Picking…" : "Choose your library folder"}</span><span class="m">music on this phone lives there; right now it is inside the app</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
{#if account}
<button class="row" onclick={() => go("community")}>
<span class="lead"><Icon name="users" size={20} /></span>
<span class="b"><span class="t">Sign in on Soulseek</span><span class="m">search, streaming and Save all come off the network</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
</div>
</div>
{/if}
+3 -1
View File
@@ -11,6 +11,7 @@
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import * as api from "../../lib/api.js";
import { lib, newest, tracksOf } from "../lib.svelte.js";
import { art, visible } from "../art.svelte.js";
@@ -215,8 +216,9 @@
{:else if lib.loaded}
<div class="stub">
<b>Nothing in the library on this phone yet.</b>
Music the phone can play lives in the app's own Library folder for now; follow artists and Save records from the catalog, and this page fills up.
Save records from the catalog and follow the artists you collect, and this page fills up with what they release.
</div>
<SetupNote />
{/if}
{#if catalogDown}
+6
View File
@@ -4,6 +4,7 @@
survive a push and a pop. */
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte";
@@ -125,6 +126,7 @@
</div>
{#if !list.length}
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
<SetupNote />
{:else if view === "grid"}
<div class="grid2">
{#each list as a (a.id)}
@@ -142,6 +144,10 @@
{/if}
{:else if tab === "artists"}
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
{#if !arts.length}
<div class="stub"><b>No artists yet</b>Every artist with a record in the library on this phone is listed here.</div>
<SetupNote />
{/if}
{#each arts as ar (ar.name)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
+2 -1
View File
@@ -10,6 +10,7 @@
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import Cover from "../Cover.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
@@ -300,8 +301,8 @@
<div class="stub">
<b>The catalog, your library{online ? " and the network" : ""}.</b>
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if}
</div>
<SetupNote />
{#if lib.recents.length}
<div class="sec"><h2>Jump back in <em>· picks up where you left off</em></h2></div>
<div class="rail">
+9 -2
View File
@@ -9,6 +9,7 @@
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
import { setup, openSetup, loadSetup, isPrivateFolder } from "../setup.svelte.js";
import { refreshSourcePrefs } from "../../lib/sources.js";
import { devBuild } from "../util.js";
@@ -16,7 +17,7 @@
let scanning = $state(false);
/* the library root the backend could not reach, out of its refusal */
let folderGone = $state("");
$effect(() => { api.getSettings().then((s) => { cfg = s; }, () => {}); });
$effect(() => { void setup.folder; 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);
@@ -36,7 +37,7 @@
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));
const privateFolder = $derived(isPrivateFolder(cfg?.download_dir));
let picking = $state(false);
async function pickFolder() {
if (picking) return;
@@ -46,6 +47,7 @@
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({ downloadDir: r.path });
cfg = await api.getSettings();
void loadSetup();
await rescan();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
@@ -375,6 +377,11 @@
<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>
<button class="row" onclick={() => openSetup("preview")}>
<span class="lead"><Icon name="sparkle" size={20} /></span>
<span class="b"><span class="t">Set up this phone again</span><span class="m">the first run — where your music lives, and the network; nothing changes by looking</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if devBuild}
<button class="row" onclick={() => push("diag", {})}>
<span class="lead"><Icon name="devices" size={20} /></span>
+169
View File
@@ -0,0 +1,169 @@
/* The first run.
What a phone that has just installed the app actually holds: a config
file that does not exist yet, an empty database, no Soulseek account —
and a library root that is NOT missing. `config.rs`'s `download_dir`
falls back on Android to `app_data_dir()/Library`, a real, writable
folder inside the app's own sandbox that nothing else on the phone can
see, and it returns before the desktop's audio_dir() fallback is even
reached. So the root is never null and "no folder yet" can never be a
null check anywhere: the test is the PATH (isPrivateFolder), which
You.svelte carried first and now imports from here, so there is one of
it rather than two drifting apart.
Two things the app genuinely cannot work without, and both are already
fixable on a screen that exists: somewhere to keep music (You's folder
picker and the all-files permission beside it) and, for anything that
touches the network, a Soulseek sign-in (Community's card). This store
knows which of the two are still missing, opens the sheet that says so,
and stamps `setup_done` once the tour has run. It never handles a
password: the Soulseek step navigates to the card and stops there. */
import * as api from "../lib/api.js";
import { openHalf, closeHalf } from "./nav.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { lib, loadLibrary } from "./lib.svelte.js";
import { player } from "./player.svelte.js";
/** The sandbox root Android hands a fresh install —
* `/data/user/0/com.soulstream.app/files/Library`, or `/data/data/…` on an
* older device. A real folder, and an invisible one, which is why it reads
* as "no folder picked yet". A string match is all there is: change the
* package id and every phone silently reads as set up. */
export function isPrivateFolder(dir) {
return !dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(dir);
}
export const setup = $state({
/** the config has been READ — not that it said anything in particular */
loaded: false,
/** "boot" = the tour opened itself · "preview" = You opened it ·
* "shown" = it has already stamped and must not stamp again */
reason: "boot",
folder: "",
private: true,
access: null, // all-files access: true | false | null (never asked)
done: false, // the config's setup_done
picking: false,
/** stamps this store has written. A preview must leave it at zero — it
* is the whole proof that the door in You is not a door onto the
* config, and the phone walk asserts it on Kyle's own phone. */
writes: 0,
});
/** Still the app's own sandbox folder. */
export function needsFolder() {
return setup.loaded && setup.private;
}
/** Signed out, with no saved account to sign in with. Gated on the
* credentials having been read: before that every phone looks signed out,
* and a note that flashes on a set-up phone is a lie with good timing. */
export function needsAccount() {
return slsk.credsRead && slsk.connection !== "online" && !slsk.savedUser;
}
/** What the walk reads back: "<folder>:<account>:<flag>". */
export function setupState() {
return (setup.private ? "private" : "chosen")
+ ":" + (needsAccount() ? "out" : "in")
+ ":" + (setup.done ? "done" : "todo");
}
export async function loadSetup() {
let cfg = null;
try { cfg = await api.getSettings(); } catch { return; }
setup.folder = cfg?.download_dir || "";
setup.private = isPrivateFolder(cfg?.download_dir);
setup.done = !!cfg?.setup_done;
setup.loaded = true;
await refreshAccess();
}
/** All-files access: true, false, or null where the phone never asked
* (an old device, or the desktop). A failed read leaves the last answer
* standing rather than turning the row back on. */
async function refreshAccess() {
try { setup.access = (await api.allFilesAccess())?.granted ?? null; } catch { /* keep what we had */ }
}
/** Ask for it. The system answers by taking the app away and giving it
* back, so the grant is re-read on the way back — You.svelte watches
* visibility for exactly this reason, and without it the row stays on
* screen offering a permission that has already been given. */
export async function requestAccess() {
try { await api.requestAllFilesAccess(); } catch (e) { player.error = String(e); return; }
const back = () => {
if (document.hidden) return;
document.removeEventListener("visibilitychange", back);
void refreshAccess();
};
document.addEventListener("visibilitychange", back);
}
/** The tour opened by hand — You's "Set up this phone again". A preview:
* it reads the real state and writes nothing, which is what makes it
* something the phone walk can drive on Kyle's own device. */
export function openSetup(reason = "preview") {
setup.reason = reason;
void loadSetup();
openHalf("setup");
}
/** The boot gate: three clauses, ALL of them required.
*
* The desktop gates on `setup_done` alone (App.svelte), and that alone
* would greet Kyle with a setup sheet on a phone full of his own music —
* the phone has never written the flag, and `config::setup_done` unwraps a
* missing field to false, so every phone in existence reads as unstamped.
* A saved account, or one record in the library, says this is not a fresh
* install whatever the flag says.
*
* Both of those are read from the backend, so the caller has to let
* `loadLibrary()` and `connectStored()` settle first: asked early they are
* `[]` and `""` on every phone there is, and the gate would open on all of
* them. */
export async function maybeOpenFirstRun() {
if (!setup.loaded) await loadSetup();
if (!setup.loaded) return false;
if (setup.done || slsk.savedUser || lib.albums.length) return false;
setup.reason = "boot";
openHalf("setup");
return true;
}
/** The library folder, picked from the tour — You's own three calls: pick,
* save, rescan. Keeping the private folder is a legitimate answer, so
* nothing here is required and a cancelled picker is not an error. */
export async function chooseFolder() {
if (setup.picking) return;
setup.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({ downloadDir: r.path });
await loadSetup();
await api.rescanLibrary();
await loadLibrary();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally {
setup.picking = false;
}
}
/** Close the tour and remember that it ran — whichever door closed it: the
* foot's button, the scrim, the back gesture, or a row that navigated away
* (a tour that ran is a tour that ran). `reason` moves to "shown" BEFORE
* the await, so two doors closing in the same tick still stamp once.
*
* A preview writes nothing at all. The write is best effort otherwise: a
* failed save leaves the flag false and the tour comes back on the next
* launch, which is the honest outcome — nothing else depends on it. */
export async function finishSetup() {
closeHalf();
if (setup.reason !== "boot") return;
setup.reason = "shown";
try {
await api.setSettings({ setupDone: true });
setup.done = true;
setup.writes++;
} catch { /* see above */ }
}
+26
View File
@@ -1563,3 +1563,29 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
(`.m mono`, which .row does not give a face — leave that as it is rather
than making these two rows disagree). */
.row.folder-gone .t { color: #ffb4b4; }
/* ── the first run ───────────────────────────────────────────────────────
A phone that has just installed the app: the tour's sheet, and the note
the screens that are empty BECAUSE of it carry. Both are the app's own
parts — .sheet-sec, .sheet-rows, .row, .stub — so there is almost nothing
here: the folder's path, the foot's button, and the note's rows pulled
out to the card's own edge so they read as part of the message rather
than as a second card floating under it. */
.setup-path {
padding: 0 var(--pad) 10px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11.5px;
color: var(--text-muted);
word-break: break-all;
}
.setup-foot { padding: 10px var(--pad) 4px; }
/* the note: one message, with the rows that answer it under a hairline */
.setup-note .setup-rows {
margin: 12px -16px 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.setup-note .setup-rows .row { padding: 10px 16px; }
.setup-note .setup-rows .row + .row { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); }
.setup-note .setup-rows .row .t { font-size: 13.5px; }
.setup-note .setup-rows .row .m { font-size: 12px; }
+7
View File
@@ -13,6 +13,8 @@ export const slsk = $state({
/** the saved username, for the sign-in card */
savedUser: "",
hasPassword: false,
/** the credentials have been READ — see loadCredentials */
credsRead: false,
/** [{ name, users }] — the server's public list */
rooms: [],
/** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */
@@ -45,6 +47,11 @@ export async function loadCredentials() {
slsk.savedUser = c?.username || "";
slsk.hasPassword = !!c?.has_password;
} catch { /* fine */ }
/* read, whatever it said. "Signed out" is a fact the first run has to
be able to state, and before this resolves every phone in the world
merely LOOKS signed out — a note that says so then is a lie with
good timing. */
slsk.credsRead = true;
}
/** Sign in. The phone always remembers: there is no credential manager to