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
+131
-2
@@ -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.
|
||||
+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
|
||||
|
||||
+120
-3
@@ -251,6 +251,23 @@ const PROBE = `(function () {
|
||||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||||
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||||
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||||
/* the first run: the sheet, the two cards it offers, and the state
|
||||
it read out of the config — "<folder>:<account>:<flag>".
|
||||
setupWrites is the number of times the store has stamped
|
||||
setup_done, and a PREVIEW must leave it where it found it: that
|
||||
is the whole proof that the door in You is not a door onto Kyle's
|
||||
own config, and the phone walk asserts it on his phone. */
|
||||
setupUp: !!document.querySelector('.halfsheet.up .setup'),
|
||||
setupCards: [].map.call(document.querySelectorAll('.halfsheet.up .setup .sheet-sec'), function (s) { return s.textContent.trim(); }).join('|'),
|
||||
setupState: (function () { try { return __ss.setupState(); } catch (e) { return ''; } })(),
|
||||
setupWrites: (function () { try { return __ss.setup.writes; } catch (e) { return -1; } })(),
|
||||
/* the note the blocked screen carries, and the rows it offers: a
|
||||
row goes away because the config changed, not because a flag was
|
||||
flipped, which is why the count is the thing worth reading */
|
||||
setupNotes: document.querySelectorAll('.screen .setup-note').length,
|
||||
setupRows: document.querySelectorAll('.screen .setup-note .row').length,
|
||||
setupNoteText: [].map.call(document.querySelectorAll('.screen .setup-note .row .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||
signIn: ((document.querySelector('.signin .signin-t') || {}).textContent || '').trim(),
|
||||
/* the boot census, latched at the shell's first frame and never
|
||||
re-opened: how many commands the app asked for before it had
|
||||
drawn anything, how many of those left in the shell's own turn,
|
||||
@@ -836,6 +853,50 @@ const MOCK_STEPS = [
|
||||
{ do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 },
|
||||
{ do: `__settle()`, wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 },
|
||||
/* ── the first run, on a device that has just been installed ──────
|
||||
The mock is the whole backend here, so it can answer as one: no
|
||||
records, no playlists, no account, no all-files permission, and the
|
||||
library root still the sandbox folder Android hands a fresh
|
||||
install. The flag rides in the URL fragment — a reload cannot lose
|
||||
that the way a file:// document's storage might — and in
|
||||
localStorage beside it, for a page opened by hand.
|
||||
|
||||
This block is LAST on purpose. The reload wipes every module's
|
||||
state (the mock's playlists, its contacts, its one fake transfer),
|
||||
so nothing the two hundred steps above built survives into it, and
|
||||
it puts the walk back on a provisioned device at the end so that
|
||||
nothing appended after it is quietly running against an empty one.
|
||||
|
||||
A reload is not a reinstall: the webview's own storage comes back
|
||||
with it, so the session the walk left behind is cleared by hand —
|
||||
a now-playing bar on a phone with nothing in it is the one piece
|
||||
of the pretence that would show in the stills. Removing the key is
|
||||
not enough on its own, and a `pagehide` listener is not either:
|
||||
the player writes the session on pagehide, on visibilitychange AND
|
||||
on a ten-second timer while a track plays (player.svelte.js), and a
|
||||
CDP-driven reload is not obliged to fire pagehide at all — so the
|
||||
first attempt at this step came back with the bar still up. The
|
||||
clearing therefore shuts the door instead of chasing the writers:
|
||||
setItem is taught to refuse that one key for the few hundred
|
||||
milliseconds this page has left, so nothing can put it back. */
|
||||
{ do: `(function () { var K = 'ss.mobile.session.v1'; var set = Storage.prototype.setItem; Storage.prototype.setItem = function (k) { if (k === K) return; return set.apply(this, arguments); }; try { localStorage.removeItem(K); } catch (e) {} localStorage.setItem('mockFresh', '1'); location.hash = 'fresh'; })(); true`, wait: 400 },
|
||||
{ do: `location.reload()`, name: "fresh-boot", wait: 4500 },
|
||||
/* the tour's Soulseek step navigates to the card and stops there —
|
||||
nothing in the walk types a username or a password */
|
||||
{ do: `__click('.halfsheet .row', 'Sign in on Community')`, name: "fresh-community", wait: 1400 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-home", wait: 1000 },
|
||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "fresh-albums", wait: 900 },
|
||||
/* the Artists tab had no empty state at all before this round */
|
||||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "fresh-artists", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "fresh-search", wait: 900 },
|
||||
/* the preview door: it reads the state and writes nothing */
|
||||
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 },
|
||||
{ do: `__click('.screen .row', 'Set up this phone again')`, name: "fresh-preview", wait: 1100 },
|
||||
{ do: `__click('.halfsheet .row', 'Choose a folder')`, name: "fresh-folder", wait: 2000 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
|
||||
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
|
||||
{ do: `location.reload()`, name: "fresh-off", wait: 4500 },
|
||||
];
|
||||
const MOCK_EXPECT = {
|
||||
tracks: (i) => i.title.startsWith("Your library"),
|
||||
@@ -1095,6 +1156,40 @@ const MOCK_EXPECT = {
|
||||
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
|
||||
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
|
||||
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
|
||||
/* ── the first run ────────────────────────────────────────────────
|
||||
Every one of these is on state that STAYS: a sheet that is up, a
|
||||
counter, a field in the config, a row that is present or absent.
|
||||
Nothing here waits on a notice or a picker — both windows are
|
||||
shorter than the step's own wait plus the screenshot. */
|
||||
"fresh-boot": (i) => i.setupUp && i.setupCards === "Where your music lives|Soulseek"
|
||||
&& i.setupState === "private:out:todo" && i.setupWrites === 0
|
||||
&& /^dialog\/true\/Welcome to SoulStream\/focus$/.test(i.dlg)
|
||||
/* and nothing of the walk before it came through the reload: a
|
||||
phone that has just been installed has no session to restore */
|
||||
&& !i.mini
|
||||
&& !i.a11yAnon && !i.a11yGhost && !i.a11yBad && !i.a11yState,
|
||||
/* the tour's own door to the sign-in, and the stamp that a tour which
|
||||
ran leaves behind whichever door closed it */
|
||||
"fresh-community": (i) => !i.setupUp && i.title.startsWith("Community")
|
||||
&& i.signIn === "Sign in to Soulseek" && i.setupWrites === 1 && i.setupState === "private:out:done",
|
||||
"fresh-home": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 2
|
||||
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
|
||||
"fresh-albums": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2
|
||||
&& i.setupNoteText === "Choose your library folder|Sign in on Soulseek",
|
||||
"fresh-artists": (i) => i.title.startsWith("Your library") && i.setupNotes === 1 && i.setupRows === 2,
|
||||
"fresh-search": (i) => i.title.startsWith("Search") && i.setupNotes === 1 && i.setupRows === 2,
|
||||
/* the preview reads the real config and adds nothing to the counter */
|
||||
"fresh-preview": (i) => i.setupUp && i.setupWrites === 1 && i.setupCards === "Where your music lives|Soulseek",
|
||||
/* a folder picked from the tour: the config moved, the permission the
|
||||
new folder needs is offered, and still nothing was stamped */
|
||||
"fresh-folder": (i) => i.setupUp && i.setupState === "chosen:out:done" && i.setupWrites === 1
|
||||
&& /Allow all files access/.test(i.halfText),
|
||||
/* and the note loses the row it answered — because the config changed,
|
||||
not because a flag was flipped */
|
||||
"fresh-fixed": (i) => !i.setupUp && i.setupNotes === 1 && i.setupRows === 1
|
||||
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
|
||||
/* the provisioned device is back, and it is NOT offered a first run */
|
||||
"fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast,
|
||||
};
|
||||
|
||||
/* the phone walk: a record by name, played for real */
|
||||
@@ -1255,6 +1350,15 @@ const PHONE_STEPS = [
|
||||
nothing is written. It also catches a build where trouble.js simply
|
||||
did not ship: __ss.say would not be there and the line stays empty. */
|
||||
{ do: `(function () { var raw = ['not connected to server', 'not connected to Soulseek', 'io: failed to lookup address information: no address associated with hostname', 'protocol: unexpected end of frame', 'login failed: INVALIDPASS', 'could not reach server: io: connection refused', 'timed out', 'user dusty_crates is offline or unknown', 'no stored credentials', 'unable to open database file', 'library-folder-missing: /storage/1A2B-3C4D/Music']; window.__trouble = raw.map(function (r) { return __ss.say(r); }).join(' || '); return window.__trouble; })()`, name: "trouble-words", wait: 400 },
|
||||
/* the first run's preview door, on Kyle's own phone. It READS his
|
||||
state — his folder, his account — and must write nothing at all: the
|
||||
walk never clears his config, never re-runs setup and never stamps
|
||||
the flag, and `setupWrites` staying at zero is what says so. Driven
|
||||
through __ss rather than the row in You so the step cannot miss it
|
||||
on a long screen, and closed by the scrim, which on a preview is the
|
||||
same no-op as the button. Nothing is typed and nothing is picked. */
|
||||
{ do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 },
|
||||
{ do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
|
||||
];
|
||||
const PHONE_EXPECT = {
|
||||
@@ -1348,6 +1452,13 @@ const PHONE_EXPECT = {
|
||||
"trouble-words": (i) => i.trouble.split(" || ").length === 11
|
||||
&& i.trouble.split(" || ").every(function (s) { return s.length > 12; })
|
||||
&& !/io:|protocol:|os error|INVALIDPASS|not connected|library-folder-missing|no stored credentials/i.test(i.trouble),
|
||||
/* both cards on a phone that is fully set up, and the counter at zero
|
||||
on the way in and on the way out — the preview wrote nothing to his
|
||||
config. `setupWrites` would also be 1 if the boot gate had fired on
|
||||
his phone, which is the other thing this catches. */
|
||||
"setup-preview": (i) => i.setupUp && i.setupWrites === 0
|
||||
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
|
||||
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
|
||||
end: (i) => /^(Good |Still)/.test(i.title),
|
||||
};
|
||||
|
||||
@@ -1525,9 +1636,11 @@ async function main() {
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Log.enable");
|
||||
};
|
||||
/* the emulated phone, kept: a re-attach has to be handed it again */
|
||||
let metrics = { width: 411, height: 914, deviceScaleFactor: 2, mobile: true };
|
||||
if (!PHONE) {
|
||||
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
|
||||
await sleep(600);
|
||||
}
|
||||
@@ -1546,7 +1659,8 @@ async function main() {
|
||||
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
|
||||
else {
|
||||
try {
|
||||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true });
|
||||
metrics = { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true };
|
||||
const r = await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
|
||||
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
|
||||
}
|
||||
@@ -1560,7 +1674,10 @@ async function main() {
|
||||
}
|
||||
await sleep(s.wait || 500);
|
||||
if (s.do === "location.reload()") {
|
||||
try { await reattach(); } catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
|
||||
try {
|
||||
await reattach();
|
||||
if (!PHONE) await cdp.send("Emulation.setDeviceMetricsOverride", metrics);
|
||||
} catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
|
||||
}
|
||||
if (!s.name) continue;
|
||||
await still(path.join(OUT, `${prefix}${s.name}.png`));
|
||||
|
||||
Reference in new issue
Block a user