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:
1 parent
46cf6a98fe
commit
95c2bfeeba
13 files changed
+633
-14
No files matched your search
+57
-2
@@ -352,6 +352,23 @@ function mockImport(cmd, args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* The walk's other device: a phone that has just installed the app. The
|
||||
mock is the whole backend on that leg, so it can answer as one — and
|
||||
the flag has to survive `location.reload()`, which is why it rides in
|
||||
the URL fragment as well as in localStorage: a file:// document's
|
||||
storage is not something the harness can lean on, and a fragment
|
||||
always comes back. */
|
||||
const fresh = (() => {
|
||||
try {
|
||||
if (location.hash.indexOf("fresh") >= 0) return true;
|
||||
} catch { /* no document */ }
|
||||
try {
|
||||
return localStorage.getItem("mockFresh") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const enabled = (() => {
|
||||
try {
|
||||
return localStorage.getItem("mockLibrary") === "1";
|
||||
@@ -367,6 +384,14 @@ export function mockInvoke(cmd, args) {
|
||||
command arm exactly the same way (see the doors below) */
|
||||
const forecast = weather(cmd);
|
||||
if (forecast) return forecast;
|
||||
/* a device that has just been installed: an empty database. Not an
|
||||
error and not a refusal — `pipeline::init` scans the sandbox root
|
||||
at startup and honestly finds nothing in it, so every one of these
|
||||
answers with an empty list. Everything else below answers exactly
|
||||
as it always did: this is the same mock with nothing in it yet. */
|
||||
if (fresh && (cmd === "library_albums" || cmd === "library_album_stats" || cmd === "recent_albums"
|
||||
|| cmd === "followed_artists" || cmd === "list_playlists" || cmd === "library_track_index"
|
||||
|| cmd === "library_track_search" || cmd === "contacts_meta_list" || cmd === "buddy_list")) return Promise.resolve([]);
|
||||
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
|
||||
if (cmd === "library_albums") return Promise.resolve(albums);
|
||||
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
||||
@@ -740,7 +765,7 @@ function mockCatalog(cmd, args) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
|
||||
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
|
||||
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: !fresh });
|
||||
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
|
||||
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
|
||||
/* the downloads notice: the mock walk cannot see the shade, so the calls
|
||||
@@ -762,6 +787,7 @@ let downloadSeq = 0;
|
||||
const mockSettings = {
|
||||
download_dir: "C:\\mock\\Library",
|
||||
volume_normalization: true,
|
||||
setup_done: true,
|
||||
library_layout: "{artist}\\{album}",
|
||||
shared_folders: [],
|
||||
upload_slots: 2,
|
||||
@@ -777,6 +803,18 @@ const mockSettings = {
|
||||
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
|
||||
download_source_prefs: { max: "any", min: "any", priority: "quality" },
|
||||
};
|
||||
/* the fresh device's own config: the library root Android actually hands
|
||||
a fresh install (the app's private sandbox — real, writable, and
|
||||
invisible to every other app), nothing shared, and the flag the tour
|
||||
stamps still unwritten. Mutated rather than replaced, so `set_settings`
|
||||
keeps working and a folder picked during the walk genuinely changes
|
||||
what the notes read. */
|
||||
if (fresh) Object.assign(mockSettings, {
|
||||
download_dir: "/data/user/0/com.soulstream.app/files/Library",
|
||||
setup_done: false,
|
||||
share_files: 0,
|
||||
shared_folders: [],
|
||||
});
|
||||
const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched
|
||||
const cacheSizes = new Map(); // path → the file's size, for stream_cache_len
|
||||
const preloading = new Set();
|
||||
@@ -786,7 +824,7 @@ let posTimer = null; // the fake engine's position ticks
|
||||
them — a declaration moves both, the way the real server does */
|
||||
const MY_INTERESTS = { likes: ["Daft Punk", "french house", "Warp Records"], hates: ["loudness war"] };
|
||||
function mockNet(cmd, args) {
|
||||
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
|
||||
if (cmd === "get_credentials") return Promise.resolve(fresh ? { username: "", has_password: false } : { username: MOCK_USER, has_password: true });
|
||||
if (cmd === "connect_stored" || cmd === "connect_and_login") {
|
||||
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
|
||||
return Promise.resolve();
|
||||
@@ -908,6 +946,23 @@ function mockNet(cmd, args) {
|
||||
return new Promise((r) => setTimeout(() => r(pool.slice(0, Math.min(pool.length, n + 1)).map((username, i) => ({ username, rating: Math.max(1, n - i) }))), 300));
|
||||
}
|
||||
if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 });
|
||||
/* Two the phone can reach that the mock never knew, found while
|
||||
mapping every api.js wrapper against it for this round: unmocked
|
||||
they fall through to the real IPC, which on a file:// page rejects —
|
||||
a console warning AND a toast, in a step that has nothing to do with
|
||||
either. The transport is answered the way the real core answers it,
|
||||
with an event and nothing else; `resume` cannot restart the position
|
||||
ticks (they live in the closure the play_* command opened), so the
|
||||
state is right and the position simply stands still. */
|
||||
if (cmd === "player_control") {
|
||||
const act = String(args?.action || "");
|
||||
if (act === "pause" || act === "stop") { clearInterval(posTimer); posTimer = null; }
|
||||
if (act === "pause") later(60, () => mockEmit("soulseek:player", { kind: "paused" }));
|
||||
if (act === "stop") later(60, () => mockEmit("soulseek:player", { kind: "stopped" }));
|
||||
if (act === "resume") later(60, () => mockEmit("soulseek:player", { kind: "playing" }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "open_external") return Promise.resolve();
|
||||
if (cmd === "preload_local") return Promise.resolve();
|
||||
if (cmd === "waveform") {
|
||||
const n = args?.buckets || 900;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user