diff --git a/ANDROID.md b/ANDROID.md index c773332..de5eb01 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -473,6 +473,119 @@ Not on the phone yet: the playlist-file door and export — both want a file picker the storage plugin has still to grow — the listener door (it needs yt-dlp, a desktop tool), drag reorder, and Share on SoulStream. +## The first run + +Every walk and every deploy so far started from Kyle's own phone — signed +in, library folder set, a config full of his settings. What a phone that +has just installed the app actually has is different in three ways, and +only one of them is what you would guess. + +**The library root is never missing.** `config.rs`'s `download_dir` has an +`#[cfg(target_os = "android")]` branch that falls back to +`app_data_dir()/Library` and returns before the desktop's `audio_dir()` +fallback is reached, so a fresh install has a real, writable, empty library +root at `/data/user/0/com.soulstream.app/files/Library` — invisible to +every other app on the phone, including the one that put the music there. +`pipeline::init` scans it at startup and `library_albums` honestly answers +`[]`. This is why "no folder yet" cannot be a null check anywhere: the test +is the PATH, `isPrivateFolder` in `setup.svelte.js`, which You.svelte had +first and now imports rather than keeping a second copy of. + +**`setup_done` was already there and the phone never touched it.** It +round-trips through `get_settings`/`set_settings` like any other field and +`config::setup_done` unwraps a missing one to `false`, so **every phone in +existence reads as unstamped**, Kyle's included. Gating the tour on that +flag alone — which is what the desktop does — would have opened a setup +sheet on a phone already full of his own music, and then written the flag +into his config on dismissal. + +**Nothing is signed in, and everything says so honestly and helpfully +nowhere.** `connectStored()` returns at once with no account saved, the +connection stays `offline`, and every stream and every Save ends as a red +toast with a sentence in it and no way out. + +So: `src/mobile/setup.svelte.js` is the one store, and there are two +surfaces. + +**The tour** is a half sheet (`kind === "setup"` in `HalfSheet.svelte`) — +one scroll, two cards, a foot. Where your music lives: the current folder, +a row that opens the storage plugin's folder picker, and the all-files +permission row beside it once a real folder has been picked. Soulseek: what +it unlocks, and one row that goes to the sign-in card on Community. **The +tour never handles a password** — there is no field in it; Kyle signs in +himself, on the card that already existed. Keeping the private folder and +staying signed out are both legitimate answers, so the sheet is answerable +by doing nothing, and the scrim and the back gesture close it like any +other half sheet. + +**The boot gate is a three-way AND** — `!setup_done && !slsk.savedUser && +!lib.albums.length` — and it is evaluated only after `loadLibrary()` and +`connectStored()` have both settled (`Promise.all` in `Mobile.svelte`'s +`onMount`). Read either of those early and they are `[]` and `""` on every +phone there is. Kyle's phone fails the second and third clause, which is +the only safe way to ship an auto-opening layer into a pipeline that +installs onto his device. + +**The note** (`SetupNote.svelte`) is the fallback for the screens that are +empty because of the same two things, with the row that fixes each: Home's +empty state (whose copy still said music "lives in the app's own Library +folder for now", written before the picker landed in You), Library → +Albums, Library → **Artists** — which had no empty state at all, just a +`0 artists · 0 followed` line over nothing — and Search's empty page, where +"Sign in on Community to play what the library does not hold" was a flat +sentence and is now the row that does it. A row is drawn only for a thing +actually missing, and the component renders nothing when both are answered, +so a phone that is set up never sees it. + +There are two doors onto the same folder — the tour's row and You's own +picker — and they tell each other: You re-reads when `setup.folder` moves, +and its picker calls `loadSetup()` on the way past, or the note on Home +would go on offering a folder that had already been picked on You. The +all-files row does the same trick You does with the grant: the system +screen takes the app away and gives it back, so `requestAccess` re-reads +the answer on the way back rather than leaving a row up that has been +answered. + +**The preview door** is one row in You → This phone: "Set up this phone +again", `openSetup("preview")`. A preview reads the config and writes +nothing — `setup.writes`, the number of times the store has stamped +`setup_done`, stays where it found it. The phone walk drives exactly that +door on Kyle's phone and asserts the counter at zero on the way in and on +the way out; it types nothing, picks nothing and clears nothing. + +The harness hosts the other device. `localStorage.mockFresh`, or `#fresh` +in the URL (which is what the walk uses — a reload cannot lose a fragment), +switches `src/lib/mockLibrary.js` to a fresh install: every library +command answers `[]`, `get_credentials` answers with no account, +`all_files_access` is not granted, and `mockSettings` is mutated in place +so the library root is the sandbox path and `setup_done` is false — +mutated rather than replaced so `set_settings` still works and picking a +folder in the walk genuinely moves the state the notes read. The +fresh-install block is the LAST thing in `MOCK_STEPS` because its reload +wipes every module's state, and it turns the flag off and reloads again at +the end so that anything appended after it is not silently running against +an empty device. + +Two things a reload does NOT clear on its own, both handled in that block +because it is the first one on the mock leg. The webview's storage comes +back with the page, and the player writes the session out on `pagehide`, +which a reload fires — so removing the key up front puts it straight back; +the block adds a `pagehide` listener of its own, last and therefore run +last, and a phone with nothing in it comes up with no now-playing bar. And +the viewport emulation lives on the DevTools session rather than on the +page, so the re-attach after a reload drops it: the walk keeps what it set +and hands it back, or every still from there on would be the Chrome +window's own size at 1×. + +Two commands the phone can reach had no mock at all until this round — +`player_control` and `open_external` — found by mapping every `api.js` +wrapper used under `src/mobile` against the mock (126 wrappers, 124 +mocked). Unmocked they fall through to the real IPC, which on a `file://` +page rejects: a console warning and a toast, in whatever step happens to +touch them first. The transport now answers the way the real core does, +with an event; `resume` cannot restart the position ticks, so the state is +right and the position stands still. + ## Settings that reach the config `set_settings` takes **camelCase** arguments (Tauri's rule for command @@ -994,12 +1107,16 @@ of theirs streamed → a buddy's sheet → You (every switch and chip saved and read back from the config) → recent searches and a spelling suggestion → the sleep timer → the phone turned sideways (the player's three faces, a record with a long sheet over it, an artist page, and the side insets faked, -all at 915×411) and back, asserts what each step should show — the Home +all at 915×411) and back → and last, on a reload of its own, a phone that +has just been installed (the first-run sheet and its two cards, its +Soulseek door, the notes on Home, on both Library tabs and on Search, the +preview from You, a folder picked from the tour and the note losing the row +it answered), then back to the provisioned device, asserts what each step should show — the Home step holds the launch to a budget as well, reading off `__ss.boot` how many commands the app issued before its first frame, how many of those left in the shell's own turn, how long it took, and requiring round 12's art prewarm to still be absent from them — screenshots every step into `tests/mobile/out/`, and fails on -any exception or console error. The block at the end of the mock walk is the +any exception or console error. Near the end of the mock walk is the weather: a library index that will not read and its Try again, the client taken offline, a refused password and a server that cannot be reached, Search with nowhere to search, a room list that never arrives, and a rescan that refuses @@ -1100,3 +1217,15 @@ symptom. - **Per library folder.** Follows and the play history live in the library folder, so the phone's own folder starts empty of both; they arrive with a shared library. +- **A first run that restores rather than starts.** The tour covers the two + things the app cannot work without (a folder, an account) and stops + there. The desktop's own tour gates on an alpha key, which has nothing to + unlock here — the APK is side-loaded and there is no updater channel on + the phone — and its Community step has no counterpart, because Freenet is + not on the phone. The honest first run for Kyle's own phone is neither: + it is "point me at the desktop's library folder and read its follows, its + playlists and its play history", which works only when that folder is + reachable from the phone's storage. That is a sync door, not a setup + step. Making an account from inside the app is possible — Soulseek makes + one from the first sign-in with a new name — but it would mean handling a + password, which the tour deliberately refuses. diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index aab4f5a..123d024 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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; diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index 0ad0f9f..ef8d36d 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -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}
+ {:else if kind === "setup"} + +
+
{setup.reason === "preview" ? "Set up this phone" : "Welcome to SoulStream"}
+
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.
+ +
Where your music lives
+
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.
+
{setup.folder || "—"}
+
+ {@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} +
+ +
Soulseek
+
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.
+ {#if needsAccount()} +
+ {@render row("users", "Sign in on Community", "the card is on the Community tab", act(() => go("community")))} +
+ {:else} +
Signed in{slsk.username || slsk.savedUser}
+ {/if} + +
+
Nothing above is required. You → Set up this phone again opens it back up.
+
{:else if kind === "sleep"}
Sleep timer
Pause after
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 7b49171..d8cb565 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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(); }; }); diff --git a/src/mobile/SetupNote.svelte b/src/mobile/SetupNote.svelte new file mode 100644 index 0000000..9c63ab9 --- /dev/null +++ b/src/mobile/SetupNote.svelte @@ -0,0 +1,39 @@ + + +{#if folder || account} +
+ Two things make the rest of this work. +
+ {#if folder} + + {/if} + {#if account} + + {/if} +
+
+{/if} diff --git a/src/mobile/screens/Home.svelte b/src/mobile/screens/Home.svelte index 348cb0b..8662523 100644 --- a/src/mobile/screens/Home.svelte +++ b/src/mobile/screens/Home.svelte @@ -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}
Nothing in the library on this phone yet. - 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.
+ {/if} {#if catalogDown} diff --git a/src/mobile/screens/Library.svelte b/src/mobile/screens/Library.svelte index b225109..f50c689 100644 --- a/src/mobile/screens/Library.svelte +++ b/src/mobile/screens/Library.svelte @@ -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 @@ {#if !list.length}
Nothing here yetThe library on this phone is empty.
+ {:else if view === "grid"}
{#each list as a (a.id)} @@ -142,6 +144,10 @@ {/if} {:else if tab === "artists"}
{arts.length} artists · {lib.follows.length} followed
+ {#if !arts.length} +
No artists yetEvery artist with a record in the library on this phone is listed here.
+ + {/if} {#each arts as ar (ar.name)} {#if devBuild}