diff --git a/handoff/README.md b/handoff/README.md new file mode 100644 index 0000000..a541bb0 --- /dev/null +++ b/handoff/README.md @@ -0,0 +1,19 @@ +# handoff/ — the Android port's pending work + +Saved 2026-09-14 when the session ended. Nothing here is on `main`; `main` holds +only rounds that were built, walked on the device and committed. + +- `rounds/` — every round's patch script and commit message, and the insertion + files they read. **Rounds 43 to 48 are written and verified but not yet + shipped**; 49 is incomplete (authoring was stopped mid-flight). The earlier + ones are kept because a patch is the record of how a round was made. +- `tools/` — the pipeline. `preflight.sh` applies a wave in order to a throwaway + copy; `chain.sh` patches and ships a list of rounds; `reset-tree.sh` puts the + tree back after a failure (a plain `git checkout --` leaves new files behind + and trips the round's own re-run guard); `compare-walks.sh` and + `baseline-mock-steps.txt` are the A/B for the unverified adaptive-wait change; + `audit-commands.cjs` lists backend commands the phone never calls. +- `notes/` — the Android Auto design in full, and the icon background comparison + mock (open `icon-mock.html` directly; it needs no server). + +Start with `../HANDOFF-2026-09-14-android-auto-and-icon.md`. diff --git a/handoff/notes/android-auto-design.md b/handoff/notes/android-auto-design.md new file mode 100644 index 0000000..58749d3 --- /dev/null +++ b/handoff/notes/android-auto-design.md @@ -0,0 +1,264 @@ +# Android Auto — the design + +_Produced 2026-09-14 by a research pass (current API research, testing research, +a scout of this repo's media stack, a scout of every icon surface) and a synthesis._ + +## Verdict + +**Build it on media3 `MediaLibraryService`, and migrate the existing card onto it rather than standing a second service beside it. Do the icon first, in two small rounds, before touching any of it.** + +Three decisions, stated plainly: + +**1. media3, not `MediaBrowserServiceCompat`.** `androidx.media` — the whole library the plugin currently depends on (`crates/tauri-plugin-media-session/android/build.gradle.kts:26`, `androidx.media:media:1.7.0`) — was deprecated in 1.8.0 (2026-05-06). `MediaSessionCompat`, `MediaButtonReceiver` and the `MediaStyle` notification in `MediaService.kt` are all on it. Building Auto on `MediaBrowserServiceCompat` would be adding new code to a deprecated library, which is exactly the obsolete pattern Kyle asked to avoid. media3 1.11.0 is stable and gives the legacy `android.media.browse.MediaBrowserService` binder Auto actually binds, for free — you just declare both actions in the intent-filter. + +**2. Migrate, do not wrap or double up.** Two live `MediaSession`s in one app means two cards in the shade and undefined media-button targeting; media3 posts and owns the notification itself. There is no safe "add Auto alongside" intermediate — the cut is atomic within one deploy. That means one round deliberately puts the daily-use lock-screen card at risk. **This is the real cost of Auto and it should be said out loud before starting.** The mitigations are: land the hardest sub-piece (the queue mirror) a round *early*, where it cannot break anything; make the migration its own round that ships and soaks before any car-facing code follows; and re-run by hand the one check the walk provably cannot catch — the 25-second paused-restore FGS survival test, which `memory/android-fgs-promote-first.md` records as having killed a real Save while the walks passed. + +**3. The cold-start problem has a real answer, and it is smaller than it looks.** The framing "playback lives in Rust driven by a WebView, so cold start is hard" is half right. What is genuinely WebView-bound is the **queue** (`src/mobile/player.svelte.js`) and the **resolver** (`src/lib/sources.js`, 872 lines of JS). What is *not* WebView-bound — and this is the load-bearing finding — is everything else: `crates/library` is plain rusqlite with no Tauri dependency, `crates/player` is plain rodio/symphonia with no Tauri dependency, and `JNI_OnLoad` already publishes the JavaVM and Context to `ndk-context` at `System.loadLibrary` time, before any Activity exists. `object Rust`'s class initializer does nothing but load that .so. + +So a service-started process with no Activity, no Tauri and no WebView can already: load the native library, open `library.db`, and drive rodio. The honest staged answer is: + +- **Browse works cold from day one**, served by a new JNI entry in `libapp_lib.so` that opens the library **read-only** (`Connection::open_with_flags`, never `Library::open` — that one writes) and answers from its own small queries. +- **Playback from cold lands one stage later**, as a headless fallback queue of local file paths that exists only while no WebView is alive, over a process-singleton `Player`. +- **Catalog and Soulseek content never plays from cold**, in this plan or any realistic successor, because the resolver is JS and Soulseek needs a live session. Those items get the documented `STATE_ERROR` + `ERROR_RESOLUTION_ACTION_LABEL` + resolution intent — "open SoulStream on your phone" — which is also exactly what satisfies Google's "must work when the user isn't signed in" test requirement. + +**What the user experiences in between** (after browse lands, before cold playback lands): the car shows the full library tree whether or not the app is running. If the app *is* running — which it is whenever the phone has been used recently — taps play normally. If the process is cold, a tap surfaces a one-line card telling them to open the app, and the next tap works. That is a real, defensible intermediate, not a broken one, and it is worth shipping on the way. + +**Two mechanism notes that save real work.** First, the JNI symbol lives in `libapp_lib.so` (the app crate, which has the `library` and `player` deps) while the Kotlin `external fun` is declared in the *plugin* module's own package and calls `System.loadLibrary("app_lib")` itself. Names link at runtime, so the plugin crate needs no Rust dependency on `library` — the whole "plugin can't depend upward on `LibraryState`, so it needs a registration hook" problem simply evaporates. Second, resources: a library module cannot override a resource the app module already defines, so the density mipmaps must come from a **tracked res overlay copied in by `build.sh`/`dev.sh`**; but the app module defines no `mipmap-anydpi-v26` and sets no `android:roundIcon`/`appCategory`, so the adaptive icon and the manifest attributes can come from a library module. Use both, each for what it can actually do. The overlay also becomes the only sane home for `res/xml/file_paths.xml`, which Auto needs later for `content://` cover art and which currently exposes only `external-path` and `cache-path`. + +Scope: Android Automotive OS, Android TV, Wear and a Play release are **separate jobs**, none of which come along for free. The template already declares `LEANBACK_LAUNCHER` with no banner — leave it dormant rather than adding a banner and making a never-tested TV entry look shippable. And note that a sideloaded SoulStream is invisible to Android Auto until developer mode + "Unknown sources" are enabled on the phone; real-world visibility needs a Play release and the car-app-quality review. That is a product decision, not an engineering one. + +## Rounds + +Nine rounds. Two are icon, six are Auto, one is Kyle's. Rounds 4 and 7 are honestly bigger than a sitting and are flagged as such. + +--- + +**R1 — The launcher icon, everywhere it is static.** + +*Delivers:* SoulStream's falcon replaces Tauri's logo on the launcher, in recents, on the splash and in other apps' share sheets; a real adaptive icon with a monochrome layer so Android 13+ themed icons work. + +*Touches:* a new tracked `src-tauri/android/res/` overlay carrying `mipmap-anydpi-v26/{ic_launcher,ic_launcher_round}.xml`, `mipmap-*/ic_launcher.png` (kit `legacy/`), and the foreground/background/monochrome layers; a copy step added to `src-tauri/android/build.sh` (before the `cd src-tauri/gen/android && gradlew` at line ~21) **and** to `dev.sh`. The kit's own `mipmap-anydpi-v26/*.xml` references `@drawable/ic_launcher_foreground` while the PNGs ship under `mipmap-*` with no `drawable-*` directory anywhere — verified by reading both — so it will not compile as delivered and must be reconciled during the copy (point the XML at `@mipmap/`, or land the layers under `drawable-*`). + +*Verified by:* the walk cannot see a launcher. Three checks instead — (a) unzip the built APK and assert `res/mipmap-anydpi-v26/ic_launcher.xml` exists and the `ic_launcher.png` hashes now match the kit, which is a pure build assertion and can be a permanent one-liner; (b) `deploy.sh` already screenshots after launch, so the recents/splash change shows up there; (c) one `adb exec-out screencap` of the launcher with themed icons off, then on — 60 seconds of Kyle's phone, and the only way adaptive masking and the monochrome layer are ever really proven. + +*Depends on:* nothing. Ship first. + +--- + +**R2 — The icon's moving parts.** + +*Delivers:* a falcon notification glyph replacing the generic eighth-note; an app-icon fallback bitmap so the lock screen and (later) Auto never show a grey hole when a cover hasn't decoded; a proper Android 12+ splash instead of the platform's white flash; `android:appCategory="audio"`. + +*Touches:* `ic_soulstream_note.xml` (media-session) and `ic_soulstream_down.xml` (transfer-notice) — **both**, or the two notifications stop matching; `MediaService.kt:261` `.setLargeIcon(MediaHub.art)` and `:205-208`'s `METADATA_KEY_ALBUM_ART` gain a decoded-once fallback; `values/themes.xml` in the overlay gains `Theme.App.Starting`; a small library manifest in the media-session module adds `appCategory` (the app manifest sets none, so the merger takes it). + +*Verified by:* the walk already reads `dumpsys notification --noredact` (smoke.mjs:2180) — assert the card is still posted and the transport still works. The glyph itself is a screenshot: Android's shade takes the alpha channel only, so a falcon exported with colour renders as a white blob and only a pixel will show it. Splash is a screencap during the first 400 ms of a cold start. + +*Depends on:* R1's overlay mechanism. + +--- + +**R3 — The mirror grows a queue window. (No behaviour change.)** + +*Delivers:* the single hardest sub-piece of the media3 migration, landed a round early where it cannot break anything. + +*Touches:* `NowPlaying` in `crates/tauri-plugin-media-session/src/lib.rs:29-44` and `NowPlayingArgs` in `MediaSessionPlugin.kt:18-31` gain a bounded queue window (current ±N items: id, title, artist, album, duration, artKey) plus the current index; `src/mobile/media.svelte.js:15-43` fills them from `player.queue`/`player.qi`. `MediaService.kt` ignores the new fields entirely. + +*Why it matters:* media3's `SimpleBasePlayer.getState()` wants a playlist and an index. Today Kotlin knows exactly one track. Doing this inside R4 would mean debugging a new mirror contract and a new session library at the same time. Doing it here means that by the time media3 arrives, the data it needs has already been running on the phone for a round. + +*Verified by:* the existing card must be completely unchanged — the walk's media-session steps pass as-is. Add one logcat assertion that the window arrives populated and the index is right after a next/prev. Genuinely half a sitting. + +*Depends on:* nothing (can even run in parallel with R1/R2). + +--- + +**R4 — media3 migration. No Auto yet. THE BIG ONE.** + +*Delivers:* the same lock-screen card, shade transport, headset buttons, audio focus and becoming-noisy pause, on a current API — and the `Player` abstraction that everything after this depends on. + +*Touches:* `build.gradle.kts` swaps `androidx.media:media:1.7.0` for `androidx.media3:media3-session` + `media3-common`. A new `SoulStreamPlayer : SimpleBasePlayer` maps `getState()` from `MediaHub.state` (with R3's window) and routes `handlePlay`/`handlePause`/`handleSeek`/`handleSetMediaItem`/`handleSeekToNext|Previous` into the existing `MediaHub.act(...)` — the round trip out to the WebView via `Plugin.trigger("action", …)` is unchanged, so `media.svelte.js:57-68` keeps working untouched. `MediaService` becomes a `MediaSessionService` returning a `MediaSession`. Deleted: `buildNotification` (`:247-282`), the `MediaButtonReceiver` declaration and the `MEDIA_BUTTON` intent-filter in the plugin manifest (`:11-27`), and the whole audio-focus block (`:107-127`, `:295-308`) — media3 handles focus via `setAudioAttributes(…, handleAudioFocus = true)`. Leaving the old receiver in place alongside media3's will fight for media buttons. + +*Verified by:* the walk's existing media-session steps re-pointed at the new card (`mediaSessionState()` at smoke.mjs:2211 reads `dumpsys media_session`, which media3 still populates). Then **by hand, every time**: lock screen shows title/artist/art; shade transport play/pause/next/prev; a headset button with the app backgrounded; unplugging headphones pauses; another app taking focus pauses and returning resumes; and the 25-second paused-restore check — force-stop, relaunch with a paused track, wait 25 s, confirm the pid survives and logcat has no `ForegroundServiceDidNotStartInTimeException`. That last one is not optional and the walk cannot do it: its restart step resumes inside 3.5 s, well under the ~10 s kill window, which is exactly why this class of bug shipped once already. + +*Honest sizing:* 3–4 sittings, and it cannot be split — a half-migrated app has two sessions. Recommend it ships on its own and soaks for a few days of ordinary use before R5. + +*Depends on:* R3. + +--- + +**R5 — Discovery + the prober. Auto can see the app and gets a root.** + +*Delivers:* Android Auto lists SoulStream and opens it to a root that answers instantly, with nothing under it yet. Plus the only instrument that can observe any of this. + +*Touches:* `MediaSessionService` becomes `MediaLibraryService`; `onGetSession` returns a `MediaLibrarySession`; `onGetLibraryRoot` returns immediately and unconditionally (no DB, no I/O — the host times out otherwise). Plugin manifest: service `exported="true"` with **both** `androidx.media3.session.MediaLibraryService` and `android.media.browse.MediaBrowserService` actions, plus `` and a new `res/xml/automotive_app_desc.xml` in the plugin module. New `tools/auto-prober/` — its own ~200-line Kotlin Gradle project, deliberately outside `src-tauri/gen/android` so `tauri android init` cannot delete it — that binds a `MediaBrowser` with Auto-shaped root hints, walks the tree, and logs JSON under one logcat tag. + +*Verified by:* `adb shell pm dump com.soulstream.app` asserts the `android.media.browse.MediaBrowserService` filter resolves and the `gms.car.application` meta-data points at a real resource — two permanent one-line assertions in the walk that catch the `TAURI_ANDROID_PROJECT_PATH` trap documented in `env.sh:18-22`, a failure this project has already lived through as a start-up `ClassNotFoundException`. Then the prober: `am force-stop`, bind, assert a root comes back within DR-3's 10 s with the app process cold. + +*Before committing to this round:* run the plain `bindService`/`unbindService` check for androidx/media issue #3158 — a create/bind/destroy loop at ~30 cycles/second when Auto binds with nothing playing, reported open against 1.6.1 and 1.7.1 and unconfirmed on 1.11.0. `MediaService` registers a `BroadcastReceiver` and an `AudioFocusRequest` in `onCreate`, which is precisely the shape that bug punishes. Mitigation if it still bites: process-lifetime singleton session/player, nothing registered in `onCreate`. + +*Depends on:* R4. + +--- + +**R6 — The tree.** + +*Delivers:* Recent / Albums / Artists / Playlists in the car, three levels deep, with covers. + +*Touches:* a new Rust module in the app crate exporting `Java_com_soulstream_mediasession_..._nativeBrowse`, opening the DB **read-only** via `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` with its own queries — never `Library::open`, which runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` write batch (`crates/library/src/lib.rs:259-262`). The path is derived from the `filesDir` Kotlin passes down plus a **read-only, never-saving** parse of `config.json` for `download_dir` (default `/Library`, per `config.rs:326-341`). Artists are not a Rust concept — they are derived in JS at `lib.svelte.js:55-65` — so the Rust side needs the same grouping. Cover art goes out as `content://` through the existing FileProvider, which needs a `files-path` entry added to `res/xml/file_paths.xml` via R1's overlay. Root children: read `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT` rather than hardcoding 4; all browsable, none playable; ignore `page`/`pageSize`, since Auto does not paginate. + +*Verified by:* the prober, extended into real assertions in the walk — root ≤ limit and all browsable; each tab's children load with unique stable ids; every item has a title and an art URI (MC-1); per-node `onGetChildren` latency under DR-3's 10 s, as numbers, the same way the walk already asserts preload latency. All of it with the app force-stopped, which is the whole point: with no WebView there is no CDP, so the existing walk is blind here and the prober is the only eye. + +*Watch for:* a read-only connection on a WAL database needs the `-shm`; same UID so it should create one, but if the WAL holds uncheckpointed content with no writer around it can fail to open. Prove it, and make the failure return an empty-but-valid tree rather than hanging the head unit. + +*Depends on:* R5. + +--- + +**R7 — Cold-start playback. The second big one.** + +*Delivers:* a tap in the car plays a local library track with the phone in a pocket and no UI running, and the album keeps going. + +*Touches:* the `Player` moves to a process-wide `OnceLock>` in the app crate, taken by whoever needs it first — `playback::init` (`src-tauri/src/playback.rs:187-191`, today `Player::spawn_with_device` owned inside `PlayerState`) or the headless service. Two `Player`s in one process means two AAudio output streams, so this is the load-bearing piece. The event pump that feeds `PlaybackSnapshot` becomes the same singleton, with a subscription slot. A new `nativePlay(mediaId)` resolves local paths read-only and drives a small **headless fallback queue** — a `Vec` plus an index that advances on `track-ended`. It is not "move the queue into Rust": the JS queue keeps shuffle, repeat, `alts` failover, provenance and 3-ahead preload. The fallback exists only when no WebView is alive, and the WebView takes over when it comes up — which the session-restore path already half-does, since it asks `player_state()` and follows a live audio thread. + +*Verified by:* the prober — force-stop, bind, `playFromMediaId`, assert `dumpsys media_session` reaches `STATE_PLAYING` and `dumpsys media.audio_flinger` shows a track on the app's uid (both parsers already exist in the walk, smoke.mjs:2211 and :2242). The real proof is the **second** track: assert the fallback queue advances on its own. Then the handover: launch the app while headless playback is running and assert exactly one audio stream and one queue driver survive. + +*Honest sizing:* 3 sittings. The handover rule is where the bugs will be. + +*Depends on:* R6. + +--- + +**R8 — Voice, errors, and the quality pass.** + +*Delivers:* "play X" from Assistant/Gemini; an honest error instead of silence for anything the resolver would be needed for; the checklist items that block Play acceptance later. + +*Touches:* `onSearch`/`onGetSearchResult` **and** the `mediaItem.requestMetadata.searchQuery` path in `onAddMediaItems` — Google's own samples disagree on which Auto uses, so implement both. Empty query ("play some music") → Recent. Catalog/Soulseek items → `STATE_ERROR` with a localized message routed through `src/mobile/trouble.js`'s existing `say()`, plus `ERROR_RESOLUTION_ACTION_LABEL` and a resolution intent. Content-style extras (grid for albums/artists, list for tracks), title + thumbnail on every item (MC-1), no autoplay on bind (MA-1 — already half-satisfied, since restore comes up paused), tintable white icons from the kit's monochrome SVG (VD-2). + +*Verified by:* the prober for search (assert rows come back for a known query, and that a catalog item's tap lands in `STATE_ERROR` with a non-empty message rather than silence). The visual half is DHU-only. + +*Depends on:* R7. + +--- + +**R9 — DHU pass. Kyle's round, not an agent's.** + +*Delivers:* the answers only pixels can give. + +*How:* `sdkmanager "extras;google;auto"` → `%LOCALAPPDATA%\Android\Sdk\extras\google\auto\desktop-head-unit.exe`. On the phone: Settings → Connected devices → Connection preferences → Android Auto → About → tap Version ×10 → ⋮ → Developer settings → **Unknown sources on**, then ⋮ → Start head unit server. Then `adb forward tcp:5277 tcp:5277` and launch the DHU. Prefer the adb tunnel over `--usb` on Windows — `--usb` needs a WinUSB driver and fights adb. + +*Checks:* contrast (VD-1/2/3), no animation (SA-1 — the app's animated eq-bars glyph and waveform lane must never reach a browse row or the playback card), no auto-scrolling text (ST-1), ≤2 s button response (DR-1), day/night (`daynight`), driving restrictions (`U` / `Shift+U`), rotary input (`-i rotary`), all three resolutions, and — critically — what Auto actually presents itself as in `onConnect` (log `packageName`/`uid`/signature rather than copying a years-old `allowed_media_browser_callers.xml`). + +*Cannot be answered by DHU or by any agent:* real car audio routing, OEM launcher skins, wireless projection handover, and genuine Gemini/Assistant round trips. Those need an actual car, and are worth deferring to "Kyle happens to be in one." + +*One caution:* `smoke.mjs` calls `adb("forward", "--remove-all")` around line 2430, which will drop the DHU tunnel mid-session. Do not run the walk and a DHU session at the same time, or fence that call. + +*Add to ANDROID.md:* the exact invocation and the phone's setting path. Those taps are forgettable and the docs for them move. + +## Decisions that were Kyle's + +**Fork 1 — The phone icon's background: the kit's flat `#05080B`, or concept-04's navy gradient rebuilt at 108dp.** + +The desktop ships concept 04 "Deep navy glow" (`src-tauri/icons/icon.svg` is byte-identical to the kit's `soulstream-04-deep-navy-glow.svg`). The kit's *adaptive* background is flat `#05080B` — the older, darker `soulstream-app-dark` ground. Taking the kit as-is gives a phone icon visibly flatter than the desktop's, side by side. + +- Flat: ship it as `#FF05080B` rather than five identical flat PNGs. Zero extra work, and honestly it reads fine under a launcher mask. +- Gradient: one 108-unit SVG reproducing concept 04's `#0A2330 → #07141F → #040A10` axis plus the `#31C0CB`/0.18 radial glow, scaled 1024→108. + +**Recommendation: build the gradient, but mock it first.** It is maybe 15 minutes and it is the difference between "the phone has the app's icon" and "the phone has a version of the app's icon". Kyle's own standing rule is that a look gets mocked before it gets implemented, and this qualifies — a side-by-side of both backgrounds under a circle mask, themed on and off, is cheap and is the only way to actually judge it. + +One note either way: the kit's foreground sits 2 px above the geometric safe-zone line at xxxhdpi, recorded in `brand-tokens.json` as `opticalSourceOffset`. That is a deliberate optical correction for the light wing and tail tips. Do not "fix" it — it will look low, and it will diverge from every other kit export. + +--- + +**Fork 2 — Where Auto stops.** + +Three defensible stopping points, and they differ by several sittings: + +- **(a) R1–R6.** Browse works always; playback works when the app process is alive, which is most of the time in practice. Cold tap → "open SoulStream on your phone", then it works. Stops after the tree. +- **(b) R1–R8.** Adds cold-start playback for the local library and the search/voice/error pass. The car works from a genuinely cold phone for music Kyle owns. +- **(c) Catalog and Soulseek in the car.** Would require the resolver (`src/lib/sources.js`, 872 lines of shared JS) and a Soulseek session to exist headlessly. Not in this plan and not in a realistic successor. + +**Recommendation: commit to (b), ship (a) on the way, never promise (c).** (a) is a real intermediate rather than a broken one, and shipping it means R7's risk is taken against something already working in a car rather than against a blank. But (a) alone is not what "Android Auto support" means to a user — the car binds the service when the phone is in a pocket, which is exactly when the process is cold. Stopping at (a) permanently would mean the feature works least often precisely when it is most wanted. + +Folded into this recommendation: **let R4 soak.** The media3 migration should ship on its own and get a few days of ordinary daily use before R5 lands on top of it. It is the round that risks the card Kyle uses every day, and a regression in it is much easier to see and attribute alone than underneath three rounds of car code. + +--- + +**Fork 3 — Build the prober APK, or drive everything by hand from the DHU.** + +The prober is ~200 lines of Kotlin plus its own small Gradle project under `tools/`, installed alongside the app, driven from `smoke.mjs` with the `adb` helper it already has (`tests/mobile/cdp.mjs:25`) and gated to dev builds. + +- **Build it:** every structural thing Auto checks becomes an assertion — root width and flags, unique stable ids, title and art on every item, per-node latency as numbers against DR-3, and the cold-start case. +- **Skip it:** rely on the DHU and Google's Media Controller Test app, both of which are human-driven and neither of which asserts anything about the browse tree. + +**Recommendation: build it — but note the decision is coupled to Fork 2.** If Kyle stops at (a), the prober's value drops a lot: the tree can be eyeballed in the DHU and the cold-start case barely matters. If Kyle goes to (b), the prober is not optional. With no WebView there is no CDP, so the walk is structurally blind to every single thing R6 and R7 deliver — the prober is the only instrument that can see the state Auto actually operates in. It is also the cheapest insurance against R7's handover bugs, which will otherwise only show up as "the car went quiet sometimes". + +--- + +*Deliberately not offered as forks:* media3 vs `MediaBrowserServiceCompat` (the latter means writing new code into a library deprecated five months ago — settled), migrate vs second service (two sessions means two shade cards and undefined button routing — settled), and Android TV (the template's dormant `LEANBACK_LAUNCHER` costs nothing; adding the kit's banner would make a never-tested TV entry look shippable, and removing the category means editing a generated file — leave it alone). + +## Risks + +**What could break that works today** + +**The lock-screen card, in R4.** This is the headline risk and there is no way to avoid it while getting Auto right. Media3 takes over notification posting, media-button routing and audio focus, which means the promote-first invariant gets re-litigated inside `MediaSessionService`'s own lifecycle. `memory/android-fgs-promote-first.md` records that this exact class of bug — a foreground start that does not promote in time — killed the process mid-Save on 2026-09-13, four of fourteen tracks in, **and that the walks did not catch it**: the restart step resumed in 3.5 s, well inside the ~10 s kill window. The 25-second paused-restore check is manual, every time, no exceptions. + +**Two sessions is not a safe intermediate.** No incremental "add media3 alongside" exists. R4 is atomic within one deploy, or the shade shows two cards and media buttons land somewhere undefined. + +**The `mediaPlayback` service's lifetime changes shape.** Today `MediaService.refresh()` calls `stopSelf()` the moment the bar empties (`:191-198`) — deliberately, which is why downloads needed their own `dataSync` service. Auto binds with `BIND_AUTO_CREATE` and **never calls `onStartCommand`**, so the current promote-first path is simply not on Auto's route into the process. Bound is not the same as foregrounded, so this is probably fine — but "probably" is not good enough for the code path that already killed the app once. Decide it deliberately in R5, do not inherit it. + +**androidx/media #3158 — the bind loop.** When Auto binds a `MediaLibraryService` with no playback ongoing, the service can enter a `onCreate → onBind → onGetSession → onConnect → onUnbind → onDestroy` loop at ~30 cycles/second, building a fresh player and session each time; reporters hit crashes past 1000 registered receivers. Open, reported on 1.6.1 and 1.7.1, **unconfirmed on 1.11.0**. `MediaService.onCreate` registers a `BroadcastReceiver` and requests audio focus, which is the exact shape that gets punished. Reproduce it with a plain `bindService`/`unbindService` instrumentation test before committing to R5 — no DHU needed — and if it still bites, the mitigation is a process-lifetime singleton session/player with nothing registered in `onCreate`. + +**Two `Player`s in one process, in R7.** `playback::init` creates its own `Player` via `Player::spawn_with_device` (`playback.rs:189`). A headless one alongside it means two rodio output streams and two AAudio clients. The `OnceLock>` singleton is the fix; getting it wrong is either doubled audio or a silently dead output, and the event pump has a single receiver so it must become part of the same singleton rather than a second consumer. + +**The config-write hazard — the sharpest one in the plan.** `config::load` (`config.rs:306-311`) is `serde_json::from_str(&s).ok()` → `unwrap_or_default()`, with no BOM tolerance, and `save` writes unconditionally. A headless path that used the normal load/save shape and failed to parse would hand back all-defaults and, on the next save, wipe Kyle's library folder setting — the exact failure `memory/config-json-bom-silently-defaults.md` already documents, arriving from a new direction. **The headless browse path must be read-only end to end: parse `config.json` tolerantly, take `download_dir` if present, default to `/Library`, and never call anything that writes.** + +**A second SQLite connection.** `Library::open` runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` batch (`crates/library/src/lib.rs:259-262`) — it is a writer, not an opener, and must not be used headlessly. `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` is the right shape, but reading a WAL database needs the `-shm` file; same UID so it should create one, and if the WAL holds uncheckpointed content with no writer around it can still fail to open. Prove it in R6, and make failure yield an empty-but-valid tree rather than a head unit stuck on a spinner. + +**The res overlay must be copied in two places.** `build.sh` and `dev.sh`. Miss `dev.sh` and dev builds silently revert to the Tauri logo — a failure with no error message that will be blamed on something else. + +**`npx tauri icon` is a trap, and `src-tauri/icon-source.svg` is a loaded gun.** The CLI regenerates every platform from `default` in one pass — `icon.ico`, `.icns`, the `Square*Logo.png` set — and when `gen/` exists it writes into the gitignored tree rather than `src-tauri/icons/android/`, which is exactly why that tracked directory looks correct while the APK does not. Meanwhile `src-tauri/icon-source.svg` is still the pre-falcon green `#1ed760` waveform, tracked, with a name that invites the one command that would re-brand the app backwards. Worth deleting in passing. + +**`src-tauri/icons/android/` is an attractive nuisance.** It is tracked and on-brand, so it looks like the obvious source — but its `ic_launcher_foreground.png` is full-bleed (alpha bbox = the whole 432×432 canvas) over a `#fff` background. Copied into `gen/` it produces a cropped rounded box on white: a *different* wrong icon that will look plausibly done in a file listing. Confirmed independently — the three candidate `mipmap-xxxhdpi/ic_launcher.png` files (`gen/`, `src-tauri/icons/android/`, kit) have three different md5s, and there is no `mipmap-anydpi-v26` in `gen/` at all. + +**Library resources cannot override app resources.** Putting `mipmap-*/ic_launcher.png` in a plugin crate does nothing — the app module wins for the same name at the same qualifier. Only `mipmap-anydpi-v26` (which the app module does not define) survives, and only with layer names that do not collide with the app's existing `ic_launcher_foreground`. Get this wrong and you get a green build with an unchanged icon. + +**Patch scripts.** `memory/heredoc-regex-patch-trap.md`: a regex inside a heredoc→template-literal patch became a `//` comment and shipped, losing every artist photo. These rounds are patch-script-shaped. Write them with the Write tool, and look at the resulting still. + +--- + +**What is unverifiable without a car** + +DHU covers more than expected: browse tree rendering, content style, contrast, day/night, driving-restriction states, rotary input, all three resolutions, the ≤2 s button response, error and sign-in cards, and a canned voice query via `mic play`. Everything structural is covered better by the prober. + +Genuinely car-only: **real car audio routing** (DHU keeps audio on the phone — there is no car speaker path, no Bluetooth/USB audio, no car-side focus contention), **OEM launcher skins and quirks**, **wireless projection handover**, and **actual Gemini/Assistant round trips**. Also unverifiable anywhere before a Play release: whether the app is visible to a non-developer at all. + +Which leads to the thing worth saying before the work starts rather than after: **a sideloaded SoulStream will not appear in Android Auto until developer mode and "Unknown sources" are turned on in the phone's Android Auto settings.** Until that tap happens, a completely correct implementation looks identical to a broken one. Real-world visibility needs a Play Store release and the car-app-quality review — a product decision, not an engineering one, and `tauri-plugin-storage`'s `MANAGE_EXTERNAL_STORAGE` is a Play-policy-restricted permission that would need its own answer if that ever comes up. + +Two smaller unknowns worth five minutes each on the phone before R9: whether Android 16's Android Auto overflow still carries "Start head unit server" (every write-up describes Android 9–15 menus), and whether the head unit server can be started from adb at all — if it can, R9 becomes scriptable end to end; if not, it stays a manual tap before every DHU session. + +## Effort + +Sized in sittings of roughly twenty minutes, the unit this project actually ships in. + +| Round | Sittings | Note | +|---|---|---| +| R1 launcher icon overlay | 1 | + ~1 min of Kyle's phone for the launcher screencap | +| R2 glyph, art fallback, splash | 1 | | +| R3 mirror grows a queue window | 0.5 | pure additive, zero risk | +| R4 **media3 migration** | **3–4** | cannot be split; ships alone and soaks | +| R5 discovery + prober | 2 | prober is its own Gradle project | +| R6 the tree (JNI + read-only DB) | 2 | | +| R7 **cold-start playback** | **3** | singleton player + fallback queue + handover | +| R8 search, voice, errors, quality | 2 | | +| R9 DHU pass | — | Kyle, 30–45 min, once, not an agent's round | + +**Total: 14–15 sittings of agent work, plus one session of Kyle's with the DHU.** + +The proportions matter more than the total: + +- **The icon is about 15% of this** — two and a half sittings out of fifteen. It is low risk, the assets already exist, it is the most visible change in the whole plan, and it fixes a live shipped defect: the phone is currently wearing Tauri's logo with no adaptive icon at all. It should go first, and it should go first even if Auto is deferred indefinitely. +- **Two rounds are half the work.** R4 and R7 together are 6–7 of the 15. Everything else is genuinely sitting-sized. Any estimate that makes those two look like ordinary rounds is wrong, and splitting either one produces a worse outcome — R4 split leaves two sessions fighting, R7 split leaves a headless player racing the app's. +- **R3 is the cheapest insurance in the plan.** Half a sitting, no risk, and it removes the single hardest sub-problem from R4 by landing it a round early where the existing card simply ignores it. +- **The two open questions that could move these numbers** are both cheap to settle and both should be settled before their round rather than during it: reproduce androidx/media #3158 against 1.11.0 with a plain `bindService`/`unbindService` test before R5 (if it still bites, R5 grows by a sitting for the singleton restructure), and prove a read-only WAL open of `library.db` before R6 (if it fails, R6 needs a different data path and grows by one to two). +- **Stopping at Fork 2(a)** — browse only, no cold-start playback — lands at about 9 sittings instead of 15, and makes the prober optional, which would trim another 1–2. + +Nothing here is blocked on anything outside the repo except the DHU install and two settings taps on the phone. The critical path is strictly sequential from R3 onward; R1, R2 and R3 can run in any order or in parallel. diff --git a/handoff/notes/icon-mock-build.mjs b/handoff/notes/icon-mock-build.mjs new file mode 100644 index 0000000..614e7d1 --- /dev/null +++ b/handoff/notes/icon-mock-build.mjs @@ -0,0 +1,159 @@ +// Generates icon-mock.html from the REAL SoulStream brand-kit sources. +// Reads only; never writes into F:/Coding/SoulStream. +import fs from 'node:fs'; +import path from 'node:path'; + +const KIT = 'F:/Coding/SoulStream/branding/soulstream-logo-kit'; +const ADP = KIT + '/android/adaptive'; +const OUT = path.dirname(new URL(import.meta.url).pathname.replace(/^\//, '')); + +const read = p => fs.readFileSync(p, 'utf8'); + +/* ---------- helpers ---------------------------------------------------- */ +function attrs(tag) { + const o = {}; + for (const m of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) o[m[1]] = m[2]; + return o; +} +function gradBlock(svg, id) { + const i = svg.indexOf('id="' + id + '"'); + if (i < 0) throw new Error('gradient not found: ' + id); + const start = svg.lastIndexOf('<', i); + const isRadial = svg.slice(start, i).includes('radialGradient'); + const close = isRadial ? '' : ''; + const end = svg.indexOf(close, i) + close.length; + return { block: svg.slice(start, end), radial: isRadial }; +} +function stopsOf(block) { + return [...block.matchAll(/]*\/?>/g)].map(m => { + const a = attrs(m[0]); + const s = { o: Number(a.offset), c: a['stop-color'] }; + if (a['stop-opacity'] != null) s.a = Number(a['stop-opacity']); + return s; + }); +} +function linOf(svg, id) { + const { block } = gradBlock(svg, id); + const a = attrs(block.slice(0, block.indexOf('>'))); + return { x1: +a.x1, y1: +a.y1, x2: +a.x2, y2: +a.y2, stops: stopsOf(block) }; +} +function radOf(svg, id) { + const { block } = gradBlock(svg, id); + const a = attrs(block.slice(0, block.indexOf('>'))); + const gt = a.gradientTransform || ''; + const tr = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)\s*\)/.exec(gt); + const ro = /rotate\(\s*([-\d.]+)\s*\)/.exec(gt); + const sc = /scale\(\s*([-\d.]+)\s*\)/.exec(gt); + if (!tr || !sc) throw new Error('unexpected gradientTransform: ' + gt); + return { cx: +tr[1], cy: +tr[2], rot: ro ? +ro[1] : 0, r: +sc[1], stops: stopsOf(block) }; +} +const r6 = n => Math.round(n * 1e6) / 1e6; +function scaleLin(g, s, off) { + return { x1: r6(g.x1 * s + off), y1: r6(g.y1 * s + off), + x2: r6(g.x2 * s + off), y2: r6(g.y2 * s + off), stops: g.stops }; +} +function scaleRad(g, s, off) { + return { cx: r6(g.cx * s + off), cy: r6(g.cy * s + off), rot: g.rot, r: r6(g.r * s), stops: g.stops }; +} + +/* ---------- read the real layers --------------------------------------- */ +const fgSvg = read(ADP + '/ic_launcher_foreground.svg'); +const bgSvg = read(ADP + '/ic_launcher_background.svg'); +const monoSvg = read(ADP + '/ic_launcher_monochrome.svg'); +const conSvg = read('F:/Coding/SoulStream/src-tauri/icons/icon.svg'); + +const fgTag = /]*>/.exec(fgSvg)[0]; +const monoTag = /]*>/.exec(monoSvg)[0]; +const fgA = attrs(fgTag), monoA = attrs(monoTag); + +if (fgA.d !== monoA.d) console.warn('WARN: monochrome path d differs from foreground'); +else console.log('ok monochrome path d is byte-identical to the foreground path'); +if (fgA.transform !== monoA.transform) console.warn('WARN: transforms differ'); +else console.log('ok both layers share transform:', fgA.transform); + +const flat = attrs(/]*>/.exec(bgSvg)[0]).fill; +console.log('ok kit adaptive background =', flat); + +// transform: translate(a b) scale(s) translate(c d) +const tm = /translate\(([-\d.]+)\s+([-\d.]+)\)\s*scale\(([-\d.]+)\)\s*translate\(([-\d.]+)\s+([-\d.]+)\)/ + .exec(fgA.transform); +if (!tm) throw new Error('unexpected foreground transform'); +const tParsed = [ +tm[1], +tm[2], +tm[3], +tm[4], +tm[5] ]; +console.log('ok optical offset preserved verbatim: translate(' + tm[4] + ' ' + tm[5] + ')'); + +const fgGrad = linOf(fgSvg, 'adaptive-gradient'); +const surf1024 = linOf(conSvg, 'rounded-4-surface-gradient'); +const glow1024 = radOf(conSvg, 'rounded-4-glow-gradient'); +console.log('ok concept 04 surface axis :', JSON.stringify(surf1024).slice(0, 120)); +console.log('ok concept 04 glow : centre', glow1024.cx + ',' + glow1024.cy, + 'r', glow1024.r, 'peak alpha', glow1024.stops[0].a, glow1024.stops[0].c); + +const S_LAYER = 108 / 1024; // B: 1024 artwork spans the whole 108 layer +const S_MASK = 72 / 1024; // C: 1024 artwork spans only the visible 72 window + +const gradL = { surf: scaleLin(surf1024, S_LAYER, 0), glow: scaleRad(glow1024, S_LAYER, 0) }; +const gradM = { surf: scaleLin(surf1024, S_MASK, 18), glow: scaleRad(glow1024, S_MASK, 18) }; + +/* ---------- composite sampling ----------------------------------------- */ +const hex2rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); +const rgb2hex = c => '#' + c.map(v => Math.round(Math.max(0, Math.min(255, v))) + .toString(16).padStart(2, '0')).join('').toUpperCase(); +function sampleStops(stops, t) { + t = Math.max(0, Math.min(1, t)); + let a = stops[0], b = stops[stops.length - 1]; + for (let i = 0; i < stops.length - 1; i++) + if (t >= stops[i].o && t <= stops[i + 1].o) { a = stops[i]; b = stops[i + 1]; break; } + const f = b.o === a.o ? 0 : (t - a.o) / (b.o - a.o); + const ca = hex2rgb(a.c), cb = hex2rgb(b.c); + const al = (a.a == null ? 1 : a.a), bl = (b.a == null ? 1 : b.a); + return { rgb: ca.map((v, i) => v + f * (cb[i] - v)), a: al + f * (bl - al) }; +} +function linT(g, p) { + const vx = g.x2 - g.x1, vy = g.y2 - g.y1; + return ((p[0] - g.x1) * vx + (p[1] - g.y1) * vy) / (vx * vx + vy * vy); +} +function composite(v, p) { + if (v === 'flat') return rgb2hex(hex2rgb(flat)); + const g = v === 'gradL' ? gradL : gradM; + const s = sampleStops(g.surf.stops, linT(g.surf, p)).rgb; + const d = Math.hypot(p[0] - g.glow.cx, p[1] - g.glow.cy); + const gl = sampleStops(g.glow.stops, d / g.glow.r); + return rgb2hex(s.map((v2, i) => gl.rgb[i] * gl.a + v2 * (1 - gl.a))); +} +const R = 36 / Math.SQRT2; +const POINTS = [ + ['visible NW corner (18, 18)', [18, 18]], + ['circle NW rim (' + r6(54 - R).toFixed(1) + ', ' + r6(54 - R).toFixed(1) + ')', [54 - R, 54 - R]], + ['mask centre (54, 54)', [54, 54]], + ['circle SE rim (' + r6(54 + R).toFixed(1) + ', ' + r6(54 + R).toFixed(1) + ')', [54 + R, 54 + R]], + ['visible SE corner (90, 90)', [90, 90]] +]; +const samples = POINTS.map(([where, p]) => ({ + where, v: ['flat', 'gradL', 'gradM'].map(v => composite(v, p)) +})); +console.log('\n point A flat B layer C mask'); +for (const s of samples) console.log(' ' + s.where.padEnd(34) + s.v.join(' ')); + +/* ---------- shipping raster -------------------------------------------- */ +const pngPath = 'F:/Coding/SoulStream/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png'; +const todayPng = 'data:image/png;base64,' + fs.readFileSync(pngPath).toString('base64'); +console.log('\nok shipping raster embedded:', pngPath); + +/* ---------- emit -------------------------------------------------------- */ +const DATA = { + flat, + fgPath: { d: fgA.d, t: fgA.transform, tParsed }, + monoPath: { d: monoA.d, t: monoA.transform }, + fgGrad, gradL, gradM, samples, todayPng, + themed: { + light: { ground: '#DCE3E9', ink: '#44515E' }, + dark: { ground: '#2A3138', ink: '#C6D3DF' } + } +}; + +const tpl = read(path.join(OUT, 'template.html')); +if (!tpl.includes('/*__DATA__*/null')) throw new Error('template marker missing'); +const html = tpl.replace('/*__DATA__*/null', JSON.stringify(DATA)); +const dest = path.join(OUT, 'icon-mock.html'); +fs.writeFileSync(dest, html, 'utf8'); +console.log('ok wrote', dest, (html.length / 1024).toFixed(1) + ' KB'); diff --git a/handoff/notes/icon-mock.html b/handoff/notes/icon-mock.html new file mode 100644 index 0000000..a73bfa4 --- /dev/null +++ b/handoff/notes/icon-mock.html @@ -0,0 +1,376 @@ + + + + + +SoulStream Android icon — flat vs concept 04 gradient + + + +
+ +
+

SoulStream Android launcher icon — background layer decision

+ real kit layers · true 108/72 adaptive geometry · nothing here is shipped +
+ +
+
+
+
+ + +
+ + + + +
+ +

Size ladder — same mask, same wallpaper

+
Neutral ground
+ +
+ +
+ + + + diff --git a/handoff/rounds/commit-msg.txt b/handoff/rounds/commit-msg.txt new file mode 100644 index 0000000..572a42e --- /dev/null +++ b/handoff/rounds/commit-msg.txt @@ -0,0 +1,35 @@ +android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found + +The desktop's two playlist pages on the phone: the shelf (pinned first, +mosaic covers, badges, New playlist, Import) and the page (kind, Pinned +chip, description, the meta line's warnings, Play / Shuffle / more, a +filter, rows tagged unresolved / set audio / file missing). Sheets for a +card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm; +Add to playlist on every track and record sheet; a removal undoable for +seven seconds from the neutral toast (notice.svelte.js); stream rows play +through the resolver as path-less entries, a set slice starts and ends at +its cues; Add to library / Download all tracks with the desktop's bulk +rules; nav.replace for a review that became the thing it made. + +The import doors that need no file — a link (YouTube · Spotify · Apple +Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the +review screen and the desktop's catalog verification pass. A share plugin +crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps +the text in a ShareActivity and the WebView hands it to the import when it +comes back to the front. You → Sources: the source-quality window and the +priority per mode. + +Found by the walks and fixed: set_settings takes camelCase arguments, so +the phone's normalization and library-folder saves never landed; +Cover.svelte's non-eager wrapper has no layout box, so its intersection +observer never fired and row thumbnails never loaded (the Artists tab too); +a history push while a history.go was still in flight left the nav mirror +one entry ahead and the page blank after a confirmed delete — the mirror +now applies once per microtask and never pushes during a pending traversal. + +The harness names unmocked commands, carries console warnings' frames (the +smoke bundle is built unminified), probes the history mirror on every step, +mocks app_status / play_local / waveform / lyrics_get / player_state and +applies settings patches, runs adb steps on the phone (the share intent), +and walks playlists, the imports, the share and the settings on both the +mock and the phone. ANDROID.md documents all of it. diff --git a/handoff/rounds/commit-msg13.txt b/handoff/rounds/commit-msg13.txt new file mode 100644 index 0000000..46ec14e --- /dev/null +++ b/handoff/rounds/commit-msg13.txt @@ -0,0 +1,8 @@ +android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down + +Without a drag: a playlist row's sheet moves it up, down or to the top and +persists the whole order in one call (playlist_set_order, as the desktop's +drop does); a queue row's sheet moves it up or down, never past the track +playing. Also src-tauri/android/ship.sh — one round end to end: the mock +walk, the embedded build installed, the phone walk, the commit, each step +gated on the one before. diff --git a/handoff/rounds/commit-msg14.txt b/handoff/rounds/commit-msg14.txt new file mode 100644 index 0000000..e409f54 --- /dev/null +++ b/handoff/rounds/commit-msg14.txt @@ -0,0 +1,5 @@ +android: the room list filtered by name, the rest on demand + +Soulseek lists hundreds of rooms; the phone showed the first 80 and no way +to the rest. Community → Rooms now has a filter by name and extends the +list 200 at a time. diff --git a/handoff/rounds/commit-msg15.txt b/handoff/rounds/commit-msg15.txt new file mode 100644 index 0000000..0142543 --- /dev/null +++ b/handoff/rounds/commit-msg15.txt @@ -0,0 +1,8 @@ +android: a playlist's cover from a photo on the phone + +The storage plugin gains pick_image — the document picker limited to +images, the picked content copied into the app's cache so the Rust side +gets a plain path. playlist_cover_set(id, path) is the desktop pick's +install step split out and shared (squared, the master cover, the row's +revision moved). A playlist's sheet offers Choose cover photo and Remove +custom cover; the mock walk exercises both. diff --git a/handoff/rounds/commit-msg16.txt b/handoff/rounds/commit-msg16.txt new file mode 100644 index 0000000..6e3118b --- /dev/null +++ b/handoff/rounds/commit-msg16.txt @@ -0,0 +1,6 @@ +android: hours on the library's count line, By format, Jump back in on the empty search + +The desktop Library summary's hours on the phone's count line; a By format +order from the file stats (lossless by depth and rate first, then by +bitrate, the badge as the card's line); the empty Search page's Jump back +in — the last records played — as the desktop's empty search shows them. diff --git a/handoff/rounds/commit-msg17.txt b/handoff/rounds/commit-msg17.txt new file mode 100644 index 0000000..f773a01 --- /dev/null +++ b/handoff/rounds/commit-msg17.txt @@ -0,0 +1,5 @@ +android: the nav's badges — transfers in flight on You, unread chats on Community + +The desktop's title-bar download ring and its unread indicator, on the +phone's nav: a count on You while Save is fetching, a count on Community +when a chat has unread lines. diff --git a/handoff/rounds/commit-msg18.txt b/handoff/rounds/commit-msg18.txt new file mode 100644 index 0000000..837d131 --- /dev/null +++ b/handoff/rounds/commit-msg18.txt @@ -0,0 +1,6 @@ +android: the hub's filters on the network results, pasted lyrics + +The raw results take the Soulseek hub's filters — a free slot only, FLAC +or MP3 320+ — as chips above the folders. The lyrics sheet has the +desktop's paste door: plain text, or LRC with timestamps, kept for the +track. diff --git a/handoff/rounds/commit-msg19.txt b/handoff/rounds/commit-msg19.txt new file mode 100644 index 0000000..9365b41 --- /dev/null +++ b/handoff/rounds/commit-msg19.txt @@ -0,0 +1,7 @@ +android: downloads on Wi-Fi only + +One switch the desktop never needed, off by default: on mobile data every +Save the phone can start — a record, a track, a folder, a file, a +playlist's bulk, the now-playing Save — waits, with the reason on the bar; +streaming is never held back. The walk flips the switch, makes the +connection cellular and sees a download refused. diff --git a/handoff/rounds/commit-msg2.txt b/handoff/rounds/commit-msg2.txt new file mode 100644 index 0000000..545b18a --- /dev/null +++ b/handoff/rounds/commit-msg2.txt @@ -0,0 +1,30 @@ +android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest + +The playlist repair (Find a local match: the library searched by the +row's name, a pick relinks it through playlist_relink_item; an unknown +row takes the track's identity). A peer's sheet on the raw results (play +or save the folder, browse their files, message, buddy) and the Browse +screen — the hub's Browse tab: a user's whole share with sizes and totals, +a folder played or saved whole; a share can hold thousands of folders, so +it filters and renders 80 at a time. "Look up a user" on Community → +Buddies. The sheets the phone lacked: a queue row's (Play now, Move to +next, Remove from queue), a catalog song's, a catalog card's and the +record page's own, a raw file's (in the results and in Browse), a room +name's, a buddy's, the now-playing Save (the desktop's Save menu for a +streamed track or record). The artwork viewer with pinch zoom, one layer +the back gesture peels. Search's recent searches and its spelling +suggestion from the names this phone knows. You → Streaming, Downloads, +Sharing, Lyrics, Album artwork, and Clear play history; a record's +Re-fetch the cover. The lyrics tools on the sheet's face: the offset +nudged and kept per track, another LRCLIB candidate chosen, the lookup +forgotten and done again (lyrics.svelte.js, shared by the face and the +lyrics sheet). + +The harness: every CDP call times out and the reload step re-attaches +(a WebView that stopped answering hung a walk for ten minutes); the +audio check counts SoulStream's own tracks' underruns from the mixer dump +instead of the thread-wide counters that any app's notification moves — +the afternoon's "dropouts" were other apps' sounds, and the browse of a +49,544-file share measured at zero; __settle resets every tab to its root +through the nav; an adb step kind; the walks cover all of the above on +the mock and on the phone. diff --git a/handoff/rounds/commit-msg20.txt b/handoff/rounds/commit-msg20.txt new file mode 100644 index 0000000..e74af9f --- /dev/null +++ b/handoff/rounds/commit-msg20.txt @@ -0,0 +1,11 @@ +android: a peer in full — their card, their interests, and what they have that you don't + +The peer sheet's new "About this user" row opens a screen of its own: the +card their client serves (their own words, their picture, the upload slot +and the queue), the likes and hates they declared, and the comparison this +phone makes of their share against the library. The comparison is the +point of opening a stranger — every record they have by an artist in your +library that you don't, each row opening the search that finds it, and a +way to forget the whole comparison again. Nothing is uploaded to work it +out. Their picture opens in the artwork viewer, which now takes a data: +key beside a library path and an https URL. diff --git a/handoff/rounds/commit-msg21.txt b/handoff/rounds/commit-msg21.txt new file mode 100644 index 0000000..e7a9df6 --- /dev/null +++ b/handoff/rounds/commit-msg21.txt @@ -0,0 +1,12 @@ +android: a name of your own for anyone on the network + +The desktop's local contact metadata on the phone: a peer's sheet ends in +Name and notes — a petname, a group, a private note and the Favorite and +Trusted marks, kept in the app's own config and published nowhere. The +petname stands in for the username on the buddy list, in the chat list, on +a thread's bar, in the peer sheet, in a room's roster and on a room line, +and the real username stays with it — on the line beneath, or beside the +name in a room — so a stranger is never quietly turned into someone you +know. Favourites sit at the top of the buddy list, and clearing a person's +details drops the entry rather than storing an empty one — the backend's +own rule. diff --git a/handoff/rounds/commit-msg22.txt b/handoff/rounds/commit-msg22.txt new file mode 100644 index 0000000..4c2ca4e --- /dev/null +++ b/handoff/rounds/commit-msg22.txt @@ -0,0 +1,11 @@ +android: your own Soulseek interests, and who the server says is like you + +Community's fourth tab is Taste: the likes and hates this client +publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set), +added and taken back as chips, and the people the server ranks as close to +them (slsk_similar_users) — a row each into the peer sheet the app already +has. Every declaration asks the server again and the chips stay quiet +until it has answered, the way the desktop does, because the ranking only +moves when its input does. The lists live in taste.svelte.js, read once +per account when the tab is opened; the mock walk declares a like, watches +the match grow, declares a hate and takes the like back. diff --git a/handoff/rounds/commit-msg23.txt b/handoff/rounds/commit-msg23.txt new file mode 100644 index 0000000..77ca6a7 --- /dev/null +++ b/handoff/rounds/commit-msg23.txt @@ -0,0 +1,12 @@ +android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy + +The editor the desktop drives with a mouse is better under a thumb: the +song plays, the line to time sits large in the middle of the screen with +the one behind and the one ahead faint around it, and a tap anywhere +stamps it. One layer the back gesture peels, and a set left mid-tap is +offered back by the row that opened it; Save writes the track's own +document, lines never tapped left out of it and the button saying how +many are kept. The lyrics sheet also gains the per-track auto-sync switch +and the desktop's reduced-vocals copy — the same title, artist and +duration, so the words keep following it, and one row puts the original +back. diff --git a/handoff/rounds/commit-msg24.txt b/handoff/rounds/commit-msg24.txt new file mode 100644 index 0000000..5bbfd2b --- /dev/null +++ b/handoff/rounds/commit-msg24.txt @@ -0,0 +1,11 @@ +android: a folder of audio files, and a paste of track links + +The paste door now reads itself the way the desktop's does: one track link +in the paste and every line is looked up on its own public page +(playlist_import_track_links), which is the way past Spotify's 100-track +public ceiling — the wait screen counts them as they arrive. A fourth door +imports a folder of audio files off this phone, picked with the storage +plugin's own document-tree picker; playlist_import_folder grew a path +argument for it, because on Android it had no dialog to open and always +answered nothing. A YouTube playlist link needed no door at all — the link +one already dispatches it. diff --git a/handoff/rounds/commit-msg25.txt b/handoff/rounds/commit-msg25.txt new file mode 100644 index 0000000..76a118d --- /dev/null +++ b/handoff/rounds/commit-msg25.txt @@ -0,0 +1,13 @@ +android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo + +Popular is asked on its own as well as with the page, so the section can +paint before the full page is built and an artist whose page came back +with none still gets one. A catalog record's rows that the album's own +artist anchor found no count for — a feature's guest, a compilation's +stranger — get the deferred Last.fm pass by their own artist id. The +artist page's ⋯ is its own sheet now: choose a hero photo from this phone, +or put the catalog's own back; set_artist_hero is pick_artist_hero's +install step split out and shared, so the desktop's dialog keeps its door. +You gains the desktop's Refresh artist portraits, and a data: URL is the +image itself in the art gate — a chosen hero comes back from Rust as one, +and the thumbnail fetch used to throw it away. diff --git a/handoff/rounds/commit-msg26.txt b/handoff/rounds/commit-msg26.txt new file mode 100644 index 0000000..a93decf --- /dev/null +++ b/handoff/rounds/commit-msg26.txt @@ -0,0 +1,14 @@ +android: a streamed file copied into a folder, the listening port, the dev build's diagnostics + +The now-playing sheet's streamed track can be copied into a folder picked +on the phone, as the file it is — the cache written out, nothing downloaded +again, no library row (copy_stream_to_folder, which the desktop has had a +command for and no UI). You → Sharing carries the listening port, what +peers dial when they download from you: unset is the client's own 50300, +the app asks the router to forward it over UPnP and falls back to the +server relaying, and a change applies on the next connect. The dev flavour +gets the desktop's debug overlay as a screen under This phone — CPU, +memory, the network, the stream and its loudness, what the pre-buffer is +fetching, the size of the play history — behind devBuild in util.js, which +is Vite's own DEV flag, so the embedded build the phone keeps has no row +to tap. diff --git a/handoff/rounds/commit-msg27.txt b/handoff/rounds/commit-msg27.txt new file mode 100644 index 0000000..837d545 --- /dev/null +++ b/handoff/rounds/commit-msg27.txt @@ -0,0 +1,15 @@ +android: the phone turned sideways — the player beside itself, the chrome cut down + +The phone UI was drawn portrait-first and had never been looked at in +landscape. The player sheet was the casualty: its cover is a square sized by +width, so at 915×411 it came out 879px tall and the title, the scrubber, the +transport and the face buttons all sat below the bottom of a sheet that does +not scroll — a corner of the artwork and no way to pause. Sideways the sheet +is two columns now, the cover sized by height beside the controls, with a +lyrics or queue face taking the cover's column; the nav and the mini bar come +down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has +always been rather than 300px of a 411px screen, the record head puts the +cover beside the title, and the shell reads the side insets the cutout and the +gesture pill take when the phone is on its side. The walk turns the phone over +and back on its own: `{ view: [w, h] }` is a step kind now, and the steps +assert what used to be off the screen. diff --git a/handoff/rounds/commit-msg28.txt b/handoff/rounds/commit-msg28.txt new file mode 100644 index 0000000..c7a1860 --- /dev/null +++ b/handoff/rounds/commit-msg28.txt @@ -0,0 +1,17 @@ +android: what a screen reader says — names, roles, and the sheets out of the tree when they are down + +Every control on the phone has a name now, in the app's own words, and the +desktop's word where it already has one; the nav is a tab set whose badge +count is part of the tab's name — "You, 3 transfers" rather than a stray 3 +read out before it. The two sheets are dialogs with somewhere sensible for +the focus to land, and the half sheet's grab handle is finally a real +Close — the first dismissal a screen reader could reach, because the scrim +never was one. A sheet that is down is inert rather than sitting +off-screen in the accessibility tree with the hidden mini bar and the nav +under it, and one live region is mounted for the life of the app so that +an undo offer is actually announced; switches say on or off, chips say +pressed, and the waveform is a slider where it can seek. Nothing visible +changed. The walk counts the controls with no name, with a junk name, in a +hidden layer, or with unsaid state — all four held at zero on ten screens, +and the nameless count on a room and on both prompts besides, which is +where the six fields named here actually live. diff --git a/handoff/rounds/commit-msg29.txt b/handoff/rounds/commit-msg29.txt new file mode 100644 index 0000000..cf4adfe --- /dev/null +++ b/handoff/rounds/commit-msg29.txt @@ -0,0 +1,14 @@ +android: the downloads say so in the shade + +A Save still running with the app in the background now posts a progress +notice — how many files, the one it is on, a bar, Stop all and Open — and +takes it down when the queue empties. It sits on its own low-importance +channel so it can be silenced without silencing the player's card, and +rides its own dataSync foreground service rather than the media one: a +mediaPlayback service stops itself when the bar empties, a download is not +playback, and nothing else was keeping the process alive for a Save with +nothing playing — which is how the first real Save died, four tracks in. +Stop all goes through the same cancel(id) a row's tap takes, and Transfers +grew the matching button beside Clear finished. The walk pushes two rows +straight into the transfer store and reads the real notification back out +of `dumpsys notification`, without fetching anything. diff --git a/handoff/rounds/commit-msg3.txt b/handoff/rounds/commit-msg3.txt new file mode 100644 index 0000000..590f4e7 --- /dev/null +++ b/handoff/rounds/commit-msg3.txt @@ -0,0 +1,8 @@ +android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm + +The desktop Library's per-record file stats on the phone (library_album_stats, +one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards, +the size on the list rows and the record page. The artist page's own +listening (artist_play_stats): the time played as a chip, the most played +track under "On this phone". The whole-library thumbnail warm a moment after +boot, at the cards' edge, one decode at a time behind the tiles' gate. diff --git a/handoff/rounds/commit-msg30.txt b/handoff/rounds/commit-msg30.txt new file mode 100644 index 0000000..7531d33 --- /dev/null +++ b/handoff/rounds/commit-msg30.txt @@ -0,0 +1,19 @@ +android: the room can measure the song, and a cold artist page stops waiting on one call + +The tap-to-sync room draws the recording's own vocal band +(lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the +head, and one button lays every line across the stretches it says are sung — +the desktop's own generator, shared and unit-tested, which refuses rather +than guesses when there is too little voice to read. The result is labelled +an estimate everywhere it shows, the cursor goes back to the top so the taps +correct it, and one more tap drops the whole thing back to an empty set, +because a rough fill stamps every line at once and leaves Undo nothing to +take back. The artist page now fires the hero seed and the overview beside +the full page, the way the desktop stages them: a hero Kyle picked on this +phone comes back off the file with no network at all, the discography paints +from the overview's first page until the exhaustive pass lands with the +classified one, and both lanes are dropped with the page when a hero +changes. ANDROID.md's "Not done yet" is rewritten as the true map of the +port — what is absent by design and why, what is waiting on Rust rather than +on a file picker, and one bullet about the artwork viewer that had been done +for rounds. diff --git a/handoff/rounds/commit-msg31.txt b/handoff/rounds/commit-msg31.txt new file mode 100644 index 0000000..6f2e756 --- /dev/null +++ b/handoff/rounds/commit-msg31.txt @@ -0,0 +1,29 @@ +android: what the phone says when things go wrong + +Fifty-nine places put a raw Rust string into a field a person reads, and +the red toast drew it verbatim — a phone in a lift showed "could not reach +server: io: failed to lookup address information" under the password box. +One translator, src/mobile/trouble.js, called where an error is DRAWN +rather than where it is caught: the stores keep what Rust said and only +the sentence on screen is ours, so every sink is fixed at once. Five +screens that drew a failed read as an empty answer stop lying — Tracks no +longer reports a broken database as an empty library, a playlist that +could not be read is not an empty playlist, the lyrics face says a lookup +failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste +lyrics, Gaps says why it could not reach the catalogue, and Search signed +out says so where it used to render nothing at all. The room list, which +is fire-and-forget over the wire, gets a twelve-second end and a Try +again. + +rescan_library ran prune_missing() first, which deletes every track row +whose file does not exist — so one tap on You → Rescan library with the +folder unreachable emptied the whole index and returned Ok(()). It now +makes the root if it is merely not there yet and refuses if it cannot be +read, and You draws the path with what to do about it. + +The mock grew a weather system so the walk can drive all of this: fail, +answer, offline and clear, checked before dispatch so a plugin command +arms like any other. Nine mock steps and one on the phone — the first +steps in thirty rounds that ever made a command reject or saw the client +signed out — and the last of them puts the weather away and leaves the app +as it found it. diff --git a/handoff/rounds/commit-msg32.txt b/handoff/rounds/commit-msg32.txt new file mode 100644 index 0000000..585264a --- /dev/null +++ b/handoff/rounds/commit-msg32.txt @@ -0,0 +1,54 @@ +android: the boot chain goes out in one round, and a census that holds it there + +Thirty rounds of onMount had never been read end to end, and read end to end +it was a queue: two dozen commands with a critical path ten deep, because +Tauri's `listen` is an invoke like any other. loadLibrary asked for five +independent SQLite reads one behind the other, bindSlsk registered eight +listeners one behind the other, and watchLibrary and the player's bindEvents +did two each. None of them depended on anything before them. They go out in +one round now — the same commands, critical path 10 to 3, and ten of them +used to leave in the shell's own first turn where twenty-three do now — and +in a rig that runs the real store modules with the bridge instrumented, +lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead +of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip +swept rather than measured; the phone's own numbers are unproven until the +walk prints bootMs and bootLibMs. + +The behaviour is meant to be identical. loadLibrary's only movable part was +lib.error, which has two writers and used to be decided by the order they were +awaited in; the two messages are now carried out of the round and applied in +that same order, so a failing library still shows the library's message. Every +combination of which of the five reads fails, over six latency shapes, comes +out identical to the old code. bindSlsk still does not return until every +handler is up, so connectStored cannot let a fast connected state land with +nobody listening, and bindEvents still hands back the transport's unlisten and +still rejects only when the transport is the one that failed. + +src/lib/boot.js is the project's first launch instrument — one counter every +invoke and every listen passes, sealed at the first frame the shell paints and +never re-opened, so a probe landing a second and a half later still reads the +launch. perf.mjs measures frames; nothing measured the launch. The mock walk's +Home step now holds a budget against it and both walks print bootCalls, +bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no +clock in it: a call that waits on the one before it can only issue its first +in the shell's own turn, so a chain put back in a queue falls under the floor +whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there +at all, which is the census itself being broken rather than the app. + +One import line goes with it: Sheet.svelte's `import * as api`, the only one +of the ten dead imports this round turned up whose reason is the bundle at +all. Rollup already drops an unreferenced NAMED import, so a dead name on an +import line is hygiene rather than bytes; a namespace import is the one shape +tree-shaking cannot see through, and this one was dead. It frees nothing on +its own — twenty-nine other files in the phone's graph hold the same module +the same way, so api.js is pinned into the chunk whatever Sheet.svelte does — +but it is the only one of the ten that belongs to a round about weight. The +nine that are drift go out with the sweep in the round after this one. + +Two things were looked at and left alone, both written down in ANDROID.md +rather than half-done. Home's per-artist catalog walk is the deepest chain in +the app, and that serial walk is the Deezer and MusicBrainz pacing, not an +oversight. And the follows really are read twice at launch, but the obvious +guard does not work: Home's effect calls loadHome with force the first time +they land, so the guard would never fire. One small local read, left for a +round that can do it properly. diff --git a/handoff/rounds/commit-msg33.txt b/handoff/rounds/commit-msg33.txt new file mode 100644 index 0000000..5c4f5f5 --- /dev/null +++ b/handoff/rounds/commit-msg33.txt @@ -0,0 +1,23 @@ +android: the sweep — what thirty rounds left behind, and the byte that hid a file from grep + +Nothing here changes what the phone does, and the walks are untouched on +purpose: they are the guard, not the subject. Out go nine imports no file +ever names again, two things nothing in src, tests, crates or src-tauri +has ever referenced — `forgetAccount` in the Soulseek store, whose Sign +out row calls `disconnect`, and `isLossless` in util.js — and two CSS +rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and +`fmtCount` had each been written twice word for word, so both bodies move +into util.js unchanged and four screens gain a name on an import line they +already had. The byte is in player.svelte.js, where the play-history key +joins artist and title on a NUL that was written into the source as the +character itself rather than as the escape catalog.svelte.js already uses +— that made the file non-text, so every grep of it since has answered +"Binary file matches" and shown nothing. One line is added rather than +removed: `.mini .mini-lane { width: auto }` reads exactly like the strays +and is the only thing keeping the lane from coming out 32px wider than the +strip it was cut for, so it now says so — and `.feat`, declared twice in +shell.css with different values, is left alone, because untangling that +moves pixels and this round may not. A tenth dead import is missing from +that first list on purpose: Sheet.svelte's `import * as api` is a namespace +import, the one shape Rollup cannot shake, so its reason was the bundle +rather than drift and it went out with the round before. diff --git a/handoff/rounds/commit-msg34.txt b/handoff/rounds/commit-msg34.txt new file mode 100644 index 0000000..5ee0bb5 --- /dev/null +++ b/handoff/rounds/commit-msg34.txt @@ -0,0 +1,31 @@ +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. diff --git a/handoff/rounds/commit-msg35.txt b/handoff/rounds/commit-msg35.txt new file mode 100644 index 0000000..073e0e2 --- /dev/null +++ b/handoff/rounds/commit-msg35.txt @@ -0,0 +1,20 @@ +android: a playlist, out of the phone + +The desktop has had Export since playlists v2 and the phone had no door onto +it for one honest reason: `tauri-plugin-dialog` does open Android's +create-document sheet, but it answers with a `content://` URI and +`playlist_export` wrote with `std::fs::write`, which cannot take one. The +storage plugin grew a `create_document` whose Kotlin writes the bytes through +the ContentResolver — a plugin function called from Rust, not a command, so +the file never passes through the WebView and nothing was added to the ACL — +and `playlist_export` now builds the file above the platform fork with the +same `playlist_formats` writers, so the phone rebuilds no format and the two +exports cannot drift. The door is the playlist's own sheet, on the page and +on the shelf card, with the desktop's five profiles in the desktop's order; a +dismissed sheet says nothing, because nothing happened. The mock walk drives +it three ways — a file written where the sheet pointed, a sheet dismissed, a +refusal read back as a sentence — and the phone walk opens the real sheet and +dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt: +nothing in this tree compiles it, though it is written against +tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than +against a guess at them. diff --git a/handoff/rounds/commit-msg36.txt b/handoff/rounds/commit-msg36.txt new file mode 100644 index 0000000..15552b2 --- /dev/null +++ b/handoff/rounds/commit-msg36.txt @@ -0,0 +1,32 @@ +android: a playlist file, picked + +The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`: +the document sheet with the caller's own filter, answered with a path Rust +can open. A document already on this phone hands its path over as it lies — +the document sibling of the same volume:relative id the folder picker +resolves, plus the Downloads provider's raw: — so a 300 MB pick copies +nothing; one that has no path is copied into cacheDir/picked under its own +name, and only as far as the caller allows, because a playlist is kilobytes +and a DJ set is not. Those copies are swept at the start of every pick, +which pick_image's never were. + +The import sheet has its fifth door, on round 24's screen, wait card and +review, and the note that apologised for its absence says the one true thing +about it instead. `playlist_import_probe` took a path and a name the way +`playlist_import_folder` took a path, and the name is the load-bearing half: +the parser picks its format off the extension and titles the draft from the +stem, so a cache copy under an invented name would have read every playlist +as plain text. The mock walk drives the door and both of the picker's +refusals through round 31's fail door; the phone walk opens the real sheet +and dismisses it with BACK, reading nothing and writing nothing. + +The brief asked for a second consumer — sending a file in a chat — and there +is nothing behind it: `community_pick_file` is the persona layer's three +native dialogs, `soulseek-client` has `say_room` and `message_user` and +nothing that sends a file, and both frontends' thread composers are text +only. Nothing was invented for it. The plugin command is general enough — a +caller's own filter, a zero-copy path, a bounded copy — that the one reader +still waiting, the local set listener the phone puts off by design, needs no +further plugin change. Playlist export is not on that list and never was: +writing a document out is the share plugin's errand, not a picker's. The +Kotlin is unbuilt — nothing on this machine compiles it. diff --git a/handoff/rounds/commit-msg37.txt b/handoff/rounds/commit-msg37.txt new file mode 100644 index 0000000..510cf71 --- /dev/null +++ b/handoff/rounds/commit-msg37.txt @@ -0,0 +1,40 @@ +android: the rows go where you put them — a real drag on the playlist and the queue + +The phone stood in for the desktop's drag with Move up / Move down / Move to +the top on a row's sheet, which is fine but is not what a finger is for. Press +and hold a row for 320ms now and it lifts, the rows below make room, and +letting go lands it — on the playlist page and on the player sheet's queue, +through one action in src/mobile/reorder.js. The Move rows stay: they are the +accessible path round 28 named to a screen reader, and they are the only way +to reorder while the filter is on. Release a press without having moved it and +the row's sheet opens exactly as a long press always opened it, which is what +keeps the queue's sheet reachable at all — a queue row has no ⋯, so +`oncontextmenu` is its only door to Play now, Move to next and Remove from +queue. + +Most of the work is in not fighting the page. Nothing carries +`touch-action: none`, because a flick that starts on a row is how the list is +scrolled and `touch-action` is latched at touchstart anyway; the scroll is +refused instead by a non-passive `touchmove` listener added at the lift, which +works precisely because the finger has been still for 320ms and the browser +has not started one. A press that moves more than 8px before then is a scroll +and the gesture lets go of it. Click and contextmenu guards sit on the +container in the capture phase while a lift is live, or a released drag would +fire the row's own click and play the track. Rows are measured once at the +lift in the scroller's content space, so the edge pull — within 64px of either +edge — leaves the snapshot true, and each displaced row moves by the exact +difference between its top and its neighbour's rather than by one assumed row +height, because the queue face puts album separators between rows. + +No new mutation and no new command: `moveItem` and `moveInQueue` each take a +numeric target now as well as "up" / "down" / "top", so there is still exactly +one call to `playlist_set_order` carrying the whole id order, and the existing +queue guards were already the right guards. The drop is optimistic and snaps +back with the error toast if the backend says no. Two refusals cover the +middle of a gesture — the playlist drops nothing if a refetch has replaced its +rows, and the queue drops nothing if `player.qi` has moved — and dragging is +off entirely while the playlist's filter has anything in it, because a +filtered list's visible order is not the stored order. Both walks drive it +with real pointer events and assert the order out of the store: a drag, a drag +back, a refusal under the filter, a press that still opens the sheet, and a +drop the backend is armed to reject. diff --git a/handoff/rounds/commit-msg38.txt b/handoff/rounds/commit-msg38.txt new file mode 100644 index 0000000..a348b0a --- /dev/null +++ b/handoff/rounds/commit-msg38.txt @@ -0,0 +1,19 @@ +android: the whole discography, on a page of its own + +The artist page already filed every release the desktop's way, but it only +ever showed one category at a time and it showed all of it: no slice anywhere +in that grid, so an artist with 140 releases drew 140 cards. There is a page +now — All in front of the categories, a sort, a grouping by year, and the +editions opened in place under their own record, which is the part a flat +list loses — and the grid behind it stops at twelve with the way down under +it. It is the same classifier twice over the same cached albums, which the +desktop cannot do because its filing waits on a background description +harvest; the three things its own page has and this one does not — a +tracklist per record, an accent sampled per cover, the scroll-led banner — +are 140 calls and 140 decodes over mobile data for rows that are one tap +away, and ANDROID.md says so rather than leaving them looking forgotten. + +The list caps at sixty with the rest a button away, before the grouping, so +the last year on screen can be a part of itself; under a name order By year +is not offered, because the years would repeat out of sequence. The mock +gains a 140-record artist so both caps are walked rather than argued about. diff --git a/handoff/rounds/commit-msg39.txt b/handoff/rounds/commit-msg39.txt new file mode 100644 index 0000000..2587c8c --- /dev/null +++ b/handoff/rounds/commit-msg39.txt @@ -0,0 +1,21 @@ +android: the faces the walk had never seen — six failure states, read back in their own words + +Round thirty-one gave the phone honest error states and shipped six of them +unobserved, because neither `loadHome` nor `loadLyrics` was reachable from the +`__ss` handle and both answer a second ask with nothing. The handle carries the +two stores and their two loaders now, and a block of mock steps arms each +failure with the mock's own weather, drives the face, and asserts the sentence +a person reads as well as the absence of the Rust string behind it: Home's +catalog line, the Gaps line and its second tap, a catalog record's stub, the +artist page's two different answers, a playlist that would not read, the lyrics +lookup that failed, a refused transfer, and the library toast's Try again, +pressed for the first time. + +It sits before the first-run block, which reloads and empties the mock's +follows; every failure is disarmed by the step that proves the face heals, and +a last step asserts nothing was left armed. The phone walk gets a safe step of +its own — a terminal transfer row written into the store and taken away, +nothing fetched and nothing reaching the shade. ANDROID.md now names what the +walk still cannot see: three screens that draw a raw backend string, six error +faces with no expectation, and the history mirror printed on every step and +asserted on none. diff --git a/handoff/rounds/commit-msg40.txt b/handoff/rounds/commit-msg40.txt new file mode 100644 index 0000000..af96f8a --- /dev/null +++ b/handoff/rounds/commit-msg40.txt @@ -0,0 +1,29 @@ +android: what the device has actually been tested for — the map, and the steps it could close + +Most of this port is proven against an object in memory and only some of it +against the phone, and nobody had written down which was which. Writing it +down found four phone steps — `library-ambience`, `queue`, `lyrics` and +`community` — named and screenshotted with no entry in `PHONE_EXPECT` at all, +and the driver reads a missing key as a pass, so all four had been green since +the rounds that wrote them; they have assertions now, tolerant of Kyle's own +library and of nothing else. + +Thirty-three more steps move onto the device: everything that can be proven +there without downloading, joining, sending, signing in or writing to his +library. The library's shape and its sort (nav state, not config), a local +track's sheet off a real path, Search's recents out of this WebView's own +storage, a queue row's sheet opened and not tapped, the walk's own playlist +with a row removed and put back by Undo — the first round trip that stash has +made through real SQLite — the import review's Keep switch restored before the +draft is created, the sleep timer set and put out by the next step, the +stream-cache chip and the Wi-Fi-only guard read first and restored last, and a +port the core has to refuse. + +ANDROID.md carries the map beside Verifying: what the device walk proves (121 +named steps, up from 88, plus the seven things after the loop that no mock can +do), what stays mock-only and why — needs `__ssMock`, needs a fresh install, +needs a viewport the phone walk refuses to set, needs the dev flavour, or +would download, join, or destroy his data — which six of the new steps are +conditional on what his own library happens to hold, and what still needs a +person. No product code in this round: four inserts in the phone half of +smoke.mjs, one in the doc. diff --git a/handoff/rounds/commit-msg41.txt b/handoff/rounds/commit-msg41.txt new file mode 100644 index 0000000..29023c6 --- /dev/null +++ b/handoff/rounds/commit-msg41.txt @@ -0,0 +1,52 @@ +android: the long session — what the app is still holding after an hour + +Every walk this project has ever run is minutes long, and nothing in the port +had been looked at for a leak. Driving the real bundle lap after lap says two +useful things. The first is that the obvious answer is wrong: the naive +add-minus-remove listener counter climbs about twelve a lap, and every one of +those is a `.screen` element that `{#key nav.seq}` threw away — count only +what is still attached to window, document or a connected element and it is +flat, lap for lap, and so are the intervals. The second is that exactly two +things grow without a limit. `art.svelte.js` kept every cover it had ever +fetched for the WebView's lifetime — one browse of a 114-cover library takes +it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64 +each — and `pls.covers` is keyed `id:revision`, so every playlist edit +stranded a 360 px data URL under a key nothing could ever read again. + +Both are bounded at the desktop's own numbers rather than at invented ones: +`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of +`state.svelte.js`, oldest first. Two rules the desktop does not need are +added, because its callers ask through `getArt(path)` while the phone's +templates index the map: the entry that just landed is never dropped, and +neither is one a connected `` is painting — `Cover`'s `visible` action +re-arms only on a path prop change, so an evicted cover that is still mounted +would stay a disc for good. Built with the cap forced to 8 and scrolled +through eighteen screens of a 140-card Library grid, the map sat at 61 — +spared, every one of them, for being painted — and not one cover on screen +lost its image; at the shipped 512 a whole library walk runs the trim zero +times. The playlist covers need no policy at all: the new revision drops the +old ones, which are unreachable by construction. + +The verification is the round. `artTally()` on the handle reports the map and +its side tables, which are module-private; seventeen probe fields carry that, +the catalog's ten session maps, the queue, the transfers and the DOM on every +step of both walks; and seven mock steps install a live listener and timer +registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`, +which the first-run block's reloads would throw away — drive two warm laps, a +baseline and nine more, assert the census, and take the registry back out so +nothing appended after this block inherits a patched platform. Installed that +late it can only see what is registered from that moment on, so its absolute +counts are small and are not asserted as a census; what is asserted is the +delta across the nine laps, and that it fires was proved by planting a +listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks` +0 → 9, and the step fails. As it ships, over those nine laps, the listeners +and the intervals moved by 0, the art map grew 17 against a headroom of 80 +and the catalog 5 against 24, and the DOM ended smaller than it started. +`tallyTimers` is a ceiling and not a delta, and says so: every track start +schedules a 47-second recents refresh, so a walk this fast keeps a handful +pending, and making it sharp would cost a fifty-second quiet period. The +catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md +as left alone on purpose — evicting a catalog entry is not safe while +`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg: +the registry is never installed there, those three fields read −1, and +`artTally()` is a pure read. diff --git a/handoff/rounds/commit-msg42.txt b/handoff/rounds/commit-msg42.txt new file mode 100644 index 0000000..7652068 --- /dev/null +++ b/handoff/rounds/commit-msg42.txt @@ -0,0 +1,44 @@ +android: turned over, for real — the walk rotates the phone, and puts it back + +Round twenty-seven wrote the landscape layout and proved it at 915×411 in +headless Chrome; no real device had ever been turned, and the phone walk +refuses a `{ view: [...] }` step because a device's orientation is not the +step list's to set. That refusal stays. The turn goes in the phone tail +instead, right after `background` has proved playback is live with the app +off screen, and it asserts the four things the mock physically cannot show: +`screen.orientation.angle` is 90 or 270 — an override without +`screenOrientation` leaves the angle at 0, so this one is only ever a real +phone — the WebView RESIZED as well (a separate clause, because a WebView +that never got the configuration change looks exactly like a display that +never rotated), `--nav-h` computed to 48px so the phone-sized landscape query +really fired, and the activity was not recreated: the same window token, the +same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and +those last two are read tolerantly, because `pidof` exits 1 when the process +is GONE and `adb()` throws on a non-zero exit, so the plain read threw the +whole report away at exactly the moment it mattered. Then the stack, the +track still playing and past where it was (a track that rolled over during +the turn is still playing, which is all the clause claims), nothing clipped, +and the player sheet sideways in two columns with every control on screen. + +The safe-area insets are printed, not asserted. Nobody had ever measured +them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing +in the generated Kotlin touches insets, so `env()` comes straight out of +Chromium's window-inset mapping — and asserting a number nobody has measured +is how a round fails on a correct build. What is asserted is the plumbing, on +both edges, because a hole-punch is only on one of them: whatever `env()` +says, the glass bar, the mini bar, the screen and the sheet spend it. + +The phone is Kyle's, so the two rotation settings are read before either is +written and put back from four places — the block's own finally, main's +finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed +clause only walks on but a WebView that stops answering throws clean out of +the loop, and Ctrl-C runs no exit handler at all. The latch clears itself +before it writes, a failed restore hands it back for the next caller, and the +skip paths disarm rather than restore, so a phone that could not be read, one +already sideways and `--no-rotate` all leave it untouched. Nothing else on the +device is written. + +One thing beyond the turn: the `{ adb }` step kept its stdout. `am start` +prints "Error: Activity not started, unable to resolve Intent" on stdout and +exits 0, so `share-import` — the step that exists to prove the share intent +resolves — could not fail for its own reason. It can now. diff --git a/handoff/rounds/commit-msg43.txt b/handoff/rounds/commit-msg43.txt new file mode 100644 index 0000000..8a788ec --- /dev/null +++ b/handoff/rounds/commit-msg43.txt @@ -0,0 +1,35 @@ +android: the keep rules for a release build nobody has made + +Every APK this port has produced is `assembleArm64Debug`, and the generated +project turns R8 on for `release` and only for `release` — while tauri finds a +plugin class by string, files every `@Command` under `commands[method.name]`, +and lets Jackson fill every `@InvokeArg` field by field. Minification breaks +exactly that, and it breaks it without a word: the app compiles, installs, +launches, shows Home, and then a tap on Library folder answers "No command +pickFolder found for plugin ..." in logcat and nothing on screen. So each +plugin module now carries its own `consumer-rules.pro` naming its own +reflected surface — the shape tauri-android and tauri-plugin-dialog already +use in this build, and one that travels with the plugin into a regenerated +`gen/android`, which a rule written into that gitignored tree would not. + +What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`, +copied into the generated project by the build script because the app's own +Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn` +pair for the two `java.beans` classes jackson-databind references and Android +does not have (R8 turns a missing class into a build error, so without them +there may be no APK at all), and a keep for the four `@Command` methods +declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes +annotated `@TauriPlugin` and the members they declare; the base class carries +no such annotation, so nothing names `registerListener` — which is what the +lock-screen card's buttons and the download notice's Cancel and Open ride on. + +`build.sh` grows `--release`: the release profile, `--features tauri/devtools` +so the walk can still attach at all, `assembleArm64Release`, and a zipalign and +sign with the same debug key the phone already trusts, so the APK installs over +the app rather than forcing an uninstall that would take Kyle's settings with +it. With no argument the script is what it has always been, cargo line for +cargo line, so deploy.sh and ship.sh keep the path they run every twenty +minutes. None of this has been built: Gradle, Kotlin and R8 do not run on this +machine, the rules are a precaution against a silent failure rather than a fix +for an observed one, and ANDROID.md says so and lists what to test by hand, +command by command, on the first release build anyone makes. diff --git a/handoff/rounds/commit-msg44.txt b/handoff/rounds/commit-msg44.txt new file mode 100644 index 0000000..d11a898 --- /dev/null +++ b/handoff/rounds/commit-msg44.txt @@ -0,0 +1,32 @@ +android: the falcon on the launcher — a real adaptive icon, with the monochrome layer + +The phone has been wearing tauri's logo. `tauri android init` writes the +generated project's `res/` from its own template and never reads +`src-tauri/icons/android/`, which has carried the falcon since 50afa6a, and the +detour build runs cargo and Gradle directly — so nothing ever copied it across: +the two dominant colours in the packaged `mipmap-xxxhdpi/ic_launcher.png` are +`#24c8db` and `#ffc131`, tauri's own brand cyan and amber. `src-tauri/android/res/` +is the app module's res now, tracked, and `build.sh` and `dev.sh` copy the whole +tree into `gen/android` before Gradle, because that project is regenerated and +gitignored and an asset that only lives there is an asset that disappears. + +It is a real adaptive icon: the kit's foreground and its **monochrome** layer at +five densities, the legacy mipmaps under it, and the background as one +`#FF05080B` colour resource rather than five identical flat PNGs, so the gradient +being mocked separately replaces one file. The kit's own XML could not ship — it +points every layer at `@drawable/` while shipping them as PNGs under `mipmap-*`, +and in the generated project two of those three references resolve to Android +Studio's template robot rather than failing — so the overlay's XML says +`@mipmap/` and `@color/`, and its colour file takes the same name tauri's does, +because two files declaring one colour is a hard aapt2 error. + +Losing the copy would be silent — the APK still builds, still installs, and wears +the old logo with nothing in the log — so `verify-icon.sh` opens every APK both +scripts make and reads the resources back out: the two `mipmap-anydpi-v26` XMLs +present, and four PNG names at five densities byte for byte from the overlay. A +release APK keeps neither the bytes nor the names — AGP re-encodes every PNG and +shortens every resource path — so that path asks `aapt2 dump resources` what each +name points at and compares pixel dimensions instead; 72×72 at hdpi still tells +the kit's icon from tauri's 49×49. No walk step: a launcher icon is not reachable +from inside the app, and the masking and the themed layer want a minute with the +phone's home screen. diff --git a/handoff/rounds/commit-msg45.txt b/handoff/rounds/commit-msg45.txt new file mode 100644 index 0000000..d59cb27 --- /dev/null +++ b/handoff/rounds/commit-msg45.txt @@ -0,0 +1,29 @@ +android: the icon where it moves — the glyph, the lock screen and a splash that is not white + +Both notification glyphs are the same falcon now, the logo kit's monochrome +path verbatim inside a group that re-frames it for a 24dp box — the playback +card's and the transfers notice's, because a note beside an arrow does not +read as one app, and the transfers one keeps the tray bar it always had so +the falcon dives into it. The fill stays white on transparent: the shade +composites a small icon from its alpha channel alone and tints it itself, so +a coloured export would arrive as a white blob. + +The lock screen falls back to the app's own icon instead of a grey hole. +`MediaHub.art` is null from the moment a track changes until the frontend has +finished decoding its cover, and null forever for a track with none, so +`appIcon()` decodes the launcher icon ONCE — through the PackageManager, +because a library module's R has no `ic_launcher` — and hands it to both the +card and `METADATA_KEY_ALBUM_ART`. The app manifest lives in gitignored +`gen/` and declares no category, so the media-session plugin's own library +manifest contributes `android:appCategory="audio"` through the merger, and a +`Theme.App.Starting` on the brand ground replaces Android 12+'s white flash — +in `values-v31` and `values-night-v31` both, because the qualifier order puts +night mode ahead of platform version and a splash written only into +`values-v31` is silently ignored on a dark-mode phone. + +The walk gains `media-card`: after the session block, the card is still +posted on the `playback` channel with the playing track's title on it, and +`media-keys` below it still gets the transport back — which is what a +drawable the shade cannot inflate would actually break. The glyph itself and +the splash are screenshots; `dumpsys notification` prints a resource id and +never a pixel, and the splash is gone before the walk's first frame. diff --git a/handoff/rounds/commit-msg46.txt b/handoff/rounds/commit-msg46.txt new file mode 100644 index 0000000..e23c911 --- /dev/null +++ b/handoff/rounds/commit-msg46.txt @@ -0,0 +1,47 @@ +android: the media mirror grows a queue window the card never shows + +The Kotlin side of the mirror has always known about exactly one track, and +media3's `SimpleBasePlayer.getState()` — the thing an Android Auto session is +built on — wants a playlist and an index into it. So the data lands a round +before the migration that needs it, where it cannot break anything: +`NowPlaying` and `NowPlayingArgs` carry a bounded `queue` of at most +twenty-one entries, the current item and ten either side, each with its id, +title, artist, album, duration and art key, plus `queueIndex`, where the +current one sits within that window rather than within the whole queue. +`src/mobile/media.svelte.js` fills them from `player.queue` and `player.qi`; +`MediaService` ignores every bit of it, so the card is byte for byte the card +it was. Ten either side because one covers the card's own prev and next and +nothing else while a queue list — which is what Auto asks for — wants a +screenful plus a scroll both ways, and it is a cap rather than a length: a +two-thousand-track queue crosses the bridge as twenty-one entries exactly +like a twenty-one-track one, measured at two kilobytes of JSON for twenty-one +short titles and six with long ones and real cover paths, against the thirty +to eighty of base64 cover that already ride the same call once per record. + +Nothing renders it, which is the whole difficulty in checking it — and both +hops of this bridge fail silently, because serde drops an unknown field and +tauri's Jackson mapper has `FAIL_ON_UNKNOWN_PROPERTIES` off, so a key spelled +snake_case where camelCase was wanted arrives as nothing at all with no error +anywhere. `MediaSessionPlugin.update` logs one line per update, and the phone +walk holds that line against `__ss.card` — the mirror media.svelte.js now +keeps of what it last sent, the shape xfernote has kept of the shade since it +was written — field for field, through a next and a previous. The art-key +count is compared as well as the size and the index, since that is the only +camelCase field an entry has. The check arms at queue index one rather than +at wherever the walk left off, which buys a row on either side — `prev()` +seeks to zero at the top of a queue and `next()` stops at the end — a track +that has only just started, so `prev()` is still a step rather than the seek +it becomes past five seconds, and a window whose own index is the queue +index, because nothing is clamped off the front while the index is under ten. +Each of the three readings has to be a fresh one at both ends, the push +counter up and a new line in the log, since a `prev()` that lands back where +it started reads exactly like a stale line otherwise; and a queue too short +to arm, or repeat-one, under which `next()` seeks instead of stepping, fails +the check rather than skipping it. + +The plugin's `consumer-rules.pro` keeps the new `@InvokeArg` class by name +beside the one it already keeps; without that line a release build would fill +`NowPlayingArgs` and leave every queue entry null, quietly. Rust and Kotlin +both changed, so this one cannot go down `dev.sh` — it needs the full +`deploy.sh` detour. And everything the walk already asserts about the card is +untouched, which is the real check on this round. diff --git a/handoff/rounds/commit-msg47.txt b/handoff/rounds/commit-msg47.txt new file mode 100644 index 0000000..2764aeb --- /dev/null +++ b/handoff/rounds/commit-msg47.txt @@ -0,0 +1,50 @@ +android: the launcher's ground is the desktop's, not a flat colour under it + +The icon round left the adaptive background as one `#FF05080B` and a note that +a gradient was being mocked. The mock settles it: the falcon covers about a +quarter of a circle-masked tile, so the ground is three quarters of what +anyone sees, and concept 04's glow survives every mask — the visible top-left +corner goes from `#05080B` to `#0E3543`. So `@color/ic_launcher_background` +becomes `@drawable/ic_launcher_ground`, a vector carrying the desktop icon's +own linear surface and the radial glow over it, every number +`src-tauri/icons/icon.svg`'s multiplied by 108/1024 and nothing else. The +colour resource stays where it is, unreferenced, because its filename is the +only thing stopping tauri's own `#fff` from colliding with it. + +A vector and not five PNGs on evidence. The kit's glow has a uniform scale, so +the SVG ellipse is a circle and its rotation is a no-op, which is exactly what +`android:type="radial"` can carry; two numbers there and no vector could have +said it. The linear axis is 51.1° off horizontal, which rules out a +`` of shapes as well, whose angle must be a multiple of 45. It was +compiled and linked with the SDK's own aapt2 over the real merged res tree and +every stop read back out of the APK. The name is `ic_launcher_ground` and not +`ic_launcher_background` on purpose: the generated project still carries +Android Studio's template drawable of that name, so under the other name an +overlay copy that failed would silently link the green robot — under this one +it is a hard link error, which was tested too. + +`verify-icon.sh` follows the layer. It does not ask whether the file is in the +APK; it asks what the adaptive icon's `` resolves to, through the +resource table, in both build types, and then reads concept 04's six stops back +out of what it found — which catches a stale APK as well as a lost overlay. +Passing on a debug APK and on one put through `--shorten-resource-paths`, +failing on a stale one, and saying NOT CHECKED rather than guessing when aapt2 +is absent. What none of it can see is a pixel: banding on the OLED, the glow's +weight under the Pixel's mask and how the falcon reads on a lit ground are a +launcher screenshot on the phone, and ANDROID.md says so instead of implying +the check covers it. + +One slot the ground reaches is not the launcher at all. `MediaHub.appIcon()` +draws whatever `getApplicationIcon()` returns into a 512 px bitmap and hands +it to the lock-screen card as the large icon for any track whose cover has +not been decoded yet, so the card and the shade carry this ground as well — +the quickest place for Kyle to look at it, and the only reader of the +launcher icon inside the app. The walk reads that card's title and the +session's playback state and never its icon, so no step changes meaning. The +Android 12+ splash very likely changes as well, and the other way from what +the docs' one-line version of the rule suggests: the platform drops an +adaptive background only when it is a single colour close to +`windowSplashScreenBackground`, which round 44's flat `#05080B` was, so the +falcon has been drawing there alone and enlarged; a gradient is not one +colour, so it is kept and the whole icon is drawn. Likely, not proven — that +one is a screenshot too. diff --git a/handoff/rounds/commit-msg48.txt b/handoff/rounds/commit-msg48.txt new file mode 100644 index 0000000..52d80f4 --- /dev/null +++ b/handoff/rounds/commit-msg48.txt @@ -0,0 +1,61 @@ +android: the lock-screen card, moved to media3 before anything is built on it + +The card Kyle sees every day was a hand-built MediaStyle notification over a +MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto +needs a media3 session to browse, so the choice was to write the new thing +against a deprecated library or to move the old thing first; this moves the +old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a +new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the +engine lives somewhere else — rodio, in Rust, behind a WebView round trip — +so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge, +queue window and all, and every button routes back out through `MediaHub.act`. + +Three corrections to the plan, each the kind that only shows up on a phone. +media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a +project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy +blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an +ExoPlayer convenience that lands in a `@ForOverride` method which throws, and +deleting them would have lost "another app takes the output" and "headphones +out" with nothing failing anywhere. And the service is BOUND, never started — +`MediaSessionService.onStartCommand` returns `START_STICKY` without promoting +for any intent that is not media3's own, so a `startForegroundService` would +arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug +in a new coat. Four more things are load-bearing and silent when they break: a +playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE` +with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both +spellings of next and previous. Each ends in a card that is simply never +posted, with nothing in any log. The window carries `queueBase` now, for a +stable media-item uid as it slides, and `repeat`, whose numbering is swapped +between the app and media3, so that what a controller is told about repeat is +the truth and media3's own view of the end of the queue agrees with the +`hasNext` the next button is advertised on. + +The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it. +media3 hands the framework a media-button `PendingIntent` built with +`PendingIntent.getForegroundService`, unconditionally, because the receiver the +legacy stub resolves is now this service — there being no `MediaButtonReceiver` +left for it to find. So the SYSTEM starts this service in the foreground on +every media KEY, while media3 promotes only for a session it calls +user-engaged. A key into a process built for that key, a key on a card paused +past the ten-minute tail that does not start playback, any key with the bar +cleared: none of them qualify, and each would take the app down ten seconds +later. (The card's own buttons are milder — only play/pause, and only while +paused, is a foreground start.) So `onStartCommand` promotes first, the way +this service always did — through media3's own `onUpdateNotification` when +there is a card to post, and otherwise with a plain notification of its own, +posted and withdrawn in the same breath. `Service`'s +`startForeground` is final, so the split is by what media3 can be asked to do, +not by watching it. + +The walk gains `one-card` and `one-session` — the only checks that tell a +landed migration from a doubled one — plus an advisory `media-plumbing` line; +the logged window grew `base` and `rep`, which are the only readback either +field has anywhere on the device and which `queue-window` now asserts; and the +paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one +place and contradicted in another. ANDROID.md also says plainly what the walk +cannot prove and Kyle has to try himself: headphones out, another app taking +focus, the card's actual look, and a Bluetooth headset's own buttons. The +Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of +media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`, +`MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest +and resources compile and link under aapt2, and the Rust type-checks. diff --git a/handoff/rounds/ins-r20-peer.txt b/handoff/rounds/ins-r20-peer.txt new file mode 100644 index 0000000..53ba9b5 --- /dev/null +++ b/handoff/rounds/ins-r20-peer.txt @@ -0,0 +1,185 @@ + + +
+ {#if picture} + + {:else} + + {/if} +
+
{username}
+
{uploadLine}
+
+ +
+ +
+ + +
+ +{#if !online} +
Sign in to Soulseek to ask their client for their card.
+{:else if infoErr} +
Their client did not answer ({infoErr}) — offline, firewalled, or not sharing a card.
+{:else if !info} +
Asking their client for the card — direct, peer to peer.
+{:else} +
{info.description?.trim() || "They wrote nothing about themselves."}
+ {#if info.picture_note}
{info.picture_note}
{/if} +{/if} + +

Interests · what they declared on Soulseek

+{#if likes.length || hates.length} + {#if likes.length} +
Likes
+
{#each likes as l, i (i)}{l}{/each}
+ {/if} + {#if hates.length} +
Hates
+
{#each hates as h, i (i)}{h}{/each}
+ {/if} +{:else if intErr} +
The server did not answer.
+{:else if interests} +
None declared.
+{:else if online} +
Asking the server…
+{:else} +
Sign in to Soulseek to see what they declared.
+{/if} + +

Library overlap · compared on this phone

+{#if overlap} + {@const o = overlap} +
+ {o.their_albums + ? `${o.albums_in_common_count} of their ${o.their_albums} albums are in your library · ${o.artists_in_common_count} artist${o.artists_in_common_count === 1 ? "" : "s"} in common · compared ${ago(o.computed_at)}` + : `No album folders among ${o.audio_files} audio files · compared ${ago(o.computed_at)}`} +
+ {#if o.gaps.length} +
They have {o.gaps_count} record{o.gaps_count === 1 ? "" : "s"} by artists in your library that you don't.{o.gaps.length > cap ? ` The first ${cap} are here.` : ""}
+ {#each o.gaps.slice(0, cap) as g, i (i)} + + {/each} + {#if o.gaps.length > cap} +
+ {/if} + {:else} +
Nothing they have that you lack.
+ {/if} +
+ + +
+{:else} +
Not compared yet.Their share folders against your albums, on this phone — nothing is uploaded to do it.
+
+ +
+{/if} + +
diff --git a/handoff/rounds/ins-r21-buddyrow.txt b/handoff/rounds/ins-r21-buddyrow.txt new file mode 100644 index 0000000..773ea54 --- /dev/null +++ b/handoff/rounds/ins-r21-buddyrow.txt @@ -0,0 +1,6 @@ + + + {#if m.petname || m.group || m.note || m.trusted || m.favorite} +
+ {@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))} + {/if} + diff --git a/handoff/rounds/ins-r21-steps-b.txt b/handoff/rounds/ins-r21-steps-b.txt new file mode 100644 index 0000000..d8bf6eb --- /dev/null +++ b/handoff/rounds/ins-r21-steps-b.txt @@ -0,0 +1,12 @@ + /* the contact editor behind the peer sheet: a petname, a note, the + favourite mark — then the buddy list wearing all three, ada's own + name still under it and favourites first */ + { do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 }, + { do: `__click('.halfsheet .row', 'Petname')`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 }, + { do: `__click('.halfsheet .row', 'Note')`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 }, + { do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 }, + { do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 }, diff --git a/handoff/rounds/ins-r21-steps-c.txt b/handoff/rounds/ins-r21-steps-c.txt new file mode 100644 index 0000000..fd8174d --- /dev/null +++ b/handoff/rounds/ins-r21-steps-c.txt @@ -0,0 +1,3 @@ + /* the room's roster wears the petname too, the real name beneath it */ + { do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 }, + { do: `document.querySelector('.scrim').click()`, wait: 500 }, diff --git a/handoff/rounds/ins-r21-steps-d.txt b/handoff/rounds/ins-r21-steps-d.txt new file mode 100644 index 0000000..229077b --- /dev/null +++ b/handoff/rounds/ins-r21-steps-d.txt @@ -0,0 +1,6 @@ + /* nothing set is nothing stored: cleared, the entry leaves the list and + a name goes back to being a name */ + { do: `__click('.segtabs button', 'Buddies')`, wait: 600 }, + { do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 }, + { do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 }, + { do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 }, diff --git a/handoff/rounds/ins-r21-store.txt b/handoff/rounds/ins-r21-store.txt new file mode 100644 index 0000000..7de86a5 --- /dev/null +++ b/handoff/rounds/ins-r21-store.txt @@ -0,0 +1,70 @@ +/* The local view of a person on the network: a petname, a group, a private + note and the two marks. All of it lives in this app's own config and is + published nowhere — Soulseek never learns it, and neither does the person + it describes. The desktop keeps the same index by username + (src/lib/community.svelte.js), and the same rule: the backend drops an + entry with nothing set, so the list it answers with is the one we take. */ +import * as api from "../lib/api.js"; +import { player } from "./player.svelte.js"; + +export const contacts = $state({ + /** username → { username, petname, group, note, trusted, favorite } */ + by: {}, + /** the list came back; false until it does, and after a failure */ + loaded: false, + error: "", +}); + +const BLANK = { petname: null, group: null, note: null, trusted: false, favorite: false }; +/** what the backend stores: a trimmed string, or nothing at all */ +const trimmed = (v) => { const s = typeof v === "string" ? v.trim() : v; return s ? s : null; }; + +function index(list) { + contacts.by = Object.fromEntries((list || []).map((c) => [c.username, c])); + contacts.loaded = true; + contacts.error = ""; +} + +let inflight = null; +/** Read the whole list — once at boot, and again whenever anyone asks. A + * failure is quiet: a petname is an enhancement, not the screen. */ +export function loadContacts() { + if (inflight) return inflight; + inflight = api + .contactsMetaList() + .then(index) + .catch((e) => { contacts.loaded = false; contacts.error = String(e); }) + .finally(() => { inflight = null; }); + return inflight; +} + +export const metaOf = (u) => contacts.by[u] ?? { username: u, ...BLANK }; +/** The name to show: the petname when there is one, the username otherwise. */ +export const display = (u) => contacts.by[u]?.petname || u; +/** The username, but only while a petname stands in front of it — a stranger + * is never quietly turned into someone you know. */ +export const realName = (u) => (contacts.by[u]?.petname ? u : ""); + +/** Upsert one person. The answer is the whole list after the write, so it is + * taken as it comes rather than patched into our own copy: that is how an + * entry with nothing left in it disappears. A failure goes to the bar. */ +export async function setMeta(username, patch) { + const m = { ...metaOf(username), ...patch }; + try { + index( + await api.contactMetaSet({ + username, + petname: trimmed(m.petname), + group: trimmed(m.group), + note: trimmed(m.note), + trusted: !!m.trusted, + favorite: !!m.favorite, + }), + ); + } catch (e) { + player.error = String(e); + } +} + +/** Everything blank: the entry leaves the list rather than being stored empty. */ +export const clearMeta = (u) => setMeta(u, { petname: "", group: "", note: "", trusted: false, favorite: false }); diff --git a/handoff/rounds/ins-r22-community.txt b/handoff/rounds/ins-r22-community.txt new file mode 100644 index 0000000..e7e987c --- /dev/null +++ b/handoff/rounds/ins-r22-community.txt @@ -0,0 +1,35 @@ + {:else if tab === "taste"} +

What you like · {taste.likes.length}

+
Declared on Soulseek: this is what the server matches other people against.
+ +

What you hate · {taste.hates.length}

+
+ {#each taste.hates as thing (thing)} + + {/each} + +
+

People like you{#if taste.similar?.users?.length} · {taste.similar.users.length}{/if}

+ {#if taste.asking && !taste.similar} +
Asking the server…
+ {:else if taste.similar?.users?.length} + {#each taste.similar.users.slice(0, 40) as su (su.username)} + + {/each} + {:else if taste.error} +
The server did not answer.{taste.error}
+ {:else if !taste.likes.length} +
Nothing to match you on yet.The server puts people together on the likes you declare — declare one above and it has something to go on.
+ {:else} +
Nobody yet.The server found no one close to what you declared.
+ {/if} +
diff --git a/handoff/rounds/ins-r22-doc.txt b/handoff/rounds/ins-r22-doc.txt new file mode 100644 index 0000000..1eaff5f --- /dev/null +++ b/handoff/rounds/ins-r22-doc.txt @@ -0,0 +1,11 @@ +Community's fourth tab is **Taste**: the interests this phone has +declared to the Soulseek server (`slsk_interests_mine`, `slsk_interest_set`) +and the people the server ranks as close to them (`slsk_similar_users`), a +row each into the peer sheet. A like or a hate is a chip and tapping one +takes it back; "Add a like" and "Add a hate" declare a new one. Every +declaration asks the server again and the chips stay quiet until it has +answered, because the ranking only moves when its input does. The lists +are this client's own — the backend keeps them and re-declares every one +at login, since the server's copy belongs to the account and another +client signed in elsewhere can change it under us. Mock-walked only: a +live sign-in and the server's own ranking are Kyle's to see. diff --git a/handoff/rounds/ins-r22-smoke-steps.txt b/handoff/rounds/ins-r22-smoke-steps.txt new file mode 100644 index 0000000..1e4fdea --- /dev/null +++ b/handoff/rounds/ins-r22-smoke-steps.txt @@ -0,0 +1,13 @@ + /* your own interests: what is declared, a like added and the server's + match re-asked, a hate added, the like taken back, a match's sheet */ + { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 }, + { do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 }, + { do: `__click('.tastechips.likes .tadd', null)`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 }, + { do: `__click('.tastechips.hates .tadd', null)`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 }, + { do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 }, + { do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 }, + { do: `document.querySelector('.scrim').click()`, wait: 500 }, diff --git a/handoff/rounds/ins-r22-taste-store.txt b/handoff/rounds/ins-r22-taste-store.txt new file mode 100644 index 0000000..86de6a2 --- /dev/null +++ b/handoff/rounds/ins-r22-taste-store.txt @@ -0,0 +1,80 @@ +/* Taste: what this device has told the Soulseek server it likes and what + it hates, and the people the server ranks as close to it. The desktop + keeps all three in `community.svelte.js` and follows one rule — the + ranking only moves when its input does, so every declaration asks the + server again and the chips stay quiet until it has answered. The lists + are THIS CLIENT's: the backend keeps them and re-declares every one of + them at login, because the server's own copy belongs to the account and + another client signed in elsewhere can change it under us. */ +import * as api from "../lib/api.js"; +import { slsk } from "./slsk.svelte.js"; +import { player } from "./player.svelte.js"; + +export const taste = $state({ + /** the things declared, as the backend last answered */ + likes: [], + hates: [], + /** null until asked; { users: [{ username, rating }] } after */ + similar: null, + /** a declaration in flight, so the chips go quiet */ + busy: false, + asking: false, + /** the server's word when it would not answer */ + error: "", +}); + +/* the account these lists were read for — a plain variable, never $state: + the screen's effect is what calls loadTaste, and reading this inside it + would arm that effect on our own write */ +let readFor = null; + +/** What this device has declared. Once per account, unless forced. */ +export async function loadTaste(force = false) { + const who = slsk.username || slsk.savedUser || ""; + if (readFor === who && !force) return; + readFor = who; + try { + const v = await api.slskInterestsMine(); + taste.likes = v?.likes ?? []; + taste.hates = v?.hates ?? []; + } catch (e) { + player.error = String(e); + } + await askSimilar(true); +} + +/** Declare a thing, or take it back. The backend answers with both lists, + * and the server's match is asked again before the chips come back — it + * ranks on exactly this, so a second declaration must not overtake it. */ +export async function setInterest(thing, like, on) { + const t = String(thing || "").trim(); + if (!t || taste.busy) return false; + taste.busy = true; + try { + const v = await api.slskInterestSet(t, like, on); + taste.likes = v?.likes ?? []; + taste.hates = v?.hates ?? []; + await askSimilar(true); + return true; + } catch (e) { + player.error = String(e); + return false; + } finally { + taste.busy = false; + } +} + +/** The people the server puts near us, by the likes we declared. */ +export async function askSimilar(force = false) { + if ((taste.similar && !force) || taste.asking) return; + taste.asking = true; + taste.error = ""; + try { + taste.similar = { users: (await api.slskSimilarUsers()) || [] }; + } catch (e) { + taste.similar = { users: [] }; + taste.error = String(e); + } finally { + taste.asking = false; + } +} diff --git a/handoff/rounds/ins-r23-android.txt b/handoff/rounds/ins-r23-android.txt new file mode 100644 index 0000000..2d382cb --- /dev/null +++ b/handoff/rounds/ins-r23-android.txt @@ -0,0 +1,17 @@ +The rest of the lyrics tools came with it. The sheet carries the per-track +auto-sync switch (`lyrics_set_auto_sync`), so the timing correction the app +makes from a file's own lead-in can be turned off for one recording and back +on. "Re-time by hand" opens the tap-to-sync room (`SyncRoom.svelte`, one +more layer the back gesture peels): the song plays, the line to time sits +large in the middle of the screen with the one behind and the one ahead +faint around it, and a tap anywhere stamps it — the last tap undone, −5 s +and play/pause under the thumb, and Save writing the track's own document +(`lyrics_save_lines`, the top of the waterfall, where a paste lands too). +Lines never tapped are left out of it and the button says how many are kept; +a set left mid-tap by the back gesture is offered back by the row that +opened it. "Play with reduced vocals" is the desktop's offline DSP +(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a +separator, and the wording stays honest about that): the copy plays under +the same title, artist and duration, so the lyric identity is still the +recording's and the words keep following it, and one row puts the original +back. diff --git a/handoff/rounds/ins-r23-halfsheet-rows.txt b/handoff/rounds/ins-r23-halfsheet-rows.txt new file mode 100644 index 0000000..4c87f50 --- /dev/null +++ b/handoff/rounds/ins-r23-halfsheet-rows.txt @@ -0,0 +1,6 @@ + {#if lyr.lines.length || lyr.plain} + {@render row("clock", lyr.lines.length ? "Re-time by hand" : "Sync these lyrics", tapLeft ? `${tapLeft.done} of ${tapLeft.total} lines timed — keep going` : "play the song and tap as each line begins", act(openTapSync))} + {/if} + {#if canReduce} + {@render row("music", onReduced ? "Back to the original" : "Play with reduced vocals", onReduced ? "the recording as it is" : lyr.reducing ? "making the copy now — a moment" : "a rough centre cancel, made on this phone", act(() => { void (onReduced ? backToOriginal() : reduceVocals()); }))} + {/if} diff --git a/handoff/rounds/ins-r23-halfsheet-swrow.txt b/handoff/rounds/ins-r23-halfsheet-swrow.txt new file mode 100644 index 0000000..50fd4b7 --- /dev/null +++ b/handoff/rounds/ins-r23-halfsheet-swrow.txt @@ -0,0 +1,8 @@ + +{#snippet swrow(icon, t, m, on, fn)} + +{/snippet} diff --git a/handoff/rounds/ins-r23-lyrics-store.txt b/handoff/rounds/ins-r23-lyrics-store.txt new file mode 100644 index 0000000..3c0e986 --- /dev/null +++ b/handoff/rounds/ins-r23-lyrics-store.txt @@ -0,0 +1,153 @@ + +/** Re-read the payload for the loaded track without emptying the panel — + * a switch flips and the words stay on screen. The cache only, never the + * network. */ +async function reread() { + const id = identity(); + if (!id) return; + const g = gen; + try { + const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: null }); + if (g === gen) apply(p, true); + } catch { /* what is showing stands */ } +} + +/** The app's automatic timing correction, on or off for this track. The + * switch flips at once; the backend's own number comes with the re-read. */ +export async function setAutoSync(enabled) { + const id = identity(); + if (!id) return; + const before = lyr.autoSyncOff; + lyr.autoSyncOff = !enabled; + try { + await api.lyricsSetAutoSync(id.title, id.artist, id.durationSecs, enabled); + } catch (e) { + lyr.autoSyncOff = before; + player.error = String(e); + return; + } + await reread(); +} + +/* ── the vocal-reduced copy ────────────────────────────────────────────── + The desktop's offline DSP: a band-limited centre cancel, not a learned + separator, and the wording stays honest about that. The copy plays under + the SAME title, artist and duration, so the lyric identity is still the + recording's and the words keep following it. The track starts again from + the top — there is no seeking a file the engine has only just been given. */ +async function swapPath(e, to) { + /* a COPY of the entry's track, never the row itself: tracksOf caches one + array per record and hands the SAME objects to the record page and to + the queue, so writing the path in place would leave the record pointing + at a cache file the LRU drops out from under it. The desktop does not + rewrite its queue item either — it plays the copy as its own thing. */ + e.t = { ...e.t, path: to }; + await playQueueAt(player.qi); +} +export async function reduceVocals() { + const e = current(); + const t = e?.t; + if (!t?.path || t.stream || lyr.reducing) return false; + const from = t.path; + lyr.reducing = true; + note("Reducing the vocals — this can take a moment"); + try { + const res = await api.lyricsPrepareInstrumental(from); + /* the render takes seconds: starting the OLD track's copy now would + hijack whatever the queue has moved on to */ + const now = current(); + if (!res?.path || now?.t?.path !== from) { + note("The track changed while preparing — try again"); + return false; + } + lyr.reduced = { from, to: res.path }; + await swapPath(now, res.path); + return true; + } catch (err) { + player.error = String(err); + return false; + } finally { + lyr.reducing = false; + } +} +export async function backToOriginal() { + const r = lyr.reduced; + const e = current(); + if (!r || e?.t?.path !== r.to) return false; + await swapPath(e, r.from); + lyr.reduced = null; + return true; +} + +/* ── the tap-to-sync room ──────────────────────────────────────────────── + The song plays and one tap lands per line; the taps ARE the timing. + Saving writes the track's own document (lyrics_save_lines), the top of + the waterfall, the same shelf a paste lands on — and the backend drops + every line without a time, so a half-tapped set is stored as the lines + that have one. */ +export const tapSync = $state({ + rows: [], // [{ time_ms: number|null, text }] + cursor: 0, // the row the next tap stamps + identity: null, // the track the rows belong to, captured at open + saving: false, + error: "", +}); + +const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim()) + .filter((l) => l && !/^\[.*\]$/.test(l)); + +/** Open the room for the playing track. An already-timed set is re-tapped + * from scratch: the room shows three lines, not a list, so it has no + * per-line handle to nudge — it has the tap. */ +export function openTapSync() { + const id = identity(); + if (!id) return; + /* peeled by the back gesture mid-tap: the same track's stamps come back + rather than the room starting over */ + if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; } + const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain)) + .filter((t) => String(t || "").trim()); + if (!texts.length) return; + tapSync.rows = texts.map((text) => ({ time_ms: null, text })); + tapSync.cursor = 0; + tapSync.identity = id; + tapSync.saving = false; + tapSync.error = ""; + openTiming(); +} +export function closeTapSync() { + closeTiming(); + tapSync.rows = []; + tapSync.cursor = 0; + tapSync.identity = null; + tapSync.saving = false; + tapSync.error = ""; +} +/** What the lyrics sheet says under the row when a set was left mid-tap. */ +export function tapProgress() { + const id = identity(); + if (!id || tapSync.identity?.key !== id.key) return null; + const done = tapSync.rows.filter((r) => r.time_ms != null).length; + return done ? { done, total: tapSync.rows.length } : null; +} +/** The taps, as this track's own timed document. */ +export async function saveTapSync() { + const id = tapSync.identity; + const rows = tapSync.rows.filter((r) => r.time_ms != null && r.text.trim()); + if (!id || !rows.length || tapSync.saving) return false; + tapSync.saving = true; + tapSync.error = ""; + try { + const p = await api.lyricsSaveLines(id.title, id.artist, id.durationSecs, + rows.map((r) => ({ time_ms: Math.max(0, Math.round(r.time_ms)), text: r.text }))); + /* the same track is still playing: show the result at once */ + if (identity()?.key === id.key && p && p.status) apply(p); + closeTapSync(); + return true; + } catch (e) { + tapSync.error = String(e).replace(/^Error:\s*/, "").slice(0, 140); + return false; + } finally { + tapSync.saving = false; + } +} diff --git a/handoff/rounds/ins-r23-mock.txt b/handoff/rounds/ins-r23-mock.txt new file mode 100644 index 0000000..828fd37 --- /dev/null +++ b/handoff/rounds/ins-r23-mock.txt @@ -0,0 +1,14 @@ + if (cmd === "lyrics_set_auto_sync") { autoSyncOff = !args?.enabled; return Promise.resolve(); } + if (cmd === "lyrics_save_lines") { + /* the Rust drops the empty lines and sorts by time — a half-tapped + set is stored as the lines that HAVE one */ + const lines = (args?.lines || []).filter((l) => String(l?.text || "").trim()) + .map((l) => ({ time_ms: l.time_ms, text: l.text })).sort((a, b) => a.time_ms - b.time_ms); + if (!lines.length) return Promise.reject(new Error("Nothing to save")); + /* the Rust's payload_from does NOT carry the entry's auto-sync flag + back from a write — false here, faithfully, so the walk notices if + the store ever applies a save over the switch */ + return Promise.resolve({ status: "synced", source: "user", synced: lines, plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false }); + } + /* the vocal-reduced copy: a render takes a beat, and it is a new file */ + if (cmd === "lyrics_prepare_instrumental") return new Promise((r) => setTimeout(() => r({ path: String(args?.path || "") + ".instrumental.wav", cached: false, durationSecs: 214 }), 400)); diff --git a/handoff/rounds/ins-r23-shell-css.txt b/handoff/rounds/ins-r23-shell-css.txt new file mode 100644 index 0000000..f8cd46f --- /dev/null +++ b/handoff/rounds/ins-r23-shell-css.txt @@ -0,0 +1,22 @@ + +/* ── the tap-to-sync room: the song plays, the screen is the tap ───────── */ +.syncroom { position: fixed; inset: 0; z-index: 150; display: flex; flex-direction: column; background: radial-gradient(120% 70% at 20% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 58%), var(--bg-base); padding-top: var(--inset-top); padding-bottom: var(--inset-bottom); animation: viewer-in 160ms ease both; } +.sy-head { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 2px; flex: none; } +.sy-close { width: auto; min-width: var(--tap); padding: 0 10px; border-radius: var(--r-pill); } +.sy-discard { font-size: 12.5px; font-weight: 800; color: #f15e6c; } +.sy-who { flex: 1; min-width: 0; text-align: center; } +.sy-who b { display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--accent-hover); } +.sy-who i { display: block; font-style: normal; font-size: 13px; font-weight: 700; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sy-count { flex: none; min-width: 54px; text-align: right; font-size: 13px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.sy-warn { margin: 4px var(--pad) 0; padding: 9px 13px; border-radius: 10px; background: rgba(232, 163, 61, 0.14); color: #e8a33d; font-size: 12.5px; font-weight: 700; flex: none; } +.sy-pad { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px var(--pad); text-align: center; } +.sy-pad:active { background: rgba(255, 255, 255, 0.05); } +.sy-pad:disabled { opacity: 0.5; } +.sy-prev, .sy-next { display: block; width: 100%; min-height: 20px; font-size: 15px; font-weight: 650; line-height: 1.35; color: rgba(255, 255, 255, 0.3); overflow: hidden; } +.sy-now { display: block; width: 100%; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.25; color: #fff; text-shadow: 0 0 26px color-mix(in srgb, var(--accent) 50%, transparent); } +.sy-hint { display: block; margin-top: 6px; font-size: 12px; font-weight: 650; color: var(--text-muted); } +.sy-mark { display: block; font-size: 13px; font-weight: 800; color: var(--accent-hover); font-variant-numeric: tabular-nums; } +.sy-foot { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 16px; flex: none; } +.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; } +.sy-foot .iconbtn:disabled { opacity: 0.4; } +.sy-save { flex: 1; } diff --git a/handoff/rounds/ins-r23-smoke-mock-expect.txt b/handoff/rounds/ins-r23-smoke-mock-expect.txt new file mode 100644 index 0000000..61af697 --- /dev/null +++ b/handoff/rounds/ins-r23-smoke-mock-expect.txt @@ -0,0 +1,13 @@ + "lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off", + "lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on", + "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow, + "sync-tapped": (i) => i.sync && i.syCount === "2 / 3", + "sync-undone": (i) => i.sync && i.syCount === "1 / 3", + "sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText), + "sync-resumed": (i) => i.sync && i.syCount === "1 / 3", + "sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow), + "sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast, + /* the save's own payload carries no auto-sync flag: the switch kept its */ + "sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off", + "lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast, + "lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast, diff --git a/handoff/rounds/ins-r23-smoke-mock-steps.txt b/handoff/rounds/ins-r23-smoke-mock-steps.txt new file mode 100644 index 0000000..362bb09 --- /dev/null +++ b/handoff/rounds/ins-r23-smoke-mock-steps.txt @@ -0,0 +1,25 @@ + /* auto-sync: the app's own correction turned off for this track, then + back on — the re-read leaves the sheet up so the switch is seen */ + { do: `__click('.lyr-tools', null)`, wait: 600 }, + { do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 }, + { do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 }, + /* off again, and left off: a save returns the document with that flag at + its default, and the switch must not take it */ + { do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 }, + /* the tap-to-sync room: taps become the timing, one undone, the room + peeled by the back gesture and the set offered back, then saved */ + { do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 }, + { do: `__click('.sy-pad', null)`, wait: 400 }, + { do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 }, + { do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 }, + { do: `history.back()`, wait: 600 }, + { do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 }, + { do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 }, + { do: `__click('.sy-pad', null)`, wait: 400 }, + { do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 }, + { do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 }, + /* the vocal-reduced copy, and back to the recording */ + { do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 }, + { do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 }, + { do: `__click('.lyr-tools', null)`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 }, diff --git a/handoff/rounds/ins-r23-smoke-phone-expect.txt b/handoff/rounds/ins-r23-smoke-phone-expect.txt new file mode 100644 index 0000000..487de35 --- /dev/null +++ b/handoff/rounds/ins-r23-smoke-phone-expect.txt @@ -0,0 +1,8 @@ + /* the walked album's track may genuinely have no words, so the room's + own rows are conditional — the discard is not */ + "lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"), + "lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"), + "sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)), + "sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)), + "sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)), + "sync-discarded": (i) => !i.sync && !i.toast, diff --git a/handoff/rounds/ins-r23-smoke-phone-steps.txt b/handoff/rounds/ins-r23-smoke-phone-steps.txt new file mode 100644 index 0000000..6f4292e --- /dev/null +++ b/handoff/rounds/ins-r23-smoke-phone-steps.txt @@ -0,0 +1,11 @@ + /* auto-sync off and straight back on — the cache entry ends as it began */ + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 }, + /* the tap room on the real clock: two taps, one undone, then discarded — + nothing is written to Kyle's own lyrics for this track */ + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 }, + { do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 }, + { do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 }, + { do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 }, + { do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 }, + { do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 }, diff --git a/handoff/rounds/ins-r23-smoke-probe.txt b/handoff/rounds/ins-r23-smoke-probe.txt new file mode 100644 index 0000000..b5019b0 --- /dev/null +++ b/handoff/rounds/ins-r23-smoke-probe.txt @@ -0,0 +1,5 @@ + sync: !!document.querySelector('.syncroom'), + syCount: (document.querySelector('.sy-count') || {}).textContent || '', + syNow: (document.querySelector('.sy-now') || {}).textContent || '', + lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(), + trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(), diff --git a/handoff/rounds/ins-r23-syncroom.txt b/handoff/rounds/ins-r23-syncroom.txt new file mode 100644 index 0000000..230b5d7 --- /dev/null +++ b/handoff/rounds/ins-r23-syncroom.txt @@ -0,0 +1,110 @@ + + +{#if nav.timing} +
+
+ + Tap in time{tapSync.identity?.title ?? ""} + {done} / {rows.length} +
+ + {#if wrong} +
Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.
+ {:else if tapSync.error} +
{tapSync.error}
+ {/if} + + + +
+ + + + +
+
+{/if} diff --git a/handoff/rounds/ins-r24-importer-folder.txt b/handoff/rounds/ins-r24-importer-folder.txt new file mode 100644 index 0000000..ac4cdb3 --- /dev/null +++ b/handoff/rounds/ins-r24-importer-folder.txt @@ -0,0 +1,30 @@ +/** The folder door picks FIRST — the phone's own document-tree picker — so + * the review only opens for a folder that was actually chosen. The tree has + * to be on this phone's storage: the backend walks a path, and a cloud + * provider's tree has none. Returns null when the picker was dismissed. */ +export async function pickImportFolder() { + let picked; + try { picked = await api.pickLibraryFolder(); } + catch (e) { if (/cancelled/.test(String(e))) return null; throw e; } + if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage."); + return picked.path; +} +/** Stage a folder of audio files. The backend walks the folder and its disc + * subfolders, orders by the files' own track numbers when every one carries + * a distinct one and by natural filename order otherwise, and matches each + * file against the library by path. Every row comes back local, so there is + * nothing for the catalog pass to verify. */ +export async function startFolder(path) { + const g = ++gen; + Object.assign(importer, { door: "folder", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false }); + try { + const draft = await api.playlistImportFolder(path); + if (g !== gen) return; + importer.busy = false; + if (!draft) { importer.error = "Nothing came back from that folder."; return; } + importer.draft = draft; + importer.name = draft.name ?? ""; + } catch (e) { + if (g === gen) { importer.error = String(e); importer.busy = false; } + } +} diff --git a/handoff/rounds/ins-r24-importer-regex.txt b/handoff/rounds/ins-r24-importer-regex.txt new file mode 100644 index 0000000..989f198 --- /dev/null +++ b/handoff/rounds/ins-r24-importer-regex.txt @@ -0,0 +1,8 @@ + spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer", + links: "Pasted links", +}; + +/* Mirrors the backend's detector closely enough to CHOOSE a door; the + backend decides for real. A paste holding even one track link takes the + link path, so a list copied out of Spotify never parses as track names. */ +const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i; diff --git a/handoff/rounds/ins-r24-mock.txt b/handoff/rounds/ins-r24-mock.txt new file mode 100644 index 0000000..3b4eeb2 --- /dev/null +++ b/handoff/rounds/ins-r24-mock.txt @@ -0,0 +1,39 @@ + if (cmd === "playlist_import_track_links") { + const links = String(args?.text || "").split(/\r?\n/).map((l) => l.trim()) + .filter((l) => /^https?:\/\//.test(l) || /^spotify:track:/.test(l)); + if (!links.length) return Promise.reject(new Error("Nothing to import in that paste.")); + /* one row per link, in the paste's order — the lines a real lookup + would have read off each track's own public page. A page each is + slow, and the pace here is slow on purpose: the walk's still takes + long enough to miss a wait card that closes inside a second. */ + const named = ["Aphex Twin - Track 1", "Daft Punk - One More Time", "Bicep - Glue", "Four Tet - Track 2"]; + const entries = impParse(links.map((_, i) => named[i % named.length]).join("\n")); + return new Promise((r) => { + let done = 0; + const tick = setInterval(() => { + done++; + mockEmit("playlist:link-progress", { done, total: links.length, waiting: false }); + if (done >= links.length) { + clearInterval(tick); + setTimeout(() => r(impDraft(entries, args?.name || "Pasted track links", "links", args?.name ?? null, { + warnings: [entries.length + " track" + (entries.length === 1 ? "" : "s") + " read from " + links.length + " pasted link" + (links.length === 1 ? "" : "s") + "."], + })), 250); + } + }, 600); + }); + } + if (cmd === "playlist_import_folder") { + const root = String(args?.path || ""); + if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */ + const name = root.split(/[\\/]/).filter(Boolean).pop() || "Folder"; + /* four files off a record the library already holds, as the backend's + walk hands them over: every row local, matched by path */ + const own = albums[0]; + const entries = tracksFor(own.id).slice(0, 4).map((t, i) => ({ + ordinal: i, title: t.title, artist: own.artist, album: own.title, + duration_secs: t.duration_secs, start_ms: null, raw: t.path, + resolution: "local", matched_by: "path", path: t.path, art_path: own.art_path, + track_id: null, art_url: null, isrc: null, + })); + return new Promise((r) => setTimeout(() => r(impDraft(entries, name, "folder", name)), 500)); + } diff --git a/handoff/rounds/ins-r24-smoke-expect.txt b/handoff/rounds/ins-r24-smoke-expect.txt new file mode 100644 index 0000000..f3079d2 --- /dev/null +++ b/handoff/rounds/ins-r24-smoke-expect.txt @@ -0,0 +1,4 @@ + "import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine), + "import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn), + "import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn), + "import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4, diff --git a/handoff/rounds/ins-r24-smoke-mock.txt b/handoff/rounds/ins-r24-smoke-mock.txt new file mode 100644 index 0000000..b28be0a --- /dev/null +++ b/handoff/rounds/ins-r24-smoke-mock.txt @@ -0,0 +1,13 @@ + /* pasted TRACK links: the paste door reads each one off its own page — + the wait line names the door, the warning counts the links */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/aaaaaaaaaaaaaaaaaaaaaa\\nhttps://open.spotify.com/track/bbbbbbbbbbbbbbbbbbbbbb\\nhttps://open.spotify.com/track/cccccccccccccccccccccc'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 500 }, + { name: "import-links-review", wait: 2600 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the folder door: the phone's own folder picker, then the backend's walk */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-review", wait: 2200 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-folder-created", wait: 1500 }, + { do: `history.back()`, wait: 900 }, diff --git a/handoff/rounds/ins-r24-smoke-phone-expect.txt b/handoff/rounds/ins-r24-smoke-phone-expect.txt new file mode 100644 index 0000000..464bbd2 --- /dev/null +++ b/handoff/rounds/ins-r24-smoke-phone-expect.txt @@ -0,0 +1,8 @@ + /* the real core can answer one track link faster than the probe lands, + so the wait card is allowed to be gone already — but then the review + it turned into has to be there. Either way the door did something. */ + "import-links-wait": (i) => i.title.startsWith("Import") && (i.impWait ? /pasted links/.test(i.impWaitLine) : i.imp > 0 || i.impErr), + "import-links-review": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), + "import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0, + "import-folder-picker": (i) => !i.half.includes("up") && !i.toast, + "import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, diff --git a/handoff/rounds/ins-r24-smoke-phone.txt b/handoff/rounds/ins-r24-smoke-phone.txt new file mode 100644 index 0000000..6f2850b --- /dev/null +++ b/handoff/rounds/ins-r24-smoke-phone.txt @@ -0,0 +1,17 @@ + /* the pasted-links door on the real core: one track link, and the wait + line says which door read the paste; the review is left as it came */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 }, + { name: "import-links-review", wait: 9000 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the folder door on the real core, one record's own folder — the + argument the command grew, walked for real; nothing is created */ + { do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 }, + { do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 }, + /* the picker itself is a system sheet the walk cannot tap: open it, + dismiss it with BACK, and check the cancel path leaves nothing */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 }, + { adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 }, diff --git a/handoff/rounds/ins-r25-popular.txt b/handoff/rounds/ins-r25-popular.txt new file mode 100644 index 0000000..cb8bc61 --- /dev/null +++ b/handoff/rounds/ins-r25-popular.txt @@ -0,0 +1,15 @@ +/** Popular on its own — the desktop's fast lane (`catalog_artist_popular`). + * It waits on nothing but the linked MBID and one Last.fm call, so the + * section can paint while the full page is still being built; the page's + * own list wins once it lands (the same order, from the same inputs), and + * this one stays for an artist whose page came back with no Popular. */ +export function loadPopular(id) { + if (id == null || catalog.popular[id]) return; + catalog.popular[id] = { status: "loading", tracks: [] }; + api.catalogArtistPopular(id).then( + (tracks) => { catalog.popular[id] = { status: "ready", tracks: tracks ?? [] }; }, + () => { catalog.popular[id] = { status: "error", tracks: [] }; }, + ); +} + +/** A portrait for a byline — the same identity rule, search only. */ diff --git a/handoff/rounds/ins-r25-steps-hero.txt b/handoff/rounds/ins-r25-steps-hero.txt new file mode 100644 index 0000000..58546fb --- /dev/null +++ b/handoff/rounds/ins-r25-steps-hero.txt @@ -0,0 +1,7 @@ + { do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, + /* the artist's photo: the page's ⋯ → a picture from this phone becomes + the hero, the page is asked again, then the catalog's own come back */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 }, + { do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 }, + { do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 }, diff --git a/handoff/rounds/ins-r25-steps-phone.txt b/handoff/rounds/ins-r25-steps-phone.txt new file mode 100644 index 0000000..db4c062 --- /dev/null +++ b/handoff/rounds/ins-r25-steps-phone.txt @@ -0,0 +1,5 @@ + { name: "artist", wait: 4500 }, + /* the page's ⋯ on the real core: the hero door is there and knows the + artist's id — it opens the system picker, so the walk only looks */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 600 }, diff --git a/handoff/rounds/ins-r25-steps-you.txt b/handoff/rounds/ins-r25-steps-you.txt new file mode 100644 index 0000000..7cbc604 --- /dev/null +++ b/handoff/rounds/ins-r25-steps-you.txt @@ -0,0 +1,3 @@ + { do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 }, + /* the desktop's portrait refresh, beside the covers' */ + { do: `__click('.screen .row', 'Refresh artist portraits')`, name: "you-portraits", wait: 900 }, diff --git a/handoff/rounds/ins-r25-store.txt b/handoff/rounds/ins-r25-store.txt new file mode 100644 index 0000000..1ea55bf --- /dev/null +++ b/handoff/rounds/ins-r25-store.txt @@ -0,0 +1,51 @@ +/** The rows a record's own artist anchor found no count for — a feature's + * guest, a compilation's stranger — asked by each row's OWN Deezer artist + * id, the rule the desktop's search results follow. A row the backend has + * no count for keeps nothing: unknown is not a zero. */ +export function loadTrackListeners(albumId, tracks) { + if (albumId == null || catalog.listeners[albumId]) return; + const want = (tracks || []).filter((t) => t.listeners == null && t.artist_id != null); + if (!want.length) return; + catalog.listeners[albumId] = { status: "loading", counts: {} }; + api.catalogTrackListeners(want.map((t) => ({ artistId: t.artist_id, title: t.title }))).then( + (counts) => { + const out = {}; + (counts || []).forEach((n, i) => { if (n != null && want[i]) out[want[i].id] = n; }); + catalog.listeners[albumId] = { status: "ready", counts: out }; + }, + () => { catalog.listeners[albumId] = { status: "error", counts: {} }; }, + ); +} + +/** Forget an artist's page and ask for it again — the desktop's + * `invalidateCatalogArtist`, for a hero that has just changed. The seed is + * kept, so the identity search does not run a second time. */ +export function forgetArtist(name) { + const k = artistKey(name); + const cur = catalog.artists[k]; + if (!cur) return; + delete catalog.artists[k]; + lookupArtist(name, cur.seed ?? null); +} + +/** A photo from this phone as an artist's hero — the desktop's Choose hero, + * through the phone's own image picker. The page is asked again, so the + * banner, the ground and the accent all follow. */ +export async function heroFromPhoto(id, name) { + let r; + try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; } + if (!r?.path) return false; + const hero = await api.setArtistHero(id, r.path); + forgetArtist(name); + note(`Hero set · ${hero.width}×${hero.height}`); + return true; +} + +/** The chosen hero removed — the catalog's own photos come back. */ +export async function heroClear(id, name) { + const removed = await api.clearArtistHero(id); + forgetArtist(name); + note(removed ? "The catalog's own photos are back" : "No custom hero here"); +} + +export function lookupAlbum(id) { diff --git a/handoff/rounds/ins-r25-you.txt b/handoff/rounds/ins-r25-you.txt new file mode 100644 index 0000000..fa84ae1 --- /dev/null +++ b/handoff/rounds/ins-r25-you.txt @@ -0,0 +1,10 @@ + /* the desktop Settings row: a portrait for every artist already here */ + async function refreshPortraits() { + if (refreshingArtists) return; + refreshingArtists = true; + try { const n = await api.refreshArtistArt(); note(`${n ?? 0} portrait${n === 1 ? "" : "s"} refreshed`); } + catch (e) { player.error = String(e); } + finally { refreshingArtists = false; } + } + const ROWS = [ + ["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"], diff --git a/handoff/rounds/ins-r26-diag.txt b/handoff/rounds/ins-r26-diag.txt new file mode 100644 index 0000000..89f5097 --- /dev/null +++ b/handoff/rounds/ins-r26-diag.txt @@ -0,0 +1,108 @@ + + +

Diagnostics

+
The desktop's debug overlay, on the dev flavour. Polled once a second while this screen is open.
+ +{#if failed} +
+ No diagnostics. + {failed} +
+{/if} + +
This process
+
+
App CPU{pct(stats?.cpuProcess)}
+
System CPU{pct(stats?.cpuSystem)}
+
Memory{size(stats?.memWorkingSet)}
+
System memory{size(stats?.memSystemUsed)} of {size(stats?.memSystemTotal)}
+
Processes{stats?.processCount ?? "—"}
+
Up{upfor(stats?.uptimeSecs)}
+
+
Processes counts this app's own tree. Android runs the WebView in a system process of its own, so it is outside these numbers; on the desktop the whole WebView2 family is inside them.
+ +
Network
+
+
Down{rate(stats?.netRxBps)}
+
Up{rate(stats?.netTxBps)}
+
+
Every interface on the phone, not this app alone.
+ +
The track playing
+
+
Stream{!stats ? "—" : stats.dlTotal ? size(stats.dlReceived) + " of " + size(stats.dlTotal) + (stats.dlDone ? " · done" : "") : "nothing streaming"}
+
Normalization{!stats ? "—" : stats.normalizationEnabled ? "on · " + (stats.targetLufs ?? -14) + " LUFS" : "off"}
+
Measured{stats?.loudness?.lufs != null ? stats.loudness.lufs.toFixed(1) + " LUFS" + (stats.loudness.analyzing ? " · measuring" : "") : "—"}
+
Gain{db(stats?.normNowDb)}{stats && stats.normNowDb != null && stats.normNowDb !== stats.normTargetDb ? " → " + db(stats.normTargetDb) : ""}
+
+ +
On deck
+{#if stats?.preloadDl?.length} +
+ {#each stats.preloadDl as p (p.username + p.path)} +
+ + + {basename(p.path)} + {p.username} · {p.state}{p.place != null ? " · queue " + p.place : ""}{p.speedBps ? " · " + fmtBytes(p.speedBps) + "/s" : ""} + {#if p.total}{fmtBytes(p.received)}{/if} + +
+ {/each} +
+{:else} +
Nothing pre-buffering. The player readies the next three tracks while one plays.
+{/if} + +
Library
+
+
Play rows{plays != null ? plays.toLocaleString() : "—"}
+
+ +
diff --git a/handoff/rounds/ins-r26-doc-copy.txt b/handoff/rounds/ins-r26-doc-copy.txt new file mode 100644 index 0000000..4e092e2 --- /dev/null +++ b/handoff/rounds/ins-r26-doc-copy.txt @@ -0,0 +1,5 @@ +record the queue holds; beside them, "Copy to a folder…" writes that same +cached stream into a folder picked on this phone, as the plain file it is +(`copy_stream_to_folder` — no re-download, no tagging, no library row; a +track that has not finished streaming is counted back, never fetched). +A moment after boot the phone warms the library's thumbnails the diff --git a/handoff/rounds/ins-r26-doc.txt b/handoff/rounds/ins-r26-doc.txt new file mode 100644 index 0000000..5aeb11b --- /dev/null +++ b/handoff/rounds/ins-r26-doc.txt @@ -0,0 +1,22 @@ +Sharing carries one thing the desktop never exposed at all: the listening +port (`listenPort`), what other peers dial when they download from you. +Unset means the client's own 50300 (`DEFAULT_LISTEN_PORT`; bind walks +forward a few ports if it is taken); the app asks the router to forward it +over UPnP and falls back to the server relaying the connection, and a +change applies on the next connect — on mobile data the carrier's NAT +blocks incoming connections whatever the number is. `set_settings` only +assigns a port that is Some, so it can be changed but never taken back to +unset: the phone walk saves a spare number and then 50300, which is the +port an unset config already used. + +The dev flavour gets one more row under This phone: Diagnostics +(`screens/Diagnostics.svelte`), the desktop's Ctrl+Shift+D overlay as a +screen — process and system CPU, memory, the network rates, the track +playing with its loudness correction, the tracks the pre-buffer is +fetching, and how big the play history has grown (`debug_stats`, +`play_row_count`), polled once a second while it is open and never +otherwise. `devBuild` in `util.js` is the gate: Vite's own DEV flag, true +only for the build that loads from the dev server over the cable, so the +embedded build the phone keeps has no row to tap. The GPU counters are +Windows PDH and the private commit is a Win32 call, so neither is on the +screen — off Windows they report None and 0. diff --git a/handoff/rounds/ins-r26-halfsheet-copy.txt b/handoff/rounds/ins-r26-halfsheet-copy.txt new file mode 100644 index 0000000..cfda425 --- /dev/null +++ b/handoff/rounds/ins-r26-halfsheet-copy.txt @@ -0,0 +1,18 @@ + /* the desktop's copy_stream_to_folder: the cached stream written into a + folder picked on this phone, as the file it is — no re-download, no + tagging, no library row. Nothing leaves the phone, so the Wi-Fi guard + has no say here. A track that has not finished streaming is counted + back as missing, never fetched. */ + async function copyToFolder(entries) { + const files = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path })); + if (!files.length) return; + try { + const r = await api.pickLibraryFolder(); + if (!r?.path) { player.error = "That folder has no path the phone can write to — pick one on this phone's storage."; return; } + const [saved, missing] = await api.copyStreamToFolder(files, r.path); + note(saved ? saved + " file" + (saved === 1 ? "" : "s") + " copied" + (missing ? " · " + missing + " not cached yet" : "") + : "Not cached yet — play it through first"); + } catch (e) { + if (!/cancelled/.test(String(e))) oops(e); + } + } diff --git a/handoff/rounds/ins-r26-mock-expect.txt b/handoff/rounds/ins-r26-mock-expect.txt new file mode 100644 index 0000000..fff880b --- /dev/null +++ b/handoff/rounds/ins-r26-mock-expect.txt @@ -0,0 +1,5 @@ + "you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port), + "you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast, + "you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port), + "diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1, + "diag-back": (i) => i.title.startsWith("You"), diff --git a/handoff/rounds/ins-r26-mock-steps.txt b/handoff/rounds/ins-r26-mock-steps.txt new file mode 100644 index 0000000..a37fc0e --- /dev/null +++ b/handoff/rounds/ins-r26-mock-steps.txt @@ -0,0 +1,13 @@ + /* the listening port: typed in, saved, read back off the config — then + a port below 1024 refused and the saved one left standing */ + { do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 600 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50555'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 700 }, + { do: `__click('.screen .row', 'Listening port')`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 600 }, + { do: `__click('.toast:not(.notice)', null)`, wait: 300 }, + /* the dev flavour's diagnostics — the desktop's Ctrl+Shift+D overlay as + a screen; a normal build has no row here to tap */ + { do: `__click('.screen .row', 'Diagnostics')`, name: "diag", wait: 1500 }, + { do: `history.back()`, name: "diag-back", wait: 800 }, diff --git a/handoff/rounds/ins-r26-mock.txt b/handoff/rounds/ins-r26-mock.txt new file mode 100644 index 0000000..208f12a --- /dev/null +++ b/handoff/rounds/ins-r26-mock.txt @@ -0,0 +1,28 @@ + if (cmd === "copy_stream_to_folder") { + /* the real command copies only what the stream cache already holds */ + let saved = 0, missing = 0; + for (const f of args?.files || []) { if (cacheReady.has(f.path)) saved++; else missing++; } + return Promise.resolve([saved, missing]); + } + if (cmd === "play_row_count") return Promise.resolve(4210); + if (cmd === "debug_stats") { + /* the overlay's shape, camelCase as serde renames it; the numbers + wander a little so the screen is seen to be live */ + const t = Date.now() / 1000; + return Promise.resolve({ + cpuProcess: 7.5 + Math.sin(t), cpuSystem: 22 + Math.cos(t) * 3, + gpuPercent: null, gpuAppPercent: null, + memWorkingSet: 431_000_000, memPrivate: 0, + memSystemUsed: 5_400_000_000, memSystemTotal: 7_900_000_000, + netRxBps: 1_250_000, netTxBps: 96_000, + processCount: 3, uptimeSecs: 742, + dlReceived: xfer ? Math.round(xfer.received) : null, + dlTotal: xfer ? xfer.total : null, + dlDone: xfer ? xfer.received >= xfer.total : null, + normalizationEnabled: mockSettings.volume_normalization !== false, targetLufs: -14, + loudness: { lufs: -9.8, peak: 0.98, gainDb: -4.2, cached: true, analyzing: false, source: "cache", headStartDb: 0, prefixGainDb: null }, + mainFadeGain: 1, normNowDb: -4.2, normTargetDb: -4.2, + previewVolume: null, previewNormDb: null, + preloadDl: [{ username: "dusty_crates", path: "Music\\Daft Punk\\Discovery\\02 Aerodynamic.flac", state: "transferring", place: null, received: 5_000_000, total: 12_000_000, speedBps: 850_000 }], + }); + } diff --git a/handoff/rounds/ins-r26-np-steps.txt b/handoff/rounds/ins-r26-np-steps.txt new file mode 100644 index 0000000..77473b2 --- /dev/null +++ b/handoff/rounds/ins-r26-np-steps.txt @@ -0,0 +1,5 @@ + /* the same cache, copied out as a plain file into a picked folder — + nothing downloaded again, no library row */ + { do: `__click('.halfsheet .row', 'Copy to a folder')`, name: "np-copied", wait: 900 }, + { do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 }, diff --git a/handoff/rounds/ins-r26-phone-expect.txt b/handoff/rounds/ins-r26-phone-expect.txt new file mode 100644 index 0000000..4220a6f --- /dev/null +++ b/handoff/rounds/ins-r26-phone-expect.txt @@ -0,0 +1,3 @@ + "you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast, + "you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast, + "you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast, diff --git a/handoff/rounds/ins-r26-phone-steps.txt b/handoff/rounds/ins-r26-phone-steps.txt new file mode 100644 index 0000000..26c5fcf --- /dev/null +++ b/handoff/rounds/ins-r26-phone-steps.txt @@ -0,0 +1,9 @@ + /* the listening port on the real core, camelCase for real: a spare + number, then 50300 — the client's own default, so the phone ends the + walk on exactly the port an unset config would already have used */ + { do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 900 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50310'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 1200 }, + { do: `__click('.screen .row', 'Listening port')`, wait: 800 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 }, diff --git a/handoff/rounds/ins-r26-probe.txt b/handoff/rounds/ins-r26-probe.txt new file mode 100644 index 0000000..3d579f6 --- /dev/null +++ b/handoff/rounds/ins-r26-probe.txt @@ -0,0 +1,5 @@ + port: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Listening port/.test(x.textContent); }); var m = r && r.querySelector('.m'); return m ? m.textContent.trim() : ''; })(), + diagRows: document.querySelectorAll('.group.diag .kv').length, + diagCpu: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /App CPU/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(), + diagPlays: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /Play rows/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(), + diagDeck: document.querySelectorAll('.group.diag-deck .row').length, diff --git a/handoff/rounds/ins-r26-you-port.txt b/handoff/rounds/ins-r26-you-port.txt new file mode 100644 index 0000000..74e444e --- /dev/null +++ b/handoff/rounds/ins-r26-you-port.txt @@ -0,0 +1,19 @@ + /* the Soulseek listener: the port other peers dial when they download + from you. Unset means the client's own 50300, read at connect — bind + walks forward a few ports if it is taken. `set_settings` only assigns + a port that is Some, so a number typed here can be changed but never + taken back to unset. */ + const DEFAULT_LISTEN_PORT = 50300; + function portPrompt() { + openHalf("prompt", { + title: "Listening port", + value: cfg?.listen_port != null ? String(cfg.listen_port) : "", + placeholder: String(DEFAULT_LISTEN_PORT), + ok: "Save", numeric: true, + onsubmit: (v) => { + const n = Number(v); + if (!Number.isInteger(n) || n < 1024 || n > 65535) { player.error = "A port is a number between 1024 and 65535."; return; } + void saveCfg({ listenPort: n }); + }, + }); + } diff --git a/handoff/rounds/ins-r27-css.txt b/handoff/rounds/ins-r27-css.txt new file mode 100644 index 0000000..7d0403d --- /dev/null +++ b/handoff/rounds/ins-r27-css.txt @@ -0,0 +1,91 @@ +/* ── the phone turned sideways ──────────────────────────────────────────── + The activity declares no screenOrientation and handles orientation and + screenSize itself, so a rotation is a RESIZE: no state is lost and the + layout is the only thing that has to answer. Two queries do it. The player + sheet is the surface whose portrait shape cannot survive a wide viewport — + its artwork is a square sized by WIDTH, so at 915x411 the cover came out + 879px tall and the title, the lane, the transport and the face buttons all + sat below the bottom of a sheet that does not scroll. That is aspect, not + height, so its rule keys off orientation alone and a tablet gets it too; + everything under it is a phone-sized query. + + These rules are appended, and the order is load-bearing: the side-inset + longhands below have to come after .gbar's padding shorthand, .mini's + left/right and .toast's left/right to win at equal specificity. */ + +/* the side insets: the cutout and the gesture pill, from the edges */ +.gbar { padding-left: calc(6px + var(--inset-left)); padding-right: calc(8px + var(--inset-right)); } +.nav { padding-left: var(--inset-left); padding-right: var(--inset-right); } +.mini { left: calc(8px + var(--inset-left)); right: calc(8px + var(--inset-right)); } +.screen { padding-left: var(--inset-left); padding-right: var(--inset-right); } +.np { padding-left: var(--inset-left); padding-right: var(--inset-right); } +.halfsheet { padding-left: var(--inset-left); padding-right: var(--inset-right); } +.syncroom { padding-left: var(--inset-left); padding-right: var(--inset-right); } +.toast { left: calc(12px + var(--inset-left)); right: calc(12px + var(--inset-right)); } + +/* ── the player sheet, beside itself ─────────────────────────────────── */ +@media (orientation: landscape) { + .np { + display: grid; + grid-template-columns: minmax(0, 46%) minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto auto auto auto; + grid-template-areas: + "head head" + "left slack" + "left meta" + "left scrub" + "left trans" + "left foot"; + column-gap: 6px; + /* insurance for a viewport shorter than the controls themselves — + the keyboard up on a small phone — where the sheet would clip */ + overflow-y: auto; + overscroll-behavior: contain; + } + .np-head { grid-area: head; } + .np-art { grid-area: left; padding: 10px 4px 10px var(--pad); } + /* the square is sized by whichever is smaller, the column or the height */ + .np-frame { width: min(100%, calc(100vh - var(--inset-top) - var(--inset-bottom) - 92px)); } + .np-spacer { grid-area: slack; padding: 10px var(--pad) 0; justify-content: center; } + .np-face { grid-area: left; } + .np-meta { grid-area: meta; padding-top: 2px; } + .np-scrub { grid-area: scrub; padding-top: 8px; } + .np-transport { grid-area: trans; padding-top: 10px; justify-self: center; width: min(100%, 430px); } + .np-foot { grid-area: foot; padding-top: 8px; } + /* a face is up: it takes the artwork's column and the thumbnail goes — + the record is already the ground behind the whole sheet */ + .np.small { grid-template-columns: minmax(0, 1fr) minmax(0, 430px); } + .np.small .np-art { display: none; } +} + +/* ── a phone turned sideways: 411 pixels of height to spend ───────────── */ +@media (orientation: landscape) and (max-height: 560px) { + /* the nav comes down to Android's own 48dp minimum and the mini bar to a + 60dp row over the same lane strip: 22 pixels back, 46% of the height as + chrome down to 40% */ + :root { --nav-h: 48px; --mini-h: 60px; } + .nav button .gl { height: 22px; } + .nav button .lb { font-size: 9.5px; } + .nav button .pill { top: 3px; height: 24px; } + .mini { padding-bottom: 24px; } + .mini .art { width: 34px; height: 34px; } + .mini .mini-lane { bottom: 5px; } + + /* the hero stops being a portrait and becomes the banner the desktop's + hero has always been */ + .hero { height: 190px; } + .hero-name { font-size: 27px; } + .hero-logo { max-height: 64px; } + + /* the record head puts the cover beside the title, again the desktop's + own shape; .pl needs naming because its own rule is more specific */ + .rec-head { flex-direction: row; align-items: center; gap: 22px; padding: calc(var(--bar-h) + 12px) var(--pad) 4px; } + .rec-head .rec-cover, .rec-head.pl .rec-cover { width: 34%; max-width: 240px; } + .rec-meta { text-align: left; } + .rec-title { margin-top: 0; } + .rec-chips { justify-content: flex-start; } + .rec-acts { justify-content: flex-start; padding: 14px 0 4px; } + + /* 76% of 411 is 312; a half sheet may take all but a strip */ + .halfsheet { max-height: calc(100% - 36px); } +} diff --git a/handoff/rounds/ins-r27-doc.txt b/handoff/rounds/ins-r27-doc.txt new file mode 100644 index 0000000..b578137 --- /dev/null +++ b/handoff/rounds/ins-r27-doc.txt @@ -0,0 +1,30 @@ +The activity declares no `screenOrientation` and handles `orientation` and +`screenSize` itself, so turning the phone over is a resize and nothing else — +the audio thread, the stack and the screen's scroll all survive it, and the +layout is the only thing that has to answer. Two media queries do that. The +player sheet is the one surface whose portrait shape cannot survive a wide +viewport: its artwork is a square sized by WIDTH, so at 915×411 the cover came +out 879px tall and the title, the lane, the transport and the face buttons sat +below the bottom of a sheet that does not scroll — a corner of the cover and no +way to pause. Sideways it is two columns: the head across the top, the cover +down the left sized by HEIGHT, and the track, the scrubber, the transport and +the faces stacked on the right. With lyrics or the queue up, the face takes the +cover's column and the thumbnail goes — the record is already the ground behind +the whole sheet. That is an aspect problem rather than a height one, so the rule +keys off `orientation: landscape` alone and a tablet gets the same two columns. + +The rest is a phone-sized query (`max-height: 560px`). The nav comes down to +Android's own 48dp minimum and the mini bar to a 60dp row over the same lane +strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46% +of the height, it is 40%. The artist hero stops being a 300px portrait, which +covered its own Play button with the mini bar, and becomes a 190px banner: what +the desktop's hero has always been. The record head puts the cover beside the +title rather than above it, again the desktop's own shape, so Play is in view +without a scroll. A half sheet may take all but 36px of the height instead of +76% of it. Everything else — the grids, the rails, the artwork viewer, the +tap-to-sync room, the chat composer — already answered to width and was left +alone. `viewport-fit=cover` means the cutout and the gesture pill come in from +the SIDES in landscape, so the shell reads `--inset-left` / `--inset-right` +beside the top and bottom ones now. Those rules are appended at the end of +`shell.css` on purpose: they have to come after `.gbar`'s padding, `.mini`'s +and `.toast`'s left/right to win at equal specificity. diff --git a/handoff/rounds/ins-r27-expect.txt b/handoff/rounds/ins-r27-expect.txt new file mode 100644 index 0000000..00d1539 --- /dev/null +++ b/handoff/rounds/ins-r27-expect.txt @@ -0,0 +1,19 @@ + /* sideways: the chrome budget, then every surface that was clipped. The + before-value of each is in the rig's now.json — 46% of the height as + chrome, a 879px cover with the transport 687px below the fold, a + lyrics face collapsed to nothing, a 300px hero, and the record's own + Play button off the bottom of a 411px screen. */ + landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title), + "landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0, + "landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0, + /* the queue face by name, not by its rows: the track this walk is on is + the LAST queueable row of Late night drive (the missing and the + unresolved ones never reach the queue), so there is nothing up next + and `qrows` is 0 here — the assertion is that the queue's own face has + the cover's column, not that something is after this song */ + "landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0, + "landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn, + "landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0, + "landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn, + "landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26, + "portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24, diff --git a/handoff/rounds/ins-r27-probe.txt b/handoff/rounds/ins-r27-probe.txt new file mode 100644 index 0000000..f6cf77a --- /dev/null +++ b/handoff/rounds/ins-r27-probe.txt @@ -0,0 +1,21 @@ + vp: innerWidth + 'x' + innerHeight, + /* the sheet's controls: any one of them off the viewport is the landscape bug */ + npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length, + /* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */ + npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(), + faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height), + /* WHICH face: the queue's own, up whether or not anything is after this one */ + qFace: !!document.querySelector('.sheet.up .q-face'), + /* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */ + halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height), + halfIn: inside(box('.halfsheet.up')), + hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(), + hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(), + heroH: Math.round((box('.screen .hero') || { height: 0 }).height), + /* what a page offers first: Play on an artist hero, Play on a record head */ + actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')), + recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(), + /* the glass bar above plus the floating bars below, as a share of the screen */ + chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(), + miniLeft: Math.round((box('.mini') || { left: -1 }).left), + screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(), diff --git a/handoff/rounds/ins-r27-steps.txt b/handoff/rounds/ins-r27-steps.txt new file mode 100644 index 0000000..57633b5 --- /dev/null +++ b/handoff/rounds/ins-r27-steps.txt @@ -0,0 +1,32 @@ + /* ── the phone turned over. A resize, so the app keeps playing and keeps + its stack; what changes is the layout, and every one of these asserts + something that was clipped or unreachable at 915x411 before. ────── */ + { view: [915, 411], name: "landscape", wait: 900 }, + { do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 }, + { do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 }, + { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 }, + { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 }, + { do: `__ss.closeSheet()`, wait: 800 }, + /* a record sideways: the cover beside the title, Play in view without a + scroll, and a long sheet over it — the track's, 473px of rows and + facts — capped taller than portrait's 76% and still scrolling to its + last row. (Home's own overflow sheet is 212px tall at either cap and + would have proved nothing.) */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 }, + { do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 }, + { do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 700 }, + /* and the artist: the hero a banner rather than 300px of a 411px screen, + so it stops covering its own Play button with the mini bar */ + { do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 }, + { do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 }, + /* the side insets, faked: sideways the cutout and the gesture pill come + in from the edges, and env() cannot be emulated here — the plumbing can */ + { do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 }, + { do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 }, + /* and back, which is also the override reset: portrait must be exactly + what it was before any of this */ + { view: [411, 914], wait: 800 }, + { do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 }, + { do: `__ss.closeSheet()`, wait: 600 }, diff --git a/handoff/rounds/ins-r28-doc.txt b/handoff/rounds/ins-r28-doc.txt new file mode 100644 index 0000000..85b6169 --- /dev/null +++ b/handoff/rounds/ins-r28-doc.txt @@ -0,0 +1,68 @@ +## What a screen reader says + +Every control on the phone is named by what it does, in the app's own +words, and where the desktop already has a word for something it is the +word used here too — Seek, View album artwork, Close artwork viewer. An +icon-only control carries an `aria-label`; one with visible text is named +by the text it shows. + +The nav is a tab set: `role="tablist"` on the bar, `role="tab"` and +`aria-selected` on the five destinations, and the count badge round +seventeen added is part of the tab's own name — "You, 3 transfers", +"Community, 2 unread" — rather than a digit read out before the word it +belongs to. The badge itself is `aria-hidden`, because the number is in +the name now. + +The two sheets are dialogs: `role="dialog" aria-modal="true"`, the player +sheet named for what is playing, the half sheet reading its own first +title back out of the DOM so that twenty-seven kinds of sheet need no list +of names to keep in step. Opening one puts the focus somewhere sensible — +the player sheet on its Close, the half sheet on its grab handle, which is +a real button now ("Close this sheet") and the first dismissal a screen +reader could ever reach; the scrim behind it never was one. + +A sheet that is down is `inert` and `aria-hidden`. Neither sheet is +unmounted — they are translated off the bottom of the screen, and the half +sheet deliberately keeps its last body rendered through the slide down — +so before this the closed sheets' rows, the hidden mini bar and the nav +under an open sheet were all still in the accessibility tree, and TalkBack +could swipe into every one of them. + +There is one live region, `.sr-live`, mounted for the life of the app and +clipped to a single pixel, because a region that appears with its words +already inside it is announced reliably by nobody; an announcement here is +a change of text in a node that was always there. That is what carries an +error and a notice, so an Undo offer is actually read out — and the Undo +button itself stays reachable, only the sentence beside it is hidden from +the tree so it is not said twice. + +Artwork that opens the viewer is a control, not decoration. Artwork that +is decoration carries `alt=""` and every icon is `aria-hidden`, so the +tree never reads a filename out. The waveform is `role="slider"` with a +value and arrow keys where it can seek, and `aria-hidden` on the mini bar +where it is only a picture of the position. Switches say on or off, choice +chips say pressed, and the two long descriptions and the gap line say +expanded. + +The check, so this does not quietly rot: the walk's probe sweeps every +interactive element in the live DOM and counts four things — the ones with +no accessible name (`a11yAnon`, with `a11yWho` naming the first three +offenders so one run tells you what to add), the ones whose name is junk +(`a11yBad`: bare digits, a filename, a lone glyph, an essay), the ones +sitting in a layer that is off-screen but still in the tree (`a11yGhost`) +and the ones that look chosen without saying so (`a11yState`). All four +are asserted at zero on Home, the library, a record, a catalog record, the +artist page, You, the taste page, a half sheet, the player sheet and the +viewer — and the anonymous count on a room and on both shapes of prompt +besides, because a room's message box and the prompt's own field are where +the fields this round finally named actually live: assert it only where +they are not and the round guards everything except its own work. + +One known exception, left alone on purpose: the per-row ⋯ is a +`role="button"` inside the row's own `{/if} + + {/if} diff --git a/handoff/rounds/ins-r29-crate-buildrs.txt b/handoff/rounds/ins-r29-crate-buildrs.txt new file mode 100644 index 0000000..78d1a30 --- /dev/null +++ b/handoff/rounds/ins-r29-crate-buildrs.txt @@ -0,0 +1,16 @@ +// `update` and `clear` are ours. registerListener / removeListener are the +// mobile plugin runtime's own commands, the ones `addPluginListener` +// invokes to hear the `action` event — commands like any other to the ACL, +// and without them here the listener is refused and the notice's buttons go +// nowhere. The media-session plugin carries the same scar. +const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"]; + +fn main() { + // Registers the command permissions (permissions/autogenerated) and, on + // Android, hands the Gradle module under android/ to tauri-build, which + // includes it in the app project (tauri.settings.gradle) — but only with + // TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md. + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .build(); +} diff --git a/handoff/rounds/ins-r29-crate-cargo.txt b/handoff/rounds/ins-r29-crate-cargo.txt new file mode 100644 index 0000000..3edf236 --- /dev/null +++ b/handoff/rounds/ins-r29-crate-cargo.txt @@ -0,0 +1,16 @@ +[package] +name = "tauri-plugin-transfer-notice" +version = "0.1.0" +description = "SoulStream's downloads in the phone's shade: a progress notification on its own low-importance channel, carried by its own dataSync foreground service, so a Save outlives the app going to the background" +edition = "2021" +rust-version = "1.77.2" +# required by tauri-plugin's build script; must equal the crate name +links = "tauri-plugin-transfer-notice" + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/handoff/rounds/ins-r29-crate-lib.txt b/handoff/rounds/ins-r29-crate-lib.txt new file mode 100644 index 0000000..8507dc3 --- /dev/null +++ b/handoff/rounds/ins-r29-crate-lib.txt @@ -0,0 +1,110 @@ +//! The downloads notice on the phone. +//! +//! The Transfers screen only exists inside the app: the desktop says what a +//! Save is doing with its title-bar ring, the phone with a badge on the nav, +//! and neither is visible once the app is in the background — which is +//! exactly when a Save is left to run on its own. So a running Save says so +//! where the phone says things, in the shade: how many files are left, the +//! one it is on, a progress bar, Stop all and Open. +//! +//! Its own channel, low importance, so it can be silenced without silencing +//! the player's card. Its own foreground service (`TransferService`, +//! `dataSync`) rather than the media one, because a download is not +//! playback: `MediaService` stops itself the moment the bar empties, and a +//! `mediaPlayback` service with no session is a policy violation. That +//! service is also what keeps the process alive for a Save with nothing +//! playing — the way the first real Save died, four tracks in. +//! +//! Two commands, both fed by the phone UI (src/mobile/xfernote.svelte.js): +//! `update` with what is still in flight, `clear` when the queue empties. +//! The notice's buttons arrive as the plugin event `action` +//! (`addPluginListener("transfer-notice", "action", …)`): `cancel` stops +//! everything still going, `open` brings the app forward on Transfers. +//! +//! On the desktop the plugin is registered too, so the permission set +//! resolves on every platform, but its commands answer "not here". + +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +/// What the notice says: how many files are still in flight, the one it is +/// on, and how far the burst has come. `indeterminate` is a queue whose +/// sizes no peer has stated yet — a bar with no end to walk towards. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TransferNotice { + #[serde(default)] + pub count: u32, + pub title: Option, + pub line: Option, + #[serde(default)] + pub percent: i32, + #[serde(default)] + pub indeterminate: bool, +} + +#[cfg(mobile)] +pub struct TransferNoticePlugin(tauri::plugin::PluginHandle); + +#[tauri::command] +fn update(app: tauri::AppHandle, state: TransferNotice) -> Result<(), String> { + #[cfg(mobile)] + { + use tauri::Manager; + let notice = app.state::>(); + notice + .0 + .run_mobile_plugin::("update", state) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = (app, state); + Err("the downloads notice is a phone feature".into()) + } +} + +#[tauri::command] +fn clear(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(mobile)] + { + use tauri::Manager; + let notice = app.state::>(); + notice + .0 + .run_mobile_plugin::("clear", ()) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = app; + Err("the downloads notice is a phone feature".into()) + } +} + +pub fn init() -> TauriPlugin { + Builder::new("transfer-notice") + .invoke_handler(tauri::generate_handler![update, clear]) + .setup(|app, api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = api.register_android_plugin( + "com.soulstream.transfernotice", + "TransferNoticePlugin", + )?; + app.manage(TransferNoticePlugin(handle)); + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, api); + } + Ok(()) + }) + .build() +} diff --git a/handoff/rounds/ins-r29-doc.txt b/handoff/rounds/ins-r29-doc.txt new file mode 100644 index 0000000..6d2dbcc --- /dev/null +++ b/handoff/rounds/ins-r29-doc.txt @@ -0,0 +1,61 @@ +## The downloads in the shade + +Transfers is a screen inside the app; the desktop says what a Save is doing +with the ring in its title bar, the phone with a badge on the nav. Neither +is anywhere to be seen once the app is in the background, which is exactly +when a Save is left to run on its own. So a running Save now shows in the +shade: how many files are still in flight, the one it is on, a progress +bar, and two buttons — **Stop all** and **Open**. + +Its own channel ("Downloads", `IMPORTANCE_LOW`, no badge, private on a +locked screen, since a filename is nobody else's business), separate from +Playback so one can be silenced without the other, and created when the app +starts rather than when the first download does — a user who wants the +downloads quiet should not have to wait for one to be noisy first. + +Its own service too, and that is the part worth spelling out. +`foregroundServiceType` is per-service: a download is `dataSync`, playback +is `mediaPlayback`. `MediaService` stops itself the moment the bar empties, +so folding the notice into it would take the card down mid-Save; keeping it +alive for a download instead would leave a `mediaPlayback` service running +with no session, which Android 14+ treats as a violation. And nothing else +keeps the process alive for a Save with nothing playing — which is exactly +how the first real Save died on 2026-09-13, four tracks in. A `dataSync` +foreground service that exists while downloads are in flight is the +structural fix, and it can only be its own service. The same promote-first +rule applies: `onStartCommand` calls `startForeground()` before it looks at +anything, because Android kills the app about ten seconds after a +foreground start that never did. Android 15 caps a `dataSync` service at +six cumulative hours a day and kills the app if it is still up when the +callback returns, so `onTimeout` takes the notice down rather than being +taken down with it; the fetches are the Rust core's own and Transfers still +lists them. + +The buttons come back as the plugin event `action` +(`addPluginListener("transfer-notice", "action", …)`). **Stop all** runs +`cancelAll()` in `transfers.svelte.js`, which is the same `cancel(id)` a +row's tap calls, once per live row — not a second path to the same thing. +**Open** brings the activity forward and puts the You tab on Transfers, and +the notice's body does the same. Transfers grew the matching button: Cancel +all while anything is in flight, beside Clear finished. + +`src/mobile/xfernote.svelte.js` is the mirror, and it is driven by a Svelte +effect over the transfer store rather than off the `soulseek:download` +event. That is deliberate twice over: the effect tracks progress as well as +arrivals, and it means a row written straight into the store drives the +notice exactly as a fetched one does — which is how the walk exercises this +without fetching anything. Posts are throttled to one per 700 ms of changed +content; download progress arrives far faster than anyone reads it, and the +system rate-limits (and drops) notifications posted in a flood. + +`crates/tauri-plugin-transfer-notice` is shaped like the media-session +plugin: `build.rs` hands its `android/` Gradle module to tauri-build, +`src/lib.rs` registers the Kotlin class and exposes `update` / `clear`, and +the Kotlin side is two files — the plugin (arguments in, the `action` event +out) and `TransferService`. The app's capability grants +`transfer-notice:default`; the plugin is registered on every platform so +the permission resolves everywhere, and on the desktop its commands answer +"not here". The tauri-build trap above applies to this plugin as well: the +Android module only reaches the app project when +`TAURI_ANDROID_PROJECT_PATH` is set, which is why adding it also touched +`src-tauri/build.rs`. diff --git a/handoff/rounds/ins-r29-drawable.txt b/handoff/rounds/ins-r29-drawable.txt new file mode 100644 index 0000000..6f5681f --- /dev/null +++ b/handoff/rounds/ins-r29-drawable.txt @@ -0,0 +1,16 @@ + + + + + + diff --git a/handoff/rounds/ins-r29-gradle.txt b/handoff/rounds/ins-r29-gradle.txt new file mode 100644 index 0000000..22dcc28 --- /dev/null +++ b/handoff/rounds/ins-r29-gradle.txt @@ -0,0 +1,28 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.soulstream.transfernotice" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + // NotificationCompat, ServiceCompat, ContextCompat — no media here, + // there is no session in this one + implementation("androidx.core:core-ktx:1.13.1") + implementation(project(":tauri-android")) +} diff --git a/handoff/rounds/ins-r29-manifest.txt b/handoff/rounds/ins-r29-manifest.txt new file mode 100644 index 0000000..401f685 --- /dev/null +++ b/handoff/rounds/ins-r29-manifest.txt @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/handoff/rounds/ins-r29-mock-expect.txt b/handoff/rounds/ins-r29-mock-expect.txt new file mode 100644 index 0000000..878861c --- /dev/null +++ b/handoff/rounds/ins-r29-mock-expect.txt @@ -0,0 +1,7 @@ + /* xfnMax >= 5 can only have come from the real album burst, and it is + monotonic, so it is not a race; the title and the line come from the + two injected rows (990001 is the newest transferring one, so it is + deterministically the current file); xfnBad === 0 proves the call + reached a handler instead of being swallowed */ + "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13, + "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn, diff --git a/handoff/rounds/ins-r29-mock-steps.txt b/handoff/rounds/ins-r29-mock-steps.txt new file mode 100644 index 0000000..7388400 --- /dev/null +++ b/handoff/rounds/ins-r29-mock-steps.txt @@ -0,0 +1,9 @@ + /* the shade's copy of this screen, driven from the store itself: two + rows written straight in — nothing fetched — so the notice is posted + with a count and the file it is on, and Cancel all is on the head; + then the rows are taken away again and the notice must go with them */ + { do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 1300 }, + /* 2600 ms on purpose: the album burst drains 3.4-5.3 s after it starts + and this lands ten seconds after the Save, so "nothing in flight" is + settled state rather than a race against a mock still finishing */ + { do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2600 }, diff --git a/handoff/rounds/ins-r29-perm-default.txt b/handoff/rounds/ins-r29-perm-default.txt new file mode 100644 index 0000000..e7613b7 --- /dev/null +++ b/handoff/rounds/ins-r29-perm-default.txt @@ -0,0 +1,9 @@ +"$schema" = "schemas/schema.json" + +[default] +description = """ +The downloads notice on the phone: `update` mirrors what Save is fetching, +`clear` takes it down, and the two listener commands are what lets the page +hear the notice's buttons (`addPluginListener`). +""" +permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"] diff --git a/handoff/rounds/ins-r29-phone-expect.txt b/handoff/rounds/ins-r29-phone-expect.txt new file mode 100644 index 0000000..b07cd0d --- /dev/null +++ b/handoff/rounds/ins-r29-phone-expect.txt @@ -0,0 +1,6 @@ + "shade-open": (i) => i.title.startsWith("Transfers") && !i.toast, + "shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText), + /* the dump has to have been read for the negative to mean anything: one + that came back empty would pass "no downloads channel" on nothing at + all. A format change fails the step above, loudly, first */ + "shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans), diff --git a/handoff/rounds/ins-r29-phone-steps.txt b/handoff/rounds/ins-r29-phone-steps.txt new file mode 100644 index 0000000..cad3263 --- /dev/null +++ b/handoff/rounds/ins-r29-phone-steps.txt @@ -0,0 +1,9 @@ + /* the shade, for real. The walk starts no download — two rows go + straight into the transfer store, which is the notice's only input — + and the phone's own notification is read back out of dumpsys, then + the rows are taken away and it has to be gone. Nothing is fetched + into Kyle's library, and the ids are far above the backend's counter */ + { do: `__click('.screen .row', 'Transfers')`, name: "shade-open", wait: 1200 }, + { do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 2400, probe: shadeNotice }, + { do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2400, probe: shadeNotice }, + { do: `history.back()`, wait: 700 }, diff --git a/handoff/rounds/ins-r29-plugin-kt.txt b/handoff/rounds/ins-r29-plugin-kt.txt new file mode 100644 index 0000000..c03919c --- /dev/null +++ b/handoff/rounds/ins-r29-plugin-kt.txt @@ -0,0 +1,76 @@ +package com.soulstream.transfernotice + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin + +/** What the notice says — the Rust `TransferNotice`, camelCase. */ +@InvokeArg +class NoticeArgs { + /** how many files are still in flight; zero means take the notice down */ + var count: Int = 0 + var title: String? = null + var line: String? = null + var percent: Int = 0 + /** nobody has stated a size yet, so the bar has no end to walk towards */ + var indeterminate: Boolean = false +} + +/** + * The bridge between the WebView and the service that owns the notice. + * `update` mirrors what Save is fetching (and starts the service the first + * time); `clear` takes the notice down. Its buttons come back as the plugin + * event `action` with `{ action }` — "cancel" or "open". + */ +@TauriPlugin +class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) { + private var askedForNotifications = false + + init { + TransferHub.onAction = { action -> + val payload = JSObject() + payload.put("action", action) + trigger("action", payload) + } + /* the channel exists from app start, so a user can silence the + downloads on their own before the first one ever runs */ + TransferHub.ensureChannel(activity.applicationContext) + } + + @Command + fun update(invoke: Invoke) { + val args = invoke.parseArgs(NoticeArgs::class.java) + ensureNotificationPermission() + TransferHub.update(activity.applicationContext, args) + invoke.resolve() + } + + @Command + fun clear(invoke: Invoke) { + TransferHub.clear(activity.applicationContext) + invoke.resolve() + } + + /** Android 13+: the notice is a notification and needs the permission; + * asked once, the first time something is fetched. Its own request code + * — 4711 is the media card's, and two requests that share one would + * answer each other. The service runs either way; without the + * permission it simply has nothing to show. */ + private fun ensureNotificationPermission() { + if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return + askedForNotifications = true + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) return + ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712) + } +} diff --git a/handoff/rounds/ins-r29-probe.txt b/handoff/rounds/ins-r29-probe.txt new file mode 100644 index 0000000..0e76aec --- /dev/null +++ b/handoff/rounds/ins-r29-probe.txt @@ -0,0 +1,9 @@ + /* the shade's notice, as the app's own record of having posted it — + a notification is not a thing the page can be asked about */ + xfnOn: (function () { try { return !!__ss.xfn.posted; } catch (e) { return false; } })(), + xfnClr: (function () { try { return __ss.xfn.clears; } catch (e) { return -1; } })(), + xfnMax: (function () { try { return __ss.xfn.max; } catch (e) { return -1; } })(), + xfnBad: (function () { try { return __ss.xfn.failed; } catch (e) { return -1; } })(), + xfnTitle: (function () { try { return __ss.xfn.title; } catch (e) { return ''; } })(), + xfnLine: (function () { try { return __ss.xfn.line; } catch (e) { return ''; } })(), + xfnBtn: !![].slice.call(document.querySelectorAll('.phead .all')).find(function (x) { return /Cancel all/.test(x.textContent); }), diff --git a/handoff/rounds/ins-r29-service-kt.txt b/handoff/rounds/ins-r29-service-kt.txt new file mode 100644 index 0000000..db29cd2 --- /dev/null +++ b/handoff/rounds/ins-r29-service-kt.txt @@ -0,0 +1,196 @@ +package com.soulstream.transfernotice + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat + +/** + * What is being fetched, shared between the plugin (which hears the + * WebView) and the service (which owns the notice). The service is started + * on the first update and refreshed on every one after; a count of zero is + * a clear, since the phone's own rule is that an empty queue has no notice. + */ +object TransferHub { + const val CHANNEL = "downloads" + + /** 1 is the media card's, and ids are per-package */ + const val NOTIFICATION_ID = 2 + + const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL" + const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN" + + @Volatile var onAction: ((String) -> Unit)? = null + @Volatile var state: NoticeArgs? = null + @Volatile var service: TransferService? = null + + /** Its own channel, low importance and no badge: a download is not an + * alarm, and silencing it must not silence the player's card. A + * filename is not for a locked screen, so the notice is private there. */ + fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT < 26) return + val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL) != null) return + val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW) + channel.description = "What Save is fetching into your library" + channel.setShowBadge(false) + channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + manager.createNotificationChannel(channel) + } + + fun update(ctx: Context, np: NoticeArgs) { + if (np.count <= 0) { + clear(ctx) + return + } + state = np + val running = service + if (running == null) { + /* Android 12+ refuses a foreground start from the background. A + Save is always begun from the foreground, so the first start + lands; one that cannot simply leaves the fetch without a card */ + try { + ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java)) + } catch (e: Exception) { + android.util.Log.w("SoulStream", "transfer notice could not start: $e") + } + } else { + running.refresh() + } + } + + fun clear(ctx: Context) { + state = null + val running = service + /* refresh() on a null state is the one path that takes the notice + down and stops the service; stopService is for a service that was + never here */ + if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java)) + } + + fun act(action: String) { + onAction?.invoke(action) + } +} + +/** + * The foreground service behind the notice: a progress notification with + * Stop all and Open on it, and — the point — the reason the process is not + * frozen while a Save runs with nothing playing. The fetches themselves + * stay where they are, on the Rust core's own connections; this only says + * what they are doing. + */ +class TransferService : Service() { + private var inForeground = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + TransferHub.ensureChannel(this) + TransferHub.service = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + /* Started with startForegroundService, so Android demands + startForeground() promptly WHATEVER the state — a queue that + emptied while the start was in flight included — or it kills the + whole app about ten seconds later. MediaService learned this the + same way, on 2026-09-13, and it was a Save that died. Promote + first, with the notice there is or a plain one; refresh() steps + back at once if there is nothing left to say. */ + if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs())) + when (intent?.action) { + TransferHub.ACTION_CANCEL -> TransferHub.act("cancel") + TransferHub.ACTION_OPEN -> { + packageManager.getLaunchIntentForPackage(packageName)?.let { + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ } + } + TransferHub.act("open") + } + } + refresh() + return START_NOT_STICKY + } + + /** Mirror TransferHub.state into the notice — or take it down. */ + fun refresh() { + val np = TransferHub.state + if (np == null) { + /* the queue emptied: the notice goes, whole */ + if (inForeground) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + inForeground = false + } + stopSelf() + return + } + /* promote() on an already-promoted service is just a notify */ + promote(build(np)) + } + + private fun promote(notification: Notification) { + if (Build.VERSION.SDK_INT >= 29) { + ServiceCompat.startForeground( + this, TransferHub.NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(TransferHub.NOTIFICATION_ID, notification) + } + inForeground = true + } + + /** Android 15 caps a dataSync service at six hours a day and kills the + * app if it is still up when this returns. Take the notice down: the + * fetches are the Rust core's own and Transfers still lists them. */ + override fun onTimeout(startId: Int, fgsType: Int) { + TransferHub.clear(applicationContext) + } + + override fun onDestroy() { + TransferHub.service = null + super.onDestroy() + } + + private fun build(np: NoticeArgs): Notification { + val open = serviceIntent(11, TransferHub.ACTION_OPEN) + val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL) + return NotificationCompat.Builder(this, TransferHub.CHANNEL) + .setSmallIcon(R.drawable.ic_soulstream_down) + .setContentTitle(np.title ?: "Saving") + .setContentText(np.line ?: "") + .setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate) + .setContentIntent(open) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setOngoing(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel) + .addAction(android.R.drawable.ic_menu_upload, "Open", open) + .build() + } + + /** The notice only exists while this service is in the foreground, so + * its own buttons are never a background service start. The request + * codes are 11 and 12 on purpose — the media plugin uses 0 in the same + * package, and two PendingIntents that filterEqual overwrite each other. */ + private fun serviceIntent(code: Int, action: String): PendingIntent { + val i = Intent(this, TransferService::class.java).setAction(action) + return PendingIntent.getForegroundService( + this, code, i, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/handoff/rounds/ins-r29-shadenotice.txt b/handoff/rounds/ins-r29-shadenotice.txt new file mode 100644 index 0000000..1852475 --- /dev/null +++ b/handoff/rounds/ins-r29-shadenotice.txt @@ -0,0 +1,38 @@ +/* What OUR package has in the shade right now: the channels its live + * notifications sit on, and the downloads one's own title and text. + * `--noredact` is what makes the extras print their values rather than a + * placeholder. Nobody else's notifications are read — `seen` counts the + * dump's records only so that "no downloads channel" cannot pass by the + * dump having come back empty. */ +function shadeNotice() { + try { + const dump = adb("shell", "dumpsys", "notification", "--noredact").toString(); + const blocks = dump.split("NotificationRecord(").slice(1); + const chans = []; + let title = "", text = ""; + for (const block of blocks) { + const head = block.slice(0, 6000); + if (!/pkg=com\.soulstream\.app\b/.test(head)) continue; + /* Notification.toString() carries `channel=` on the record's + own first line; older dumps spell it as the channel object */ + let ch = (head.match(/channel=([\w.\-]+)/) || [])[1] || ""; + if (!ch || ch === "NotificationChannel") ch = (head.match(/mId='([^']*)'/) || [])[1] || ch; + if (ch) chans.push(ch); + if (ch !== "downloads") continue; + /* the extras print as `= ()`, and the class is + whatever CharSequence the builder kept — String on this one, + but a Spannable on a device that styles it, so the class is + matched loosely and stray quotes are trimmed */ + const extra = (key) => { + const m = head.match(new RegExp(key.replace(/\./g, "\\.") + "=[A-Za-z]+ \\(([^\\n]*?)\\)\\s*\\n")); + return m ? m[1].replace(/^"|"$/g, "") : ""; + }; + title = extra("android.title"); + text = extra("android.text"); + } + return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text }; + } catch (e) { + return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" }; + } +} + diff --git a/handoff/rounds/ins-r29-xfernote.txt b/handoff/rounds/ins-r29-xfernote.txt new file mode 100644 index 0000000..408bb24 --- /dev/null +++ b/handoff/rounds/ins-r29-xfernote.txt @@ -0,0 +1,115 @@ +/* The downloads in the phone's shade. + + The Transfers screen is inside the app: the desktop says what a Save is + doing with its title-bar ring, the phone with a badge on the nav, and + neither is anywhere to be seen once the app is in the background — which + is exactly when a Save is left to run on its own. So a running Save says + so where the phone says things: one notice with how many files are left, + the one it is on, a bar, Stop all and Open. + + Its own channel, so it can be silenced without silencing the player's + card, and its own foreground service, so the fetch outlives the playback + that happened to be keeping the process alive. Everything real is in + crates/tauri-plugin-transfer-notice; this is the wiring — the mirror of + transfers.svelte.js, exactly as media.svelte.js mirrors the player. */ +import * as api from "../lib/api.js"; +import { addPluginListener } from "@tauri-apps/api/core"; +import { active, cancelAll } from "./transfers.svelte.js"; +import { note } from "./notice.svelte.js"; +import { go, push, top, closeSheet } from "./nav.svelte.js"; + +/* What the phone was last told, and how often. A PLAIN object, not $state: + nothing renders it, and the effect that drives the post must never read + what it writes — a self-read in an $effect kills the subtree. The walk + reads it through __ss.xfn, since a notification is not a thing a headless + page can be asked about. */ +export const xfernote = { + posted: false, + updates: 0, + clears: 0, + max: 0, + title: "", + line: "", + percent: -1, + failed: 0, +}; + +/* a re-post is not free, and progress ticks arrive far faster than anyone + reads them: the lever is the rate */ +const MIN_GAP = 700; +let timer = null; +let lastAt = 0; +let lastKey = ""; + +/** What is in flight, as the notice would say it — or null when nothing is. */ +function shape() { + const live = active(); + if (!live.length) return null; + const newest = [...live].sort((a, b) => b.id - a.id); /* the screen's own order */ + const cur = newest.find((d) => d.state === "transferring") || newest[0]; + let received = 0, total = 0, known = 0; + for (const d of live) if (d.total) { received += d.received || 0; total += d.total; known++; } + return { + count: live.length, + title: live.length === 1 ? "Saving 1 file" : `Saving ${live.length} files`, + line: cur.state === "transferring" ? (cur.name || cur.path || "") : "waiting in their queue", + percent: total ? Math.round((received / total) * 100) : 0, + indeterminate: !known, + }; +} +const keyOf = (s) => (s ? `${s.count}|${s.title}|${s.line}|${s.percent}` : ""); + +/** Called from Mobile.svelte's effect: reads the store — so the effect + * tracks it — and schedules at most one post per MIN_GAP. */ +export function syncShade() { + const key = keyOf(shape()); + if (key === lastKey || timer) return; + timer = setTimeout(post, Math.max(0, MIN_GAP - (Date.now() - lastAt))); +} + +/** The one place that talks to the plugin. Reads the store again on the way + * out, so what lands in the shade is the latest, not the change that + * scheduled it. */ +async function post() { + timer = null; + lastAt = Date.now(); + const s = shape(); + lastKey = keyOf(s); + if (!s) { + if (!xfernote.posted) return; + xfernote.posted = false; + xfernote.clears++; + try { await api.clearTransferNotice(); } catch { xfernote.failed++; } + return; + } + xfernote.posted = true; + xfernote.updates++; + if (s.count > xfernote.max) xfernote.max = s.count; + xfernote.title = s.title; + xfernote.line = s.line; + xfernote.percent = s.percent; + try { await api.transferNotice(s); } catch { xfernote.failed++; } +} + +let bound = false; +/** The notice's own two buttons. Harmless where there is no plugin. */ +export async function bindXferNote() { + if (bound) return; + bound = true; + try { + await addPluginListener("transfer-notice", "action", (p) => { + if (p?.action === "cancel") { + /* the same cancel(id) a row's tap takes, once per live row — + never a second path to the same thing */ + void cancelAll(); + note("Downloads stopped."); + } else if (p?.action === "open") { + closeSheet(); + go("you"); + if (top().n !== "transfers") push("transfers", {}); + } + }); + } catch { + /* not a phone */ + } +} diff --git a/handoff/rounds/ins-r30-catalog-lanes.txt b/handoff/rounds/ins-r30-catalog-lanes.txt new file mode 100644 index 0000000..51da6e7 --- /dev/null +++ b/handoff/rounds/ins-r30-catalog-lanes.txt @@ -0,0 +1,26 @@ +/** The banner on its own (`catalog_artist_hero_seed`), ahead of the page. + * Three sources in the Rust, cheapest first: the hero Kyle picked on this + * phone (a file, no network at all), the art manifest, then the provider. + * The full page still owns the final artwork — this only paints first. */ +export function loadHeroSeed(id) { + if (id == null || catalog.heroSeeds[id]) return; + catalog.heroSeeds[id] = { status: "loading", banner: null, custom: false }; + api.catalogArtistHeroSeed(id).then( + (s) => { catalog.heroSeeds[id] = s?.banner ? { status: "ready", banner: s.banner, custom: !!s.banner_custom } : { status: "none", banner: null, custom: false }; }, + () => { catalog.heroSeeds[id] = { status: "error", banner: null, custom: false }; }, + ); +} +/** The artist and the first page of releases in one round + * (`catalog_artist_overview`) — the desktop's fast content stage, for the + * desktop's own reason: the identity chain inside the full page rides + * MusicBrainz's gate and is measured there at several seconds. Its + * `popular_tracks` are deliberately dropped: Popular has a ranked lane of + * its own already, and the overview's list is budgeted and unranked. */ +export function loadOverview(id) { + if (id == null || catalog.overviews[id]) return; + catalog.overviews[id] = { status: "loading", artist: null, albums: [] }; + api.catalogArtistOverview(id).then( + (o) => { catalog.overviews[id] = { status: "ready", artist: o?.artist ?? null, albums: o?.albums ?? [] }; }, + () => { catalog.overviews[id] = { status: "error", artist: null, albums: [] }; }, + ); +} diff --git a/handoff/rounds/ins-r30-css.txt b/handoff/rounds/ins-r30-css.txt new file mode 100644 index 0000000..7e59602 --- /dev/null +++ b/handoff/rounds/ins-r30-css.txt @@ -0,0 +1,8 @@ +/* the measured vocal band under the head: where the singing is, the lines + that already have a time, and the playhead over both */ +.sy-strip { position: relative; display: flex; align-items: flex-end; gap: 1px; height: 40px; margin: 8px var(--pad) 0; padding: 0 2px; border-radius: 8px; background: rgba(255, 255, 255, 0.04); flex: none; overflow: hidden; } +.sy-col { flex: 1; min-width: 0; border-radius: 1px; background: color-mix(in srgb, var(--accent) 55%, transparent); } +.sy-tick { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: #fff; opacity: 0.75; } +.sy-play { position: absolute; top: -2px; bottom: -2px; width: 2px; margin-left: -1px; background: var(--accent-hover); box-shadow: 0 0 8px var(--accent-hover); } +.sy-est { align-self: center; margin: 8px 0 0; padding: 7px 16px; border-radius: var(--r-pill); font-size: 12.5px; font-weight: 750; color: var(--accent-hover); background: color-mix(in srgb, var(--accent) 14%, transparent); flex: none; } +.sy-est:disabled { opacity: 0.45; color: var(--text-muted); background: rgba(255, 255, 255, 0.05); } diff --git a/handoff/rounds/ins-r30-doc-artist.txt b/handoff/rounds/ins-r30-doc-artist.txt new file mode 100644 index 0000000..64c820d --- /dev/null +++ b/handoff/rounds/ins-r30-doc-artist.txt @@ -0,0 +1,14 @@ +A cold page no longer waits on one call for everything. Three lanes run off +the artist's id the moment it is known: Popular's ranked rows +(`catalog_artist_popular`), the banner alone (`catalog_artist_hero_seed`) and +the first page of releases (`catalog_artist_overview`) — the desktop's own +staging, for the desktop's own reason, since the identity pass inside the +full page is a MusicBrainz chain behind a one-request-a-second gate. The +overview's releases hold the discography up until the exhaustive page lands +with the classified one, and never the other way round; its `popular_tracks` +are dropped, because Popular already has a ranked lane of its own. The hero +seed pays for itself twice: its first source is the hero Kyle picked on this +phone, read off the file with no network at all, so **Choose hero photo** +shows on the next visit at once instead of behind a whole catalog page — and +both lanes are forgotten with the page when a hero is set or removed, or the +old banner would answer while the new one was still being fetched. diff --git a/handoff/rounds/ins-r30-doc-lyrics.txt b/handoff/rounds/ins-r30-doc-lyrics.txt new file mode 100644 index 0000000..e10e2e6 --- /dev/null +++ b/handoff/rounds/ins-r30-doc-lyrics.txt @@ -0,0 +1,16 @@ +The room also measures the recording. `lyrics_vocal_activity` is the +desktop's coarse vocal-band envelope — the centre channel band-passed to +180 Hz – 7.5 kHz, one value per 100 ms, read on a blocking thread — and it +draws as a strip under the head, so the singing ahead is visible while the +thumb waits for it. **Estimate the timing** lays every line across the +stretches the envelope says are sung, weighted by line length, through the +desktop's own generator (`src/lib/lyric-sync.js`, shared and unit-tested): +never an even spread over the duration, and a refusal rather than a guess +when the measurement shows nothing usable. The result is labelled an +estimate everywhere it shows — the hint under the line, the Save button — +the cursor goes back to the top so the taps correct it from there, and one +more tap on the button drops the whole thing back to an empty set (a rough +fill stamps every line at once, so Undo has nothing left to take back). +A streamed track has no finished file to measure, so the button says there +is no estimate for that one — the same rule the reduced-vocals copy follows, +and the same whole-file decode behind both. diff --git a/handoff/rounds/ins-r30-doc-notdone.txt b/handoff/rounds/ins-r30-doc-notdone.txt new file mode 100644 index 0000000..c727306 --- /dev/null +++ b/handoff/rounds/ins-r30-doc-notdone.txt @@ -0,0 +1,45 @@ +## Not done yet + +Thirty rounds in, this is the honest map: what is deliberately absent, what +is waiting on Rust, and what is simply smaller than the rest. Most of what +the backend exposes and the phone never calls is a whole module it has no +business calling, so the list is by module — that is the reason, not the +symptom. + +- **Off by design — the private layer.** Spaces, holdings, the sealed inbox, + friends by key, shared playlists, Who has it, the persona and its profile, + and the Soulseek↔persona link: sixty-six commands across ten modules + (`freenet` 7, `friends` 7, `holdings` 8, `inbox` 3, `persona` 6, `profile` + 4, `proposals` 3, `shared_playlists` 13, `slsk_link` 5, `spaces` 10). + Freenet on the phone is a later experiment; the network side is in, and + rooms and chat are built and mock-walked. A live room is Kyle's to join. +- **Off by design — the desktop's own machinery.** The tag-import pipeline + (all sixteen of `import.rs`: its audition, its cover report, its portable + root — which is the import session's root, not the library folder); hover + previews (all eight of `preview.rs`), and with them the three pause/fade + acknowledgements that live there because they exist only to hand silence + between the main player and the preview player; the mini window, the glass + mode, Show in folder and SMTC; the updater (the phone's own build of it + answers honestly that there is no channel here, and the version is in You, + from `app_status`); the hero GPU upscaler, the cover-wordmark OCR and its + debug tools. The phone shows a wordmark the page carries; it does not run + the pipeline that makes one — and it does not fetch a 3000px cover upgrade + over mobile data for a screen that cannot show it, which is why + `catalog_cover` and `invalidate_catalog_cover` stay here too. +- **Off by design — external runtimes.** yt-dlp and deno, the vocal + separator, the lyric alignment models, and the listener door that needs + them. +- **Waiting on Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog` + does open Android's create-document sheet from Rust, but it answers with a + `content://` URI and `playlist_export` writes with `std::fs::write`, which + cannot take one. It needs a ContentResolver write, or the bytes handed to + an outbound share intent — and `crates/tauri-plugin-share` is inbound-only + today. Playlist-FILE import wants a `pick_file` in the storage plugin, + beside the `pick_folder` and `pick_image` it already has. +- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top + stand in), catalog track previews, the full discography page, and the + fused-artist portrait verdict — that last one needs the identity merge the + phone does not run, so porting it means porting the merge first. +- **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. diff --git a/handoff/rounds/ins-r30-lyr-store.txt b/handoff/rounds/ins-r30-lyr-store.txt new file mode 100644 index 0000000..198f333 --- /dev/null +++ b/handoff/rounds/ins-r30-lyr-store.txt @@ -0,0 +1,57 @@ +/* ── the measured envelope, and the rough pass over it ─────────────────── + The desktop's editor offers the same two things (LyricsSyncEditor.svelte): + a strip showing where the recording has voice in it, and a fill that lays + the lines across the ACTIVE stretches only. src/lib/lyric-sync.js is the + desktop's own generator — pure, shared, unit-tested — so the phone imports + it rather than keeping a second copy of the arithmetic. It refuses instead + of guessing when the measurement shows nothing usable: an estimate is a + starting point for the tap, never finished timing, and the room says so + everywhere it shows. The envelope belongs to the track, not to the store — + it is measured on open and thrown away with the room. */ +async function loadActivity(id) { + /* a streamed track is a cache file the engine may still be filling, and + the reducer refuses one on the same decode path for the same reason */ + if (!id?.path || player.track?.stream) { tapSync.reading = "none"; return; } + tapSync.reading = "reading"; + try { + const a = await api.lyricsVocalActivity(id.path); + if (tapSync.identity?.key !== id.key) return; // the room moved on + tapSync.activity = a?.frames?.length ? a : null; + tapSync.reading = tapSync.activity ? "ready" : "none"; + } catch { + if (tapSync.identity?.key === id.key) tapSync.reading = "none"; + } +} +/** Lay every line across the stretches the envelope says are sung, then put + * the cursor back at the top: the taps CORRECT the estimate from there. */ +export function estimateTimings() { + const a = tapSync.activity; + if (!a || !tapSync.rows.length) return false; + /* the room's rows ARE the lines: openTapSync built them from the document + the sheet is showing. So the generator's two halves are called directly + rather than roughSync's own text parse, which drops a section label a + SYNCED document still carries and would hand back a shorter list that + no longer lines up with the rows the room is asking about. */ + const filled = distributeLines(tapSync.rows.map((r) => String(r.text ?? "").trim()), activeSegments(a.frames, a.frameMs)); + /* it refuses rather than guesses when there is too little measured voice + to lay the lines across — and that is the only reason it refuses, so + the room can say which one it is */ + if (!filled) { + tapSync.error = "There is not enough voice in this song to estimate from."; + return false; + } + for (let i = 0; i < filled.length; i++) tapSync.rows[i].time_ms = filled[i].time_ms; + tapSync.cursor = 0; + tapSync.estimated = true; + tapSync.error = ""; + return true; +} +/** Drop it: every line back to no time, ready for the tap. A rough fill + * stamps every row at once, so Undo has nothing left to take back — this + * is the way out of an estimate that is wrong. */ +export function clearEstimate() { + for (const r of tapSync.rows) r.time_ms = null; + tapSync.cursor = 0; + tapSync.estimated = false; + tapSync.error = ""; +} diff --git a/handoff/rounds/ins-r30-mock-activity.txt b/handoff/rounds/ins-r30-mock-activity.txt new file mode 100644 index 0000000..a56209b --- /dev/null +++ b/handoff/rounds/ins-r30-mock-activity.txt @@ -0,0 +1,9 @@ + /* 214 s at a frame per 100 ms: three sung stretches with quiet between + them, which is exactly what activeSegments is built to find — 150 s of + active time, far over its 4 s floor, so the rough pass lays all three + lines and the first one lands at 10 s */ + if (cmd === "lyrics_vocal_activity") { + const sung = [[10, 60], [80, 140], [160, 200]]; + const frames = Array.from({ length: 2140 }, (_, i) => (sung.some(([a, b]) => i >= a * 10 && i < b * 10) ? 0.9 : 0.04)); + return new Promise((r) => setTimeout(() => r({ frameMs: 100, frames }), 250)); + } diff --git a/handoff/rounds/ins-r30-mock-expect-lanes.txt b/handoff/rounds/ins-r30-mock-expect-lanes.txt new file mode 100644 index 0000000..a66718e --- /dev/null +++ b/handoff/rounds/ins-r30-mock-expect-lanes.txt @@ -0,0 +1,5 @@ + /* the exhaustive page has NOT landed; the banner and the first four + releases have — three albums on the Albums tab, a single on its own */ + "artist-lanes": (i) => i.artFast === "loading:ready+art:ready:4" && i.heroPix && i.disco === 3 && i.tabs === 2, + /* and the page wins when it does land: the classified six */ + "artist-lanes-page": (i) => /^ready:/.test(i.artFast) && i.heroPix && i.disco === 6 && i.tabs === 2, diff --git a/handoff/rounds/ins-r30-mock-expect.txt b/handoff/rounds/ins-r30-mock-expect.txt new file mode 100644 index 0000000..32a0abb --- /dev/null +++ b/handoff/rounds/ins-r30-mock-expect.txt @@ -0,0 +1,3 @@ + "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow && i.syStrip === 64 && /Estimate the timing/.test(i.syEst), + "sync-estimated": (i) => i.sync && i.syCount === "3 / 3" && i.syTicks === 3 && /Estimated/.test(i.syHint) && /Clear the estimate/.test(i.syEst), + "sync-cleared": (i) => i.sync && i.syCount === "0 / 3" && i.syTicks === 0 && /Estimate the timing/.test(i.syEst), diff --git a/handoff/rounds/ins-r30-mock-lanes.txt b/handoff/rounds/ins-r30-mock-lanes.txt new file mode 100644 index 0000000..3ddc42b --- /dev/null +++ b/handoff/rounds/ins-r30-mock-lanes.txt @@ -0,0 +1,20 @@ + /* the banner ahead of the page. A hero picked on this phone is answered + from the override with no network at all, exactly as the Rust does — + and the field really is snake_case there (no rename_all on it) */ + if (cmd === "catalog_artist_hero_seed") { + const id = Number(args?.artistId); + if (heroes[id]) return Promise.resolve({ banner: heroes[id], banner_custom: true }); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id); + if (!a) return Promise.resolve(null); + return new Promise((r) => setTimeout(() => r({ banner: svgArt(210, a.name[0]), banner_custom: false }), 200)); + } + /* the one-round content stage: the raw first page of releases, before the + exhaustive pass classifies them. It goes through checkedArtistPayload, + so `artist.id` must equal the argument or the wrapper throws */ + if (cmd === "catalog_artist_overview") { + const id = Number(args?.artistId); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id); + if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist")); + const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : []; + return new Promise((r) => setTimeout(() => r({ artist: a, popular_tracks: [], albums }), 300)); + } diff --git a/handoff/rounds/ins-r30-mock-slow.txt b/handoff/rounds/ins-r30-mock-slow.txt new file mode 100644 index 0000000..68ac3c7 --- /dev/null +++ b/handoff/rounds/ins-r30-mock-slow.txt @@ -0,0 +1,15 @@ +/* An artist whose exhaustive page the mock deliberately keeps for four + seconds. Everything on screen before then came from the two fast lanes — + which is the only way the walk can tell that they ran. The titles are + deliberately plain, so the classifier's title heuristics leave them alone + and the tabs are exactly what the counts below say. */ +const CAT_SLOW = { id: 415, name: "Halcyon Drift", picture: null, album_count: 7, fans: 48000 }; +const SLOW_ALBUMS = [ + { id: 4150, title: "Halcyon Drift", release_date: "2014-04-08", record_type: "album", track_count: 11 }, + { id: 4151, title: "Second Light", release_date: "2016-09-23", record_type: "album", track_count: 10 }, + { id: 4152, title: "Slow Ascent", release_date: "2019-02-15", record_type: "album", track_count: 12 }, + { id: 4153, title: "First Signal", release_date: "2013-06-01", record_type: "single", track_count: 2 }, + { id: 4154, title: "Fourth Wall", release_date: "2021-05-07", record_type: "album", track_count: 9 }, + { id: 4155, title: "Long Winter", release_date: "2023-01-20", record_type: "album", track_count: 10 }, + { id: 4156, title: "Open Water", release_date: "2024-08-30", record_type: "album", track_count: 11 }, +].map((a) => ({ ...a, artist: CAT_SLOW.name, cover: svgArt((a.id * 17) % 360, a.title[0]), edition_group: null, is_soundtrack: false })); diff --git a/handoff/rounds/ins-r30-mock-steps-lanes.txt b/handoff/rounds/ins-r30-mock-steps-lanes.txt new file mode 100644 index 0000000..57110cb --- /dev/null +++ b/handoff/rounds/ins-r30-mock-steps-lanes.txt @@ -0,0 +1,5 @@ + /* an artist whose exhaustive page the mock keeps for four seconds: what + is on screen a second and a half in can only be the two fast lanes */ + { do: `__ss.push('artist', { name: 'Halcyon Drift' })`, name: "artist-lanes", wait: 1200 }, + { name: "artist-lanes-page", wait: 4200 }, + { do: `history.back()`, wait: 700 }, diff --git a/handoff/rounds/ins-r30-mock-steps.txt b/handoff/rounds/ins-r30-mock-steps.txt new file mode 100644 index 0000000..6388843 --- /dev/null +++ b/handoff/rounds/ins-r30-mock-steps.txt @@ -0,0 +1,4 @@ + /* the envelope is measured on open; the estimate lays every line across + the sung stretches, and dropping it puts the room back to the tap */ + { do: `__click('.sy-est', null)`, name: "sync-estimated", wait: 700 }, + { do: `__click('.sy-est', null)`, name: "sync-cleared", wait: 600 }, diff --git a/handoff/rounds/ins-r30-phone-step.txt b/handoff/rounds/ins-r30-phone-step.txt new file mode 100644 index 0000000..62599cf --- /dev/null +++ b/handoff/rounds/ins-r30-phone-step.txt @@ -0,0 +1,3 @@ + /* the real DSP on a real file: the envelope is a whole-file decode, so + give it room. Estimating writes nothing — the room is discarded next */ + { do: `(function () { var b = document.querySelector('.sy-est'); if (b && !b.disabled) b.click(); })()`, name: "sync-estimated", wait: 2500 }, diff --git a/handoff/rounds/ins-r30-probe.txt b/handoff/rounds/ins-r30-probe.txt new file mode 100644 index 0000000..96cd161 --- /dev/null +++ b/handoff/rounds/ins-r30-probe.txt @@ -0,0 +1,18 @@ + syStrip: document.querySelectorAll('.sy-strip .sy-col').length, + syTicks: document.querySelectorAll('.sy-strip .sy-tick').length, + syEst: (document.querySelector('.sy-est') || {}).textContent || '', + syHint: (document.querySelector('.sy-hint') || {}).textContent || '', + disco: document.querySelectorAll('.grid2 .release').length, + /* the artist page's three lanes at once — the page, the hero seed and + the overview — so a step can say what painted and what had not + landed yet: ":[+art]::" */ + artFast: (function () { + try { + var t = __ss.top(); + if (!t || t.n !== 'artist') return ''; + var a = __ss.catalog.artists[String(t.c.name || '').trim().toLowerCase()]; + if (!a) return 'no-entry'; + var h = __ss.catalog.heroSeeds[a.id] || {}, o = __ss.catalog.overviews[a.id] || {}; + return a.status + ':' + (h.status || '-') + (h.banner ? '+art' : '') + ':' + (o.status || '-') + ':' + ((o.albums || []).length); + } catch (e) { return ''; } + })(), diff --git a/handoff/rounds/ins-r30-syncroom-markup.txt b/handoff/rounds/ins-r30-syncroom-markup.txt new file mode 100644 index 0000000..3f75852 --- /dev/null +++ b/handoff/rounds/ins-r30-syncroom-markup.txt @@ -0,0 +1,11 @@ + {#if cols.length} +
+ {#each cols as v, i (i)}{/each} + {#each rows as r, i (i)} + {#if r.time_ms != null}{/if} + {/each} + +
+ {/if} + + diff --git a/handoff/rounds/ins-r30-syncroom-script.txt b/handoff/rounds/ins-r30-syncroom-script.txt new file mode 100644 index 0000000..8f68c63 --- /dev/null +++ b/handoff/rounds/ins-r30-syncroom-script.txt @@ -0,0 +1,21 @@ + /* the envelope at the width the strip draws: 64 columns, each the + loudest frame in its slice — the shape of the singing, not the wave */ + const cols = $derived.by(() => { + const f = tapSync.activity?.frames; + if (!f?.length) return []; + const per = f.length / 64; + return Array.from({ length: 64 }, (_, i) => { + let m = 0; + for (let j = Math.floor(i * per); j < Math.floor((i + 1) * per) && j < f.length; j++) m = Math.max(m, f[j]); + return m; + }); + }); + const spanMs = $derived((tapSync.activity?.frames?.length ?? 0) * (tapSync.activity?.frameMs ?? 100)); + const pct = (ms) => (spanMs ? Math.max(0, Math.min(100, (ms / spanMs) * 100)) : 0); + /* one button, four states: reading the file, nothing worth reading, the + estimate, and the way back out of it */ + const estLabel = $derived( + tapSync.reading === "ready" + ? (tapSync.estimated ? "Clear the estimate" : "Estimate the timing") + : tapSync.reading === "none" ? "No estimate for this one" : "Reading the song…", + ); diff --git a/handoff/rounds/ins-r31-css.txt b/handoff/rounds/ins-r31-css.txt new file mode 100644 index 0000000..791ea88 --- /dev/null +++ b/handoff/rounds/ins-r31-css.txt @@ -0,0 +1,26 @@ +/* ── what the phone says when things go wrong ────────────────────────── + The error toast gets the notice's shape: the sentence, and one action + beside it where there is one to offer — Try again, or Sign in when the + trouble is that nobody is signed in. The rules mirror .toast.notice's + above rather than sharing them: this toast is the red one, and its line + WRAPS (a translated sentence is a sentence, not a label), which is the + one thing the notice's .nt must never do. */ +.toast { display: flex; align-items: center; gap: 12px; } +.toast .nt { flex: 1; min-width: 0; } +.toast .na { flex: none; color: #ffd7d7; font-weight: 800; font-size: 13px; padding: 4px 8px; margin: -4px -8px -4px 0; border-radius: 6px; white-space: nowrap; } +.toast .na:active { background: rgba(255, 255, 255, 0.12); } + +/* a stub that is explaining a failure rather than an emptiness: the same + card, with the backend's own words under the lead in the muted colour */ +.stub.why { color: var(--text-muted); } + +/* the two lines a screen says instead of a spinner that will not end */ +.sec-note.rooms-note, +.sec-note.offline-note { color: #ffb4b4; } + +/* the library folder the rescan could not reach — a row under the folder + itself, so the path and what to do about it are in the same place. The + path is drawn exactly as the Library folder row above it draws its own + (`.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; } diff --git a/handoff/rounds/ins-r31-doc.txt b/handoff/rounds/ins-r31-doc.txt new file mode 100644 index 0000000..e59e386 --- /dev/null +++ b/handoff/rounds/ins-r31-doc.txt @@ -0,0 +1,82 @@ +## When things go wrong + +Thirty rounds built the happy path. A phone is also in a lift, signed out, on +a dead cell, or holding a library folder that has gone away, and until this +round every one of those showed up as an empty box, a spinner with no end, or +a Rust string in a red toast. + +**One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend +actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`, +`login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer +rejected: {0}`, `timed out`), the commands' own literals (`not connected to +Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server: +{e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a +sentence a person can act on. The rule that matters: **the stores keep the raw +string and only the DRAWING is translated.** `player.error` still holds what +Rust said, Diagnostics and the walk still read it, and `say()` is called at the +one place the toast is written — which is why fifty-nine `String(e)` sinks were +fixed without touching fifty-nine call sites, and why the sixtieth is covered +too. A message the backend already wrote in English ("The playlist needs a +name") matches no rule and passes through untouched. + +The worst one it retires was on the sign-in card: a phone with no DNS drew, in +red under the password box, `could not reach server: io: failed to lookup +address information: no address associated with hostname`. It now says +"Couldn't reach the Soulseek server. Check the connection and try again." + +The red toast gained the notice's shape — the sentence, and one action beside +it. **Try again** when retrying is the answer (a resolve that found no sources, +a library read that failed at launch), **Sign in** when `signedOut()` says the +trouble is that nobody is. The action is tied to the exact message it belongs +to (`player.retry = { for, fn }`), so a later, different error can never +inherit the last one's button. + +What each screen says now, where it used to say nothing or worse: + +- **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable + to open database file" and then "No tracks match.", which is a failed read + dressed up as an empty library. Now: "Couldn't read the library index", the + translated reason, and a Try again. "No tracks match" only when the read + SUCCEEDED. +- **A playlist** — a rejected `playlist_items` said "This playlist is empty." + Empty now means empty; failed says so, with a Try again. +- **A catalog record** and **an artist page** — the catalog's own error is + translated, the record's stub offers a Try again, and the artist's stub says + "the catalog didn't answer" rather than "had nothing to add" when that is + what happened. +- **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back + to "See what's missing" and could be tapped for ever. It now says "Couldn't + reach the catalogue — tap to try again". +- **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline + with follows, Home drew empty rails and said nothing. One line under the lead + now does, and points at the library below. +- **Search** — the whole network section sat inside `{#if online}` with no + `{:else}`, so signed out with a query typed there was no row and no reason, + just a gap. Now there is a line and a door to Community. +- **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for + this track.", and that branch was also the only one of the four with no `⋯` + button, so Paste lyrics was unreachable exactly when you wanted it. Both are + fixed: a failure says so, and both branches carry the `⋯`. +- **Transfers** — `failed · ` on a row is `failed · `. + +**Two spinners were given an end.** `request_room_list` is fire-and-forget over +the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and +there is no reply channel and no timeout), so "Asking the server for its +rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in +`refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that +never answers gets "The server hasn't sent its room list." and a Try again. +Tracks' own "Reading the index…" has its failure branch above. Everything else +was measured and left alone because it is already bounded: `user_info` 30 s, +`user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s. + +**One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs) +ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose +path does not `exists()`, then every album left with no tracks. With the root +unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of +trouble — You → **Rescan library** emptied the whole index, silently, and +returned `Ok(())`. There is now a guard above the prune: make the root if it is +merely not there yet (a fresh install still works), and refuse with +`library-folder-missing: ` if it cannot be made or read. You catches that +into a row under Library folder with the path and what to do about it, and the +toast says it in English. + diff --git a/handoff/rounds/ins-r31-expect.txt b/handoff/rounds/ins-r31-expect.txt new file mode 100644 index 0000000..6caa625 --- /dev/null +++ b/handoff/rounds/ins-r31-expect.txt @@ -0,0 +1,18 @@ + /* ── the weather ──────────────────────────────────────────────────── + Each of these fails if the round's fix is undone: the stub if a failed + read goes back to being drawn as an empty library, the sign-in lines if + the toast or the card stops translating, the search note if the block + goes back to rendering nothing at all signed out, the room note if the + spinner loses its end, the folder row if the rescan stops refusing. + Every one also asserts the RAW string is gone — that is the round. */ + "weather-tracks": (i) => /^Couldn't read the library index/.test(i.why) && /library folder isn't readable/.test(i.why) + && i.again && i.trks === 0 && !/database file/.test(i.why + i.toast), + "weather-tracks-ok": (i) => !i.why && !i.again && i.trks > 0 && !i.toast, + "weather-signedout": (i) => i.slskOn === "offline" && i.signin && !i.signinErr && !i.toast, + "weather-badpass": (i) => i.signinErr === "Soulseek refused that password." && !/INVALIDPASS/.test(i.signinErr), + "weather-noserver": (i) => /^Couldn't reach the Soulseek server/.test(i.signinErr) && !/io:|address information/.test(i.signinErr), + "weather-search-out": (i) => i.title.startsWith("Search") && /Community/.test(i.offlineNote) && !i.netAsk, + "weather-rooms-late": (i) => i.slskOn === "online" && /hasn't sent its room list/.test(i.roomsNote) && i.again, + "weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast) + && !/library-folder-missing/.test(i.toast + i.folderGone), + "weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title), \ No newline at end of file diff --git a/handoff/rounds/ins-r31-mock.txt b/handoff/rounds/ins-r31-mock.txt new file mode 100644 index 0000000..e6e24d0 --- /dev/null +++ b/handoff/rounds/ins-r31-mock.txt @@ -0,0 +1,64 @@ +/* ── the weather ──────────────────────────────────────────────────────── + Thirty rounds of walk drove the happy path and nothing else: not one step + ever made a command reject, and not one ever saw the client signed out. + This mock IS the whole backend behind the browser walk, so it is the only + place the unhappy paths can be driven from. + + Four doors, all of them checked in mockInvoke BEFORE any dispatch, so a + `plugin:` command and a catalog command arm exactly the same way: + + __ssMock.fail(cmd, message) the next and every call to `cmd` rejects + with that exact string; "" disarms it. + __ssMock.answer(cmd, value) `cmd` resolves with `value` — an empty + list, a null page — instead of its + fixture, so the walk names what "empty" + means rather than the mock guessing. + __ssMock.forget(cmd) takes one armed answer away. + __ssMock.offline(on) the client goes down: everything the + backend funnels through with_client() + answers "not connected to Soulseek", + connect_stored / connect_and_login answer + the server string, and soulseek:state + goes out. off restores and re-connects. + __ssMock.clear() all of it away, for the walk's last step. + + They reject with a PLAIN STRING, because that is what Tauri rejects with: + an Error would arrive as "Error: …" and hide half of what say() has to + cope with. All of it sits behind `enabled`, so a real build never sees it. */ +const armedFail = new Map(); +const armedAnswer = new Map(); +let offlineNow = false; +/* what src-tauri/src/commands.rs runs through with_client(), plus the two + searches and a download, which check the connection themselves */ +const needsClient = (cmd) => /^(slsk_|buddy_)/.test(cmd) || cmd === "start_search" || cmd === "stop_search" || cmd === "download_file"; +function weather(cmd) { + const f = armedFail.get(cmd); + if (f != null) return Promise.reject(f); + if (offlineNow) { + if (cmd === "connect_stored" || cmd === "connect_and_login") { + return Promise.reject("could not reach server: io: failed to lookup address information: no address associated with hostname"); + } + if (needsClient(cmd)) return Promise.reject("not connected to Soulseek"); + } + if (armedAnswer.has(cmd)) return Promise.resolve(armedAnswer.get(cmd)); + return undefined; +} +if (enabled && typeof window !== "undefined") { + window.__ssMock = { + emit: mockEmit, + fail(cmd, message) { if (message) armedFail.set(cmd, String(message)); else armedFail.delete(cmd); return true; }, + answer(cmd, value) { armedAnswer.set(cmd, value); return true; }, + forget(cmd) { armedAnswer.delete(cmd); return true; }, + offline(on) { + offlineNow = !!on; + mockEmit("soulseek:state", offlineNow ? { kind: "disconnected" } : { kind: "connected", username: MOCK_USER }); + return offlineNow; + }, + clear() { + armedFail.clear(); + armedAnswer.clear(); + if (offlineNow) { offlineNow = false; mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }); } + return true; + }, + }; +} diff --git a/handoff/rounds/ins-r31-phone-expect.txt b/handoff/rounds/ins-r31-phone-expect.txt new file mode 100644 index 0000000..066b08f --- /dev/null +++ b/handoff/rounds/ins-r31-phone-expect.txt @@ -0,0 +1,5 @@ + /* eleven real backend strings through the one translator on the device: + every output a sentence, and not one of them still carrying Rust */ + "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), \ No newline at end of file diff --git a/handoff/rounds/ins-r31-phone-steps.txt b/handoff/rounds/ins-r31-phone-steps.txt new file mode 100644 index 0000000..081b890 --- /dev/null +++ b/handoff/rounds/ins-r31-phone-steps.txt @@ -0,0 +1,6 @@ + /* the translator, on the real build: the backend's own vocabulary in, + plain sentences out. No side effects — nothing is signed out (the + login throttle is a remembered trap), nothing is asked of the network, + 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 }, \ No newline at end of file diff --git a/handoff/rounds/ins-r31-probe.txt b/handoff/rounds/ins-r31-probe.txt new file mode 100644 index 0000000..8aaa5af --- /dev/null +++ b/handoff/rounds/ins-r31-probe.txt @@ -0,0 +1,20 @@ + /* ── the weather: what the screen says when a read fails, an answer + is empty, or nobody is signed in. Every one of these is a line + this round put there; before it they were an empty box, a spinner + with no end, or a Rust string in the red toast. */ + slskOn: (function () { try { return __ss.slsk.connection; } catch (e) { return ''; } })(), + signin: !!document.querySelector('.signin'), + signinErr: (document.querySelector('.signin-err') || {}).textContent || '', + why: (document.querySelector('.stub.why') || {}).textContent || '', + /* the toast's one action, by its words: "Try again" or "Sign in" */ + retry: (document.querySelector('.toast:not(.notice) .na') || {}).textContent || '', + again: !![].slice.call(document.querySelectorAll('.imp-foot .btn')).find(function (b) { return /Try again/.test(b.textContent); }), + roomsNote: (document.querySelector('.rooms-note') || {}).textContent || '', + offlineNote: (document.querySelector('.offline-note') || {}).textContent || '', + netAsk: !!document.querySelector('.net-ask'), + folderGone: (document.querySelector('.folder-gone') || {}).textContent || '', + lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '', + lyrTools: !!document.querySelector('.lyr-tools'), + trks: document.querySelectorAll('.screen .trk').length, + /* the phone's trouble-words step leaves its eleven sentences here */ + trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(), \ No newline at end of file diff --git a/handoff/rounds/ins-r31-steps.txt b/handoff/rounds/ins-r31-steps.txt new file mode 100644 index 0000000..4736c53 --- /dev/null +++ b/handoff/rounds/ins-r31-steps.txt @@ -0,0 +1,56 @@ + /* ── the weather ──────────────────────────────────────────────────── + Everything above this line is the happy path; nothing in thirty + rounds of walk had ever made a command reject or taken the client + offline, so none of these lines had ever been drawn. The mock's own + doors (__ssMock.fail / answer / offline / clear) are the weather, and + every assertion below is on SETTLED state — an armed failure stays + armed, a sign-in card stays up, the folder-gone row stays — because + the probe lands after the screenshot, well past the step's own wait. + The one timed assertion is the room list, and its timeout is shortened + to 1.2 s against the 3.1 s of waits between arming it and reading it. + The last step puts the weather away and + leaves the app exactly as it found it — anything appended after this + block inherits a signed-in app on Home. + + The first step settles FIRST, and it has to: the landscape block above + leaves the library tab three screens deep (record → artist), so one tap + on the Library tab lands on the artist page and `.segtabs button` would + be its discography tabs, not Albums/Artists/Tracks. It also wipes any + error the happy path left standing, so `!i.toast` below means this + block's toast and no other. */ + { do: `(function () { __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 }, + { do: `__ssMock.fail('library_track_index', 'unable to open database file')`, wait: 300 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "weather-tracks", wait: 1300 }, + { do: `__ssMock.fail('library_track_index', '')`, wait: 200 }, + { do: `__click('.imp-foot .btn', 'Try again')`, name: "weather-tracks-ok", wait: 1300 }, + /* signed out, in a lift: the card is back and nothing says why in Rust */ + { do: `__ssMock.offline(true)`, wait: 600 }, + { do: `document.querySelectorAll('.nav button')[3].click()`, name: "weather-signedout", wait: 1100 }, + /* the single most visible raw-Rust site on the phone, twice over */ + { do: `__ssMock.fail('connect_and_login', 'login failed: INVALIDPASS')`, wait: 200 }, + { do: `(function () { var p = document.querySelectorAll('.signin .field')[1]; p.value = 'hunter2'; p.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, wait: 400 }, + { do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-badpass", wait: 1200 }, + { do: `__ssMock.fail('connect_and_login', 'could not reach server: io: failed to lookup address information: no address associated with hostname')`, wait: 200 }, + { do: `__click('.signin .btn-fill', 'Sign in')`, name: "weather-noserver", wait: 1200 }, + /* Search signed out with a query typed showed NOTHING where the network + section lives — no row, no reason. Now it says so and offers the door */ + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 }, + { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, name: "weather-search-out", wait: 1800 }, + /* the room list is fire-and-forget over the wire (the client sends it and + nothing answers if the server will not), so the wait is ours: an armed + answer that resolves and emits nothing is exactly that server. Community + is left on Buddies by the walk above, so the Rooms tab is asked for by + name — the note lives under that one. */ + { do: `__ssMock.fail('connect_and_login', ''); __ssMock.offline(false)`, wait: 1200 }, + { do: `(function () { __ss.slsk.roomsWaitMs = 1200; __ss.slsk.rooms = []; __ssMock.answer('slsk_room_list', null); __ss.refreshRooms(); return true; })()`, wait: 200 }, + { do: `document.querySelectorAll('.nav button')[3].click()`, wait: 900 }, + { do: `__click('.segtabs button', 'Rooms')`, name: "weather-rooms-late", wait: 2200 }, + /* one tap on Rescan library used to empty the whole index and say Ok */ + { do: `__ssMock.fail('rescan_library', 'library-folder-missing: /storage/1A2B-3C4D/Music')`, wait: 200 }, + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, + { do: `__click('.screen .row', 'Rescan library')`, name: "weather-rescan", wait: 1500 }, + /* and the weather clears */ + { 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 }, diff --git a/handoff/rounds/ins-r31-trouble.txt b/handoff/rounds/ins-r31-trouble.txt new file mode 100644 index 0000000..be5a33d --- /dev/null +++ b/handoff/rounds/ins-r31-trouble.txt @@ -0,0 +1,70 @@ +/* What the phone says when things go wrong. + + The backend answers in Rust. `ClientError`'s Display impls (crates/ + soulseek-client/src/lib.rs) hand up `io: {0}`, `protocol: {0}`, `login + failed: INVALIDPASS`, `user {0} is offline or unknown`, `timed out`; the + commands add their own literals (`not connected to Soulseek`, `no stored + credentials`, `no cache dir`, `could not reach server: {e}`); rusqlite and + std::io come through `e.to_string()`. Fifty-nine places in src/mobile/ put + one of those straight into a field a person reads, and the red toast drew + it verbatim — a phone in a lift showed, under the password box, "could not + reach server: io: failed to lookup address information: no address + associated with hostname". + + ONE translator, called where the words are DRAWN rather than where they + are caught. The stores keep the raw string — Diagnostics and the walk still + see exactly what Rust said — and only the sentence on screen is ours, so + every future `String(e)` is covered by the same rule as the fifty-nine + already here. A message the backend already wrote in English ("The playlist + needs a name") matches nothing and passes through untouched. + + The order below is the order they are tried: the most specific shape + first, the two catch-alls (`io:` / `protocol:`) last but one, and anything + unrecognised kept as it is, capped. */ + +/** The message inside whatever was thrown: an Error, a plain string (which + * is what Tauri rejects with), or something with neither. */ +const clean = (e) => String(e?.message ?? e ?? "").replace(/^Error:\s*/, "").trim(); + +const RULES = [ + /* this round's own guard (src-tauri/src/pipeline.rs): the library root + could not be made or read, so the rescan refused rather than pruning + every row whose file it could not see */ + [/^library-folder-missing:\s*(.*)$/i, (m) => `The library folder isn't there any more — ${m[1]}. Reconnect the storage, or pick the folder again in You.`], + /* with_client()'s funnel, and start_search's plainer version */ + [/not connected( to (soulseek|server))?$/i, () => "Not signed in to Soulseek. Sign in on Community and try again."], + [/^no stored credentials$/i, () => "No Soulseek account saved on this phone yet. Sign in on Community."], + /* the server's own refusal words, which are not for reading */ + [/^login failed:\s*(.*)$/i, (m) => (/invalidpass/i.test(m[1]) ? "Soulseek refused that password." : /invalidusername/i.test(m[1]) ? "Soulseek refused that username." : `Soulseek refused the sign-in (${m[1]}).`)], + [/^could not reach server/i, () => "Couldn't reach the Soulseek server. Check the connection and try again."], + [/^timed out$/i, () => "The network didn't answer in time."], + [/^user (.+?) is offline or unknown$/i, (m) => `${m[1]} isn't online right now.`], + [/^could not reach peer (.+)$/i, (m) => `${m[1]}'s client didn't answer — offline, or behind a firewall.`], + [/^transfer rejected:\s*(.*)$/i, (m) => `They refused the transfer${m[1] ? ` (${m[1].replace(/\.$/, "")})` : ""}.`], + [/^(io|protocol):\s*(.*)$/i, () => "The network didn't answer. Check the connection and try again."], + /* the library's own two: a database that will not open, and a folder the + phone has not been given */ + [/unable to open database file|os error (2|3)\b|cannot find the (path|file)/i, () => "The library folder isn't readable right now."], + [/permission denied|os error 13\b/i, () => "The phone refused access to that folder — check the app's storage permission in You."], + [/^no cache dir$/i, () => "This phone has no cache folder to work in."], + /* a page with no Tauri under it — the browser walk, and only ever that */ + [/reading 'invoke'|transformCallback/i, () => "The app's backend isn't here (this is the browser walk)."], +]; + +/** Longer than this and it was never a sentence anyone meant to write. */ +const CAP = 180; + +/** The backend's error → something a person can act on. */ +export function say(e) { + const raw = clean(e); + if (!raw) return ""; + for (const [re, fn] of RULES) { + const m = re.exec(raw); + if (m) return fn(m); + } + return raw.length > CAP ? raw.slice(0, CAP - 1) + "…" : raw; +} + +/** Is this the kind of trouble Community fixes? The two places that offer a + * way to sign in rather than a Try again ask here. */ +export const signedOut = (e) => /not connected|no stored credentials/i.test(clean(e)); diff --git a/handoff/rounds/ins-r32-boot.txt b/handoff/rounds/ins-r32-boot.txt new file mode 100644 index 0000000..a6dec8c --- /dev/null +++ b/handoff/rounds/ins-r32-boot.txt @@ -0,0 +1,53 @@ +/* The boot census. + + Every command the app issues passes one counter here — src/lib/api.js on + the way into `invoke`, and src/lib/events.js on the way into `listen`, + which is an invoke like any other (Tauri registers the subscription over + the same bridge). A plugin that registers its own listener through + @tauri-apps/api directly goes past both doors and is not counted — the + media card's and the transfer notice's `addPluginListener` are the two — + so what this measures is the app's own traffic. The question it answers + is the one nobody could answer before: how much of the world does the + app ask for before it has drawn anything? + + It seals at the first frame the shell paints and never re-opens, so a + probe landing a second and a half later still reads the launch. That is + what tests/mobile/smoke.mjs holds a budget against — the point is to + catch a round that loads the world at boot, not to police milliseconds. + + A plain object, deliberately: this module is shared with the desktop, it + imports nothing, and it must not throw. On the desktop nothing seals it + and nothing reads it. */ +export const boot = { + /** the moment the chain starts — the shell's onMount sets it */ + t0: 0, + /** every invoke and every listen, until the seal */ + calls: 0, + /** how many of them left in the shell's own first turn, before any + * answer could have come back. A chain that waits on itself can only + * issue its first call there, so this is the one number that says + * whether the boot chain is still going out in one round; the shell + * latches it at the end of onMount. -1 until it does. */ + sync: -1, + /** the first hundred and twenty of them, in the order they went out — + * past any budget the walk holds, so "is X at boot?" is never + * answered by a list that stopped short */ + cmds: [], + /** onMount → the first frame the app could have drawn into */ + frameMs: null, + /** onMount → lib.loaded, which is what Home waits for */ + libMs: null, + sealed: false, +}; + +export function countBoot(cmd) { + if (boot.sealed) return; + boot.calls++; + if (boot.cmds.length < 120) boot.cmds.push(cmd); +} + +export function sealBoot() { + if (boot.sealed) return; + boot.sealed = true; + try { boot.frameMs = Math.round(performance.now() - boot.t0); } catch { /* a webview without the timer */ } +} diff --git a/handoff/rounds/ins-r32-doc.txt b/handoff/rounds/ins-r32-doc.txt new file mode 100644 index 0000000..c0ea38b --- /dev/null +++ b/handoff/rounds/ins-r32-doc.txt @@ -0,0 +1,75 @@ +The launch itself had never been measured. The bundle the phone loads is +`mobile.html` and three chunks: its own (about 300 KB), the shared one +(about 130 KB) and a small third (about 27 KB) — roughly 460 KB of JS and +57 KB of CSS, all of it eager. The static graph from `src/mobile-main.js` +outward is 64 modules and contains no dynamic import at all, so there is one +chunk and the phone evaluates every byte of it at every launch. Importing +that graph in headless Chrome costs 11.6 ms at full speed and 59.2 ms at a +6× CPU throttle. + +The desktop is not riding along, which was checked rather than assumed: of +`state.svelte.js`'s 44 long string literals exactly four appear in the +chunks the phone loads, and all four are event names it legitimately uses. +`heroGpu.js`, `trace.js`, `ImportWindow.svelte`, `TitleBar` and +`DebugOverlay` contribute nothing; there is no shader source anywhere in the +bundle. The one passenger is the dev mock library +(`src/lib/mockLibrary.js`, about 32 KB of the shared chunk), which +`src/lib/api.js` and `src/lib/events.js` import unconditionally because +`mockInvoke` has to answer synchronously. It costs 6.6 ms of every launch at +6× throttle, only 1.4 ms of which is its top-level work — the rest is parse +and function definition, which only removing it from the bundle can recover. +`import.meta.env.DEV` cannot do that, because the mock walk builds +`dist-smoke/` with a plain production `vite build`; getting it out needs a +build-mode flag the walk itself would have to pass. Named here rather than +taken. + +The boot chain was the expensive half. It is 26 commands the census can see, +plus two plugin listeners that register through `@tauri-apps` directly and go +past it. Until this round its critical path — the longest chain where each +call was issued only after the one before it had answered — was ten deep, +because `listen` is an invoke like any other: `loadLibrary` asked for five +independent SQLite reads one after another, `bindSlsk` registered eight +listeners one after another, and `watchLibrary` and the player's `bindEvents` +each did two. They go out in one round now, and the difference is countable +rather than felt: ten of those commands used to leave in the shell's own +first turn and twenty-three do now, and the critical path is three instead +of ten. Measured in a rig that runs the real store modules — +both trees, the same onMount script — with the bridge instrumented and the +backend's work modelled behind the library's single `conn: Mutex`: +the same commands both ways, and `lib.loaded` 85 ms → 53 at a 6 ms bridge +round trip, 105 → 57 at 12 ms, 148 → 62 at 20 ms. Those are a desktop rig's +milliseconds with the round trip swept rather than measured; the structural +half holds anywhere, and the phone's own numbers arrive the first time the +walk prints `bootMs` and `bootLibMs`. + +Three things were looked at and deliberately left alone. Home's per-artist +catalog walk is the deepest chain in the app — `catalog_artist` → +`catalog_artist_identity` → `catalog_related`, serially, per followed artist +— and that is the pacing, not an oversight: `crates/metadata/src/lib.rs` +holds a Deezer budget of 45 requests per 5 s and records the MusicBrainz +identity chain at 4–6 requests behind a mandatory 1 req/s gate, 3.7–8.5 s +per artist. The follows are read twice at launch (once by `loadLibrary`, +once by `loadHome`), and the obvious guard does not work, because Home's +effect calls `loadHome(true)` the first time the follows land — one small +local read, left for a round that can do it properly. And lazy-loading the +one-tab screens (Diagnostics, Import, Browse, Peer, Room, Thread and the +importer) is 9% of the source against a real risk of a blank pushed frame, +so it is worth about 6 ms at 6× throttle and is not worth a dynamic import +yet. + +The instrument is `src/lib/boot.js`: one counter every `invoke` and every +`listen` passes, sealed at the first frame the shell paints and never +re-opened, so a probe landing a second and a half later still reads the +launch. The mock walk holds a budget against it at the Home step and both +walks print `bootCalls`, `bootSync`, `bootMs` and `bootLibMs` on every step. +`bootSync` is the clause with no clock in it: it is how many of the boot +commands left in the shell's own turn, so a chain put back in a queue can +only fall under its floor, whatever the machine is doing. Three things the +instrument does not do. It counts the app's own traffic, so the media card's +and the transfer notice's `addPluginListener` are invisible to it. It counts +commands, not bytes, so a later round that adds an import to a shared file +can move modules between chunks with nothing failing — that gap is open. And +the mock's exact count depends on how much of the first screen has asked for +its covers by the time the second frame lands, so the call ceiling is set to +catch a regression in kind (a per-artist walk or an index pulled to boot) +rather than a handful of calls. diff --git a/handoff/rounds/ins-r32-loadlibrary.txt b/handoff/rounds/ins-r32-loadlibrary.txt new file mode 100644 index 0000000..db2b5a4 --- /dev/null +++ b/handoff/rounds/ins-r32-loadlibrary.txt @@ -0,0 +1,30 @@ + /* Five reads that never depended on one another. Each is a + spawn_blocking SQLite read behind the library's single + `conn: Mutex`, so the queries still take their turn — + what is saved is five bridge round trips collapsing into one. + + Every call keeps the handling it had. The one thing that could have + moved is `lib.error`, which has two writers and used to be settled + by the order they were awaited in; the two messages are carried out + of the round and applied in that same order instead, so a failing + library still shows the library's message and not app_status's, no + matter which of them answers last. Nothing is dropped, nothing new + is asked for, and `lib.loaded` still goes up only once all five + have settled. */ + const [statusErr, albumsErr, , follows, playlists] = await Promise.all([ + api.appStatus().then((st) => { + lib.version = st?.version ?? ""; + lib.username = st?.connected_as ?? null; + return null; + }, (e) => String(e)), + api.libraryAlbums().then((rows) => { lib.albums = rows; return null; }, (e) => String(e)), + refreshRecents(), // writes lib.recents itself, and swallows its own failure + api.followedArtists().then((rows) => rows || [], () => []), + api.listPlaylists().then((rows) => rows || [], () => []), + ]); + if (statusErr) lib.error = statusErr; + if (albumsErr) lib.error = albumsErr; + lib.follows = follows; + lib.playlists = playlists; + lib.loaded = true; + void refreshStats(); diff --git a/handoff/rounds/ins-r32-mobile-effect.txt b/handoff/rounds/ins-r32-mobile-effect.txt new file mode 100644 index 0000000..92405aa --- /dev/null +++ b/handoff/rounds/ins-r32-mobile-effect.txt @@ -0,0 +1,10 @@ + /* ── when the library landed, for the record ────────────────────── + Home gates its landing screen on lib.loaded, so this is the number a + launch is actually felt as, and it is the one the phone leg prints. + `boot` is a plain object and never a rune, so the write cannot feed + the effect back into itself; untrack says so out loud. */ + $effect(() => { + if (!lib.loaded) return; + untrack(() => { if (boot.libMs == null) boot.libMs = Math.round(performance.now() - boot.t0); }); + }); + diff --git a/handoff/rounds/ins-r32-mobile-onmount.txt b/handoff/rounds/ins-r32-mobile-onmount.txt new file mode 100644 index 0000000..48ce115 --- /dev/null +++ b/handoff/rounds/ins-r32-mobile-onmount.txt @@ -0,0 +1,9 @@ + onMount(() => { + /* the boot census starts here. Every command the app issues between + this line and the first frame is counted, and the mock walk holds + a budget against the count — the point is to catch a round that + loads the world at boot, not to police milliseconds. */ + boot.t0 = performance.now(); + try { performance.mark("ss:boot"); } catch { /* a webview without the entry types */ } + loadPrefs(); + bootNav(); diff --git a/handoff/rounds/ins-r32-mobile-seal.txt b/handoff/rounds/ins-r32-mobile-seal.txt new file mode 100644 index 0000000..58de9b9 --- /dev/null +++ b/handoff/rounds/ins-r32-mobile-seal.txt @@ -0,0 +1,13 @@ + /* everything above went out in this one turn. A call that waits on + the one before it can only have issued its first here, so this is + the number that says the boot chain is still going out in one + round — 10 before this round, 23 after it — and the walk holds a + floor against it. */ + boot.sync = boot.calls; + /* two frames: the first is the shell, the second is the first one + the app could have drawn anything into. A phone producing no + frames at all (launched straight into the background) seals on + the timer instead, well before the walk's first probe — the + census must never depend on something that might not happen. */ + const sealTimer = setTimeout(sealBoot, 1200); + requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); })); diff --git a/handoff/rounds/ins-r32-mock-expect.txt b/handoff/rounds/ins-r32-mock-expect.txt new file mode 100644 index 0000000..bc640d9 --- /dev/null +++ b/handoff/rounds/ins-r32-mock-expect.txt @@ -0,0 +1,21 @@ + /* and the boot budget, read off __ss.boot. Each clause earns its + place: the census must have counted something (a census that + breaks and reports -1 or -2 would sail through a one-sided + test); it must not have counted the world; the boot chain must + still be going out in one round rather than waiting on itself, + which is the one clause that is not a clock at all — it was 10 + before round 32 and is 23 after, so a chain put back in a queue + lands under the floor; the shell must have sealed on a frame + rather than on the 1200 ms fallback; the library must still land + inside the launch rather than behind something slow; and round + 12's eight-second art prewarm must still be nowhere near boot. + The call ceiling is deliberately loose — the boot chain issues + about 25 commands and the first screen asks for its covers on + top, and the point there is to catch a round that pulls a + per-artist walk or an index to boot, not to police a handful of + calls. */ + && i.bootCalls >= 14 && i.bootCalls <= 60 + && i.bootSync >= 20 + && i.bootMs >= 0 && i.bootMs < 1200 + && i.bootLibMs >= 0 && i.bootLibMs < 600 + && !/library_art_prewarm/.test(i.bootCmds), diff --git a/handoff/rounds/ins-r32-phone-expect.txt b/handoff/rounds/ins-r32-phone-expect.txt new file mode 100644 index 0000000..6ed8ab7 --- /dev/null +++ b/handoff/rounds/ins-r32-phone-expect.txt @@ -0,0 +1,10 @@ + /* the phone's own launch, held wider on purpose: a real one signs in, + restores a session and may walk the follows, none of which the mock + does, and its library reads are real SQLite rather than an object in + memory. What is asserted here is the shape — a census that counted + something, sealed, still went out in one round, and did not drag the + art prewarm back to boot. The milliseconds are printed rather than + policed: every step's JSON carries bootMs and bootLibMs, so the + device's real figures land in the report the first time this walk + runs. */ + home: (i) => i.bootCalls >= 14 && i.bootCalls <= 90 && i.bootSync >= 20 && i.bootMs >= 0 && !/library_art_prewarm/.test(i.bootCmds), diff --git a/handoff/rounds/ins-r32-player-stream.txt b/handoff/rounds/ins-r32-player-stream.txt new file mode 100644 index 0000000..01c8c9f --- /dev/null +++ b/handoff/rounds/ins-r32-player-stream.txt @@ -0,0 +1,11 @@ + /* Two subscriptions in one round, for the same reason as everywhere + else this round: `listen` is an invoke, so one behind the other is + two bridge round trips at launch. Both are in flight from here; they + are awaited together at the end, so neither can reject with nobody + listening. */ + const streamSub = listen("soulseek:stream", (ev) => { + const p = ev.payload || {}; + if (!current()?.t?.stream) return; + player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null }; + if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed); + }); diff --git a/handoff/rounds/ins-r32-player-tail.txt b/handoff/rounds/ins-r32-player-tail.txt new file mode 100644 index 0000000..1515e18 --- /dev/null +++ b/handoff/rounds/ins-r32-player-tail.txt @@ -0,0 +1,8 @@ + }); + /* the stream listener is still allowed to have nothing behind it (a + desktop build, the mock without the bus); the transport's failure is + still the one the shell shows, and the unlisten handed back is still + the transport's — the one onMount stores and calls on teardown. */ + const [, unlisten] = await Promise.all([streamSub.catch(() => { /* not a phone */ }), transportSub]); + return unlisten; +} diff --git a/handoff/rounds/ins-r32-probe.txt b/handoff/rounds/ins-r32-probe.txt new file mode 100644 index 0000000..4a46610 --- /dev/null +++ b/handoff/rounds/ins-r32-probe.txt @@ -0,0 +1,15 @@ + /* 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, + which kinds they were, and how long it took. A probe landing a + second and a half later still reads the launch. -1 is "the census + never sealed", -2 is "there is no census". bootCmds is the + distinct names, not the whole run of them — every step carries + it, and what is ever asked of it is whether a kind of command was + at boot at all. */ + bootCalls: (function () { try { return __ss.boot.sealed ? __ss.boot.calls : -1; } catch (e) { return -2; } })(), + bootSync: (function () { try { return __ss.boot.sync; } catch (e) { return -2; } })(), + bootMs: (function () { try { return __ss.boot.frameMs == null ? -1 : __ss.boot.frameMs; } catch (e) { return -2; } })(), + bootLibMs: (function () { try { return __ss.boot.libMs == null ? -1 : __ss.boot.libMs; } catch (e) { return -2; } })(), + bootCmds: (function () { try { var c = __ss.boot.cmds, u = [], i; for (i = 0; i < c.length; i++) if (u.indexOf(c[i]) < 0) u.push(c[i]); return u.join(' '); } catch (e) { return ''; } })(), + navMs: (function () { try { var n = performance.getEntriesByType('navigation')[0]; return n ? Math.round(n.domContentLoadedEventEnd) : -1; } catch (e) { return -1; } })(), diff --git a/handoff/rounds/ins-r32-slsk-on.txt b/handoff/rounds/ins-r32-slsk-on.txt new file mode 100644 index 0000000..5ffa4f1 --- /dev/null +++ b/handoff/rounds/ins-r32-slsk-on.txt @@ -0,0 +1,13 @@ + const subs = []; + const pending = []; + /* Tauri's `listen` is an invoke like any other, so eight of them one + behind the other is eight bridge round trips each waiting on the one + before. They never depended on one another: all eight go out at once + and the wait happens once, at the end. The ordering that does matter + is kept — bindSlsk still does not return until every handler is + registered, so connectStored() cannot let a fast `connected` land + with nobody listening. */ + const on = (name, fn) => { + pending.push(listen(name, (ev) => fn(ev.payload)).then((u) => { subs.push(u); }, () => { /* not a phone */ })); + }; + on("soulseek:state", (ev) => { diff --git a/handoff/rounds/ins-r32-watchlibrary.txt b/handoff/rounds/ins-r32-watchlibrary.txt new file mode 100644 index 0000000..fbd37f0 --- /dev/null +++ b/handoff/rounds/ins-r32-watchlibrary.txt @@ -0,0 +1,15 @@ + /* two subscriptions that never depended on one another. `listen` is + an invoke like any other, so one behind the other is two bridge + round trips at launch for nothing. */ + await Promise.all([ + listen("soulseek:library-changed", () => { + clearTimeout(timer); + timer = setTimeout(async () => { + try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } + await refreshRecents(); + void refreshStats(); + }, 600); + }), + /* a playlist mutation (the desktop's commands emit it) re-lists them */ + listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }), + ]); diff --git a/handoff/rounds/ins-r33-fmtcount.txt b/handoff/rounds/ins-r33-fmtcount.txt new file mode 100644 index 0000000..99b1be3 --- /dev/null +++ b/handoff/rounds/ins-r33-fmtcount.txt @@ -0,0 +1,4 @@ + +/** "1.2M", "430k" — a listener or play count beside a row, the desktop's + * short form. Nothing under a thousand is abbreviated. */ +export const fmtCount = (n) => (!n ? "" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n)); diff --git a/handoff/rounds/ins-r33-stamp.txt b/handoff/rounds/ins-r33-stamp.txt new file mode 100644 index 0000000..6cc98b9 --- /dev/null +++ b/handoff/rounds/ins-r33-stamp.txt @@ -0,0 +1,11 @@ + +/** "4:31", "1:02:07" — a length or a position in MILLISECONDS, which is how + * a playlist row's cue and an import draft's track both carry one. Hours + * only when there are hours; `mmss` above is the seconds-in version. */ +export function stamp(ms) { + const s = Math.floor((ms || 0) / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return (h ? h + ":" + String(m).padStart(2, "0") : String(m)) + ":" + String(sec).padStart(2, "0"); +} diff --git a/handoff/rounds/ins-r34-css.txt b/handoff/rounds/ins-r34-css.txt new file mode 100644 index 0000000..b6527b2 --- /dev/null +++ b/handoff/rounds/ins-r34-css.txt @@ -0,0 +1,25 @@ +/* ── 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; } diff --git a/handoff/rounds/ins-r34-doc-notdone.txt b/handoff/rounds/ins-r34-doc-notdone.txt new file mode 100644 index 0000000..4471008 --- /dev/null +++ b/handoff/rounds/ins-r34-doc-notdone.txt @@ -0,0 +1,12 @@ +- **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/handoff/rounds/ins-r34-doc.txt b/handoff/rounds/ins-r34-doc.txt new file mode 100644 index 0000000..1ab52df --- /dev/null +++ b/handoff/rounds/ins-r34-doc.txt @@ -0,0 +1,113 @@ +## 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. + diff --git a/handoff/rounds/ins-r34-halfsheet.txt b/handoff/rounds/ins-r34-halfsheet.txt new file mode 100644 index 0000000..e9a5212 --- /dev/null +++ b/handoff/rounds/ins-r34-halfsheet.txt @@ -0,0 +1,33 @@ + {: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.
+
diff --git a/handoff/rounds/ins-r34-mobile-boot.txt b/handoff/rounds/ins-r34-mobile-boot.txt new file mode 100644 index 0000000..55e8c6a --- /dev/null +++ b/handoff/rounds/ins-r34-mobile-boot.txt @@ -0,0 +1,13 @@ + hooks.recentsDirty = refreshRecents; + bindMedia(); + /* 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 */ + const netRead = bindSlsk().then(() => connectStored()).catch(() => {}); + Promise.all([configRead, libRead, netRead]).then(() => maybeOpenFirstRun()).catch(() => {}); diff --git a/handoff/rounds/ins-r34-mobile-effect.txt b/handoff/rounds/ins-r34-mobile-effect.txt new file mode 100644 index 0000000..53cedb0 --- /dev/null +++ b/handoff/rounds/ins-r34-mobile-effect.txt @@ -0,0 +1,17 @@ + /* ── 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; + }); diff --git a/handoff/rounds/ins-r34-mock-empty.txt b/handoff/rounds/ins-r34-mock-empty.txt new file mode 100644 index 0000000..bfedec1 --- /dev/null +++ b/handoff/rounds/ins-r34-mock-empty.txt @@ -0,0 +1,8 @@ + /* 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([]); diff --git a/handoff/rounds/ins-r34-mock-expect.txt b/handoff/rounds/ins-r34-mock-expect.txt new file mode 100644 index 0000000..72c1697 --- /dev/null +++ b/handoff/rounds/ins-r34-mock-expect.txt @@ -0,0 +1,34 @@ + /* ── 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, diff --git a/handoff/rounds/ins-r34-mock-fresh.txt b/handoff/rounds/ins-r34-mock-fresh.txt new file mode 100644 index 0000000..aaeddab --- /dev/null +++ b/handoff/rounds/ins-r34-mock-fresh.txt @@ -0,0 +1,16 @@ +/* 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; + } +})(); diff --git a/handoff/rounds/ins-r34-mock-settings.txt b/handoff/rounds/ins-r34-mock-settings.txt new file mode 100644 index 0000000..8fed3b6 --- /dev/null +++ b/handoff/rounds/ins-r34-mock-settings.txt @@ -0,0 +1,12 @@ +/* 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: [], +}); diff --git a/handoff/rounds/ins-r34-mock-steps.txt b/handoff/rounds/ins-r34-mock-steps.txt new file mode 100644 index 0000000..d4a5f2b --- /dev/null +++ b/handoff/rounds/ins-r34-mock-steps.txt @@ -0,0 +1,44 @@ + /* ── 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 }, diff --git a/handoff/rounds/ins-r34-mock-transport.txt b/handoff/rounds/ins-r34-mock-transport.txt new file mode 100644 index 0000000..2b38bed --- /dev/null +++ b/handoff/rounds/ins-r34-mock-transport.txt @@ -0,0 +1,17 @@ + /* 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(); diff --git a/handoff/rounds/ins-r34-phone-expect.txt b/handoff/rounds/ins-r34-phone-expect.txt new file mode 100644 index 0000000..413da19 --- /dev/null +++ b/handoff/rounds/ins-r34-phone-expect.txt @@ -0,0 +1,7 @@ + /* 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, diff --git a/handoff/rounds/ins-r34-phone-steps.txt b/handoff/rounds/ins-r34-phone-steps.txt new file mode 100644 index 0000000..d8b6150 --- /dev/null +++ b/handoff/rounds/ins-r34-phone-steps.txt @@ -0,0 +1,9 @@ + /* 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 }, diff --git a/handoff/rounds/ins-r34-probe.txt b/handoff/rounds/ins-r34-probe.txt new file mode 100644 index 0000000..7ffd727 --- /dev/null +++ b/handoff/rounds/ins-r34-probe.txt @@ -0,0 +1,17 @@ + /* the first run: the sheet, the two cards it offers, and the state + it read out of the config — "::". + 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(), diff --git a/handoff/rounds/ins-r34-setup-store.txt b/handoff/rounds/ins-r34-setup-store.txt new file mode 100644 index 0000000..8704708 --- /dev/null +++ b/handoff/rounds/ins-r34-setup-store.txt @@ -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: "::". */ +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 */ } +} diff --git a/handoff/rounds/ins-r34-setupnote.txt b/handoff/rounds/ins-r34-setupnote.txt new file mode 100644 index 0000000..9c63ab9 --- /dev/null +++ b/handoff/rounds/ins-r34-setupnote.txt @@ -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/handoff/rounds/ins-r34-you-row.txt b/handoff/rounds/ins-r34-you-row.txt new file mode 100644 index 0000000..a25f0df --- /dev/null +++ b/handoff/rounds/ins-r34-you-row.txt @@ -0,0 +1,5 @@ + diff --git a/handoff/rounds/ins-r35-doc-export.txt b/handoff/rounds/ins-r35-doc-export.txt new file mode 100644 index 0000000..84ef266 --- /dev/null +++ b/handoff/rounds/ins-r35-doc-export.txt @@ -0,0 +1,19 @@ +- **Exporting** (`playlist_export`, the playlist's own sheet and the shelf + card's): the desktop's five profiles, in the desktop's order and its own + words — M3U8, XSPF, PLS, CSV and the backup JSON. The file is built in + Rust by `playlist_formats`, ABOVE the platform fork, so the phone never + rebuilds a format and the two exports cannot drift. The doors differ + because the answers do: `tauri-plugin-dialog` does open Android's + create-document sheet, but it answers with a `content://` URI and + `std::fs::write` cannot take one, so on the phone the bytes go to + `tauri-plugin-storage`'s `create_document` and its Kotlin writes them + through the ContentResolver (`openOutputStream(uri, "wt")`), wherever the + sheet pointed. That is a plugin function called from Rust, not a command: + the file never passes through the WebView, and nothing was added to the + ACL. A dismissed sheet resolves nothing and the phone says nothing — + which is the desktop's `Ok(None)`. A row with no local file exports + exactly as it does on the desktop: named, with a comment where the + location would be (M3U8), carried without a location (XSPF, CSV, the + backup), or left out (PLS, which holds only real files). The locations + that ARE written are this phone's own paths, inside the app's folder — + the format behaving correctly, not a bug to rewrite. diff --git a/handoff/rounds/ins-r35-export-rs.txt b/handoff/rounds/ins-r35-export-rs.txt new file mode 100644 index 0000000..c9430ac --- /dev/null +++ b/handoff/rounds/ins-r35-export-rs.txt @@ -0,0 +1,132 @@ +/// Export a playlist to a file the user picks. `format` ∈ m3u8 | xspf | +/// pls | csv | json. Returns where it went — the path on the desktop, the +/// document's name on the phone — or None when the dialog was cancelled. +/// +/// The file is built ABOVE the fork, by `playlist_formats`, so both doors +/// write the same bytes and neither platform owns a format. The doors +/// differ because the answers do: the desktop's save dialog hands back a +/// path and `std::fs` writes it; Android's create-document sheet hands back +/// a `content://` URI, which no path can open, so the bytes go to the +/// storage plugin and its ContentResolver writes them where the user +/// pointed. A row with no local file is the writers' business, not this +/// one's — each has its own answer, and both platforms get it. +#[tauri::command] +pub async fn playlist_export( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, + format: String, +) -> Result, String> { + let l = lib.library.clone(); + let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + .ok_or("Playlist not found")?; + let l = lib.library.clone(); + let items = tauri::async_runtime::spawn_blocking(move || l.playlist_items(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + // The MIME is the phone's half of the pair: Android's Documents UI files + // the document by it, and staples an extension of its own on anything it + // does not recognise. All five are in its bundled table. + let (ext, filter_name, mime): (&str, &str, &str) = match format.as_str() { + "m3u8" => ("m3u8", "M3U8 playlist", "audio/x-mpegurl"), + "xspf" => ("xspf", "XSPF playlist", "application/xspf+xml"), + "pls" => ("pls", "PLS playlist", "audio/x-scpls"), + "csv" => ("csv", "CSV", "text/csv"), + "json" => ("json", "SoulStream playlist JSON", "application/json"), + other => return Err(format!("Unknown export format {other}")), + }; + let safe_name: String = meta + .name + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + + // Its own block: `export_items` borrows `items` and `meta`, and the + // desktop branch below awaits a dialog — nothing borrowed may be held + // across that await. The block ends the borrows with the file in hand. + let body = { + let export_items: Vec> = items + .iter() + .map(|i| ExportItem { + title: &i.title, + artist: &i.artist, + album: i.album.as_deref(), + duration_secs: i.duration_secs, + path: i.path.as_deref(), + start_ms: i.start_ms, + source_line: i.source_line.as_deref(), + kind: &i.kind, + note: i.note.as_deref(), + }) + .collect(); + match format.as_str() { + "m3u8" => playlist_formats::write_m3u8(&export_items), + "xspf" => { + playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items) + } + "pls" => playlist_formats::write_pls(&export_items), + "csv" => playlist_formats::write_csv(&meta.name, &export_items), + _ => playlist_formats::write_soulstream_json( + &meta.name, + meta.description.as_deref(), + &meta.kind, + &export_items, + ), + } + }; + + #[cfg(mobile)] + { + // No filter and no temp file here: the sheet does the choosing, and + // the ContentResolver's "wt" truncates in place, so there is no torn + // file to move out of the way of. + let _ = filter_name; + let doc = tauri_plugin_storage::create_document( + &app, + &format!("{safe_name}.{ext}"), + mime, + &body, + )?; + // Nothing picked is both fields empty — a dismissed sheet, which is + // this command's None on either platform. The name is what a person + // can find again; the URI is the fallback when the provider is quiet. + Ok(doc.name.or(doc.uri)) + } + #[cfg(desktop)] + { + let _ = mime; + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel::>(); + app.dialog() + .file() + .set_file_name(format!("{safe_name}.{ext}")) + .add_filter(filter_name, &[ext]) + .save_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(dest) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + // Write beside the destination, then move into place — a failed write + // never leaves a torn file under the chosen name. rename on Windows + // replaces an existing destination, so there is no unlink window. + let dest_path = PathBuf::from(&dest); + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let temp = dest_path.with_extension(format!("{ext}.writing")); + std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?; + std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(Some(dest)) + } +} diff --git a/handoff/rounds/ins-r35-kt-args.txt b/handoff/rounds/ins-r35-kt-args.txt new file mode 100644 index 0000000..e58888f --- /dev/null +++ b/handoff/rounds/ins-r35-kt-args.txt @@ -0,0 +1,12 @@ +/** What `createDocument` writes and where it offers to put it — the Rust + * `CreateDocumentArgs`, camelCase. */ +@InvokeArg +class CreateDocumentArgs { + /** the name the sheet opens with, its extension included */ + var name: String? = null + /** the type Android files it under, and picks the sheet's apps by */ + var mime: String? = null + /** the whole file, built in Rust — the WebView never sees it */ + var text: String? = null +} + diff --git a/handoff/rounds/ins-r35-kt-command.txt b/handoff/rounds/ins-r35-kt-command.txt new file mode 100644 index 0000000..77296e5 --- /dev/null +++ b/handoff/rounds/ins-r35-kt-command.txt @@ -0,0 +1,64 @@ + /** A file written wherever the user points it: Android's create-document + * sheet, and the bytes through the ContentResolver — a content URI is + * not a path, so `std::fs` on the Rust side could never have written + * this one. The file arrives whole (the playlist export builds it with + * the desktop's own writers), so nothing here knows a format. */ + @Command + fun createDocument(invoke: Invoke) { + val args = invoke.parseArgs(CreateDocumentArgs::class.java) + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = args.mime ?: "application/octet-stream" + intent.putExtra(Intent.EXTRA_TITLE, args.name ?: "playlist") + startActivityForResult(invoke, intent, "documentCreated") + } + + @ActivityCallback + fun documentCreated(invoke: Invoke, result: ActivityResult) { + when (result.resultCode) { + Activity.RESULT_OK -> { + val uri = result.data?.data + if (uri == null) { invoke.reject("no file came back"); return } + try { + /* The file comes off the SAME invoke, read a second time + rather than parked in a field of ours: PluginHandle's + activity-result closure holds the Invoke, so its + arguments are still here when the sheet comes back, and + no call of this can hand its bytes to another's file. */ + val text = invoke.parseArgs(CreateDocumentArgs::class.java).text ?: "" + /* "wt" truncates: without it, writing a short file over a + longer one leaves the old tail behind the new bytes. */ + val out = activity.contentResolver.openOutputStream(uri, "wt") + if (out == null) { invoke.reject("the file could not be opened for writing"); return } + out.use { o -> o.write(text.toByteArray(Charsets.UTF_8)) } + val res = JSObject() + res.put("uri", uri.toString()) + res.put("name", displayName(uri)) + invoke.resolve(res) + } catch (e: Exception) { + invoke.reject("the file could not be written: " + e.message) + } + } + /* Deliberately NOT a rejection, where pickFolder and pickImage + both reject "cancelled": a dismissed save is `Ok(None)` on the + desktop and the export says nothing at all for it. An error + here would paint a failure over a choice. */ + Activity.RESULT_CANCELED -> invoke.resolve(JSObject()) + else -> invoke.reject("the file could not be created") + } + } + + /** The name the document actually got — the sheet lets it be changed on + * the way past. The URI's last segment when the provider will not say. */ + private fun displayName(uri: Uri): String? { + try { + activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (i >= 0 && c.moveToFirst()) { + val n = c.getString(i) + if (!n.isNullOrEmpty()) return n + } + } + } catch (_: Exception) { /* the name is a courtesy; the bytes are written */ } + return uri.lastPathSegment + } diff --git a/handoff/rounds/ins-r35-mock-expect.txt b/handoff/rounds/ins-r35-mock-expect.txt new file mode 100644 index 0000000..b509a4e --- /dev/null +++ b/handoff/rounds/ins-r35-mock-expect.txt @@ -0,0 +1,12 @@ + /* ── the playlist export ────────────────────────────────────────── + Where the file went is the only thing the command answers, so the + notice naming it IS the feature; a dismissed sheet has to leave the + screen exactly as quiet as it found it; and a refusal has to arrive + as a sentence, because "permission denied (os error 13)" is the one + string Android hands back when the document is out of reach. */ + "export-open": (i) => i.title.startsWith("Late night drive") && i.plrows >= 1 && !i.toast, + "export-sheet": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText), + "export-written": (i) => !i.half.includes("up") && /Exported as .+\.csv/.test(i.notice) && !i.toast, + "export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast, + "export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast), + "export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"), diff --git a/handoff/rounds/ins-r35-mock-steps.txt b/handoff/rounds/ins-r35-mock-steps.txt new file mode 100644 index 0000000..b4a47c1 --- /dev/null +++ b/handoff/rounds/ins-r35-mock-steps.txt @@ -0,0 +1,29 @@ + /* ── the playlist export, three ways ────────────────────────────── + On the provisioned device the block above deliberately put back, and + on the pristine fixture its reload restored. The file is built in + Rust by the desktop's own writers and written by whichever door the + platform has, so the only thing that comes back — and the only thing + the phone can show — is where it went. */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, + { do: `__click('.segtabs button', 'Playlists')`, wait: 800 }, + { do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Export')`, name: "export-sheet", wait: 800 }, + { do: `__click('.halfsheet .row', 'CSV')`, name: "export-written", wait: 1000 }, + /* a sheet dismissed: the command answers null and the phone says + nothing, because nothing happened. The notice from the step above is + cleared by hand rather than waited out — note() lives 3.2 s, longer + than any wait here, and a wait that had to outlast it would be the + fragile kind. */ + { do: `__ssMock.answer('playlist_export', null); __ss.notice.text = ''; true`, wait: 300 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Export')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'M3U8')`, name: "export-cancelled", wait: 1000 }, + /* and a refusal — what Android answers when the document is somewhere + the app cannot write — read back as a sentence rather than as Rust */ + { do: `__ssMock.forget('playlist_export'); __ssMock.fail('playlist_export', 'permission denied (os error 13)'); true`, wait: 300 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Export')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 }, + { do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 }, + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 }, diff --git a/handoff/rounds/ins-r35-mock.txt b/handoff/rounds/ins-r35-mock.txt new file mode 100644 index 0000000..84e66d7 --- /dev/null +++ b/handoff/rounds/ins-r35-mock.txt @@ -0,0 +1,16 @@ + /* Export. The whole file is built in Rust and written by whichever door + the platform has — the desktop's save dialog, the phone's + create-document sheet — so all the command ever answers is where it + went: a path there, the document's name here. The sanitiser is the + Rust one, character for character (and written as a code-point test + rather than a range, so nothing in this file is a control byte). A + dismissed sheet (null) and a refusal are armed from the walk with + `__ssMock.answer` / `.fail`, which `weather()` answers first. */ + if (cmd === "playlist_export") { + const p = plOf(args); + if (!p) return Promise.reject(new Error("Playlist not found")); + const fmt = String(args?.format ?? ""); + if (!["m3u8", "xspf", "pls", "csv", "json"].includes(fmt)) return Promise.reject(new Error("Unknown export format " + fmt)); + const safe = [...String(p.name)].map((c) => (/[<>:"\/\\|?*]/.test(c) || c.codePointAt(0) < 0x20 ? "_" : c)).join(""); + return new Promise((r) => setTimeout(() => r(safe + "." + fmt), 120)); + } diff --git a/handoff/rounds/ins-r35-phone-expect.txt b/handoff/rounds/ins-r35-phone-expect.txt new file mode 100644 index 0000000..d3b0bcc --- /dev/null +++ b/handoff/rounds/ins-r35-phone-expect.txt @@ -0,0 +1,10 @@ + /* the export door on the real core. The sheet the second step opens is + Android's own, so nothing here asserts that it is UP: the core can + build a ten-row M3U8 and hand off before the probe lands, and a step + that demanded the picker would be the one that failed for being too + fast. What is asserted is the app's own half sheet closing, nothing + going red either side of the system sheet, and — after BACK — no + notice, because a dismissed sheet wrote nothing to name. */ + "export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText), + "export-picker": (i) => !i.half.includes("up") && !i.toast, + "export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice, diff --git a/handoff/rounds/ins-r35-phone-steps.txt b/handoff/rounds/ins-r35-phone-steps.txt new file mode 100644 index 0000000..bbe1c3a --- /dev/null +++ b/handoff/rounds/ins-r35-phone-steps.txt @@ -0,0 +1,10 @@ + /* export, on the real core: the file is built by the desktop's own + writers and handed to Android's create-document sheet. That sheet is + a system activity the walk cannot tap, so BACK dismisses it — the + cancel path is what gets walked, and NOTHING is written to this + phone's storage. The playlist is the "Walk test" one the walk itself + makes, and deletes three steps below. */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 }, + { do: `__click('.halfsheet .row', 'Export')`, name: "export-door", wait: 800 }, + { do: `__click('.halfsheet .row', 'M3U8')`, name: "export-picker", wait: 2600 }, + { adb: ["shell", "input", "keyevent", "4"], name: "export-dismissed", wait: 2000 }, diff --git a/handoff/rounds/ins-r35-sheet.txt b/handoff/rounds/ins-r35-sheet.txt new file mode 100644 index 0000000..5eb0c6a --- /dev/null +++ b/handoff/rounds/ins-r35-sheet.txt @@ -0,0 +1,9 @@ + {:else if kind === "plexport" && shown?.arg?.playlist} + {@const p = shown.arg.playlist} +
Export · {p.name}
+
+ {#each EXPORTS as [format, label, m] (format)} + {@render row("export", label, m, act(() => exportPlaylist(p, format).catch(oops)))} + {/each} +
+
The file goes wherever the sheet points it — Downloads, Drive, anywhere the phone offers. A row with no file of its own keeps its name: M3U8 leaves a comment where the location would be, XSPF, CSV and the backup carry the row without one, and PLS holds only real files — and the locations that are there are paths on this phone.
diff --git a/handoff/rounds/ins-r35-storage-rs.txt b/handoff/rounds/ins-r35-storage-rs.txt new file mode 100644 index 0000000..f9cfcdc --- /dev/null +++ b/handoff/rounds/ins-r35-storage-rs.txt @@ -0,0 +1,60 @@ +/// What Android's create-document sheet answered: where the file went, and +/// the name it actually got (the sheet lets the user change it on the way +/// past). Both absent when the sheet was dismissed — a cancel is not an +/// error here, because the export it serves answers a cancel with None. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CreatedDocument { + pub uri: Option, + pub name: Option, +} + +/// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it. +/// Borrowed: the file can be long and nothing here keeps a copy. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog +struct CreateDocumentArgs<'a> { + name: &'a str, + mime: &'a str, + text: &'a str, +} + +/// A text file written wherever the user points: Android's create-document +/// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes +/// through the ContentResolver. That last part is the whole reason this +/// exists — the sheet answers with a `content://` URI, and `std::fs` has +/// never been able to open one, which is why the playlist export had no +/// door on the phone at all. +/// +/// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file +/// is built where the desktop builds it and the bytes never pass through +/// the WebView. Nothing is added to the ACL, and there is no JS wrapper to +/// keep in step with. +/// +/// A dismissed sheet is not an error — it answers with both fields empty. +pub fn create_document( + app: &tauri::AppHandle, + name: &str, + mime: &str, + text: &str, +) -> Result { + #[cfg(mobile)] + { + use tauri::Manager; + let storage = app.state::>(); + storage + .0 + .run_mobile_plugin::( + "createDocument", + CreateDocumentArgs { name, mime, text }, + ) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = (app, name, mime, text); + Err("the create-document sheet is the phone's; here a save dialog picks the path".into()) + } +} + diff --git a/handoff/rounds/ins-r35-store.txt b/handoff/rounds/ins-r35-store.txt new file mode 100644 index 0000000..df95eec --- /dev/null +++ b/handoff/rounds/ins-r35-store.txt @@ -0,0 +1,23 @@ +/* ── export: the desktop's five profiles, in the desktop's order and its + own words. The file itself is built in Rust by the writers the desktop + uses (playlist_formats), so nothing here knows what an M3U8 looks like — + two exports that drift are worse than one that is missing. What comes + back is only where it went: a path on the desktop, the document's name + on the phone, and null when the sheet was dismissed. ── */ +export const EXPORTS = [ + ["m3u8", "M3U8", "one line a track — most players read it"], + ["xspf", "XSPF", "an open format, titles and times carried"], + ["pls", "PLS", "older players want this one"], + ["csv", "CSV", "a row a track, for a spreadsheet"], + ["json", "Backup (JSON)", "everything about it, this app's own"], +]; +/** Export `p` as `format` — the system's own sheet says where it goes. + * False, and nothing said, when that sheet was dismissed: nothing + * happened, so there is nothing to report. */ +export async function exportPlaylist(p, format) { + const where = await api.playlistExport(p.id, format); + if (!where) return false; + note(`Exported as ${basename(where)}`); + return true; +} + diff --git a/handoff/rounds/ins-r36-doc-storage.txt b/handoff/rounds/ins-r36-doc-storage.txt new file mode 100644 index 0000000..b22bf86 --- /dev/null +++ b/handoff/rounds/ins-r36-doc-storage.txt @@ -0,0 +1,20 @@ +**A file** from the phone is the same plugin's `pick_file`, beside +`pick_folder` and `pick_image`: Android's document picker with the caller's +own MIME filter, answered with `{ path, name, size, copied }`. It copies as +little as it can, because an image is small and an audio file is not. A +document already on this phone's storage hands its own path over as it lies +— the DOCUMENT sibling of the tree id the folder picker resolves, plus the +Downloads provider's `raw:` — so picking a 300 MB file costs nothing and +touches no cache. One that has no path (a cloud provider's, a document a +provider only streams) is copied into `cacheDir/picked` under its own +display name, and only as far as the caller's `maxCopyBytes` allows: zero +refuses the copy outright, which is what an audio-sized pick wants, and the +copy counts itself as it goes because a provider's stated size is often +absent and sometimes wrong. Those copies are swept at the start of every +pick — unlike `pick_image`'s, which live in the cache for ever — so a path +from one pick is gone by the next. The display name travels beside the path +because it is load-bearing: `playlist_formats::detect_format` reads the +extension off it and `parse` titles the draft from its stem, so a copy under +an invented name would read every playlist as plain text. The dialog plugin +cannot serve here: `tauri-plugin-dialog` does open the sheet from Rust, but +it answers with a `content://` URI, and `std::fs` cannot take one. diff --git a/handoff/rounds/ins-r36-halfsheet-door.txt b/handoff/rounds/ins-r36-halfsheet-door.txt new file mode 100644 index 0000000..c8f5326 --- /dev/null +++ b/handoff/rounds/ins-r36-halfsheet-door.txt @@ -0,0 +1,12 @@ + /* the playlist-file door picks FIRST too: the review only opens for a + file that was chosen, and a document this phone can hand no path for + is refused with the reason rather than half-read */ + async function fileDoor() { + closeHalf(); + try { + const picked = await pickImportFile(); + if (!picked) return; + push("import", {}); + void startFile(picked.path, picked.name); + } catch (e) { oops(e); } + } diff --git a/handoff/rounds/ins-r36-importer-pick.txt b/handoff/rounds/ins-r36-importer-pick.txt new file mode 100644 index 0000000..0494410 --- /dev/null +++ b/handoff/rounds/ins-r36-importer-pick.txt @@ -0,0 +1,20 @@ +/** The extensions the backend's parser knows; anything else is refused here + * rather than read as "pasted text" with a nonsense title. */ +const PLAYLIST_EXT = /\.(m3u8?|pls|xspf|csv|json|txt)$/i; +/** The playlist-FILE door picks FIRST, like the folder door — the phone's + * own document picker (the storage plugin's `pick_file`). Every type is + * offered on purpose: Android's providers report m3u and pls under half a + * dozen types and often as plain bytes, so a filter that names one greys + * out the very file being looked for; the extension is checked here + * instead. A document with no path on this phone is copied into the app's + * cache under its own name, up to the size the parser itself refuses. + * Returns { path, name } — or null when the picker was dismissed. */ +export async function pickImportFile() { + let picked; + try { picked = await api.pickFile("*/*", 8 * 1024 * 1024); } + catch (e) { if (/cancelled/.test(String(e))) return null; throw e; } + if (!picked?.path) throw new Error("That file isn't on this phone's storage — copy it here first, then import it."); + const name = picked.name || ""; + if (!PLAYLIST_EXT.test(name)) throw new Error("“" + (name || "That file") + "” isn't a playlist file — M3U, M3U8, PLS, XSPF, CSV, JSON or TXT."); + return { path: picked.path, name }; +} diff --git a/handoff/rounds/ins-r36-importer-start.txt b/handoff/rounds/ins-r36-importer-start.txt new file mode 100644 index 0000000..947baab --- /dev/null +++ b/handoff/rounds/ins-r36-importer-start.txt @@ -0,0 +1,22 @@ +/** Stage a playlist FILE. The name travels beside the path rather than + * being read off it: the picked document's own name is what chooses the + * parser and titles the draft, and a document the phone had no path for + * arrives as a cache copy. Rows naming a file by a relative path resolve + * against the file's own folder; a copied file's rows fall back to the + * library's basename index, so they still find what this phone holds. A + * playlist file does produce stream rows, so the catalog pass runs. */ +export async function startFile(path, name) { + const g = ++gen; + Object.assign(importer, { door: "file", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false }); + try { + const draft = await api.playlistImportProbe(path, name); + if (g !== gen) return; + importer.busy = false; + if (!draft) { importer.error = "Nothing came back from that file."; return; } + importer.draft = draft; + importer.name = draft.name ?? ""; + if (draft.entries?.length) queueMicrotask(() => void verifyStreams(g)); + } catch (e) { + if (g === gen) { importer.error = String(e); importer.busy = false; } + } +} diff --git a/handoff/rounds/ins-r36-kt-body.txt b/handoff/rounds/ins-r36-kt-body.txt new file mode 100644 index 0000000..fc8bef9 --- /dev/null +++ b/handoff/rounds/ins-r36-kt-body.txt @@ -0,0 +1,169 @@ + /** A FILE from the phone: the document picker with the caller's own + * filter. A document that is already on this phone's storage is handed + * over as it lies — nothing is copied, so a 300 MB pick costs nothing + * — and one that has no path of its own is copied into cacheDir/picked + * under its own display name, as far as `maxCopyBytes` allows and no + * further. Zero refuses the copy outright. + * + * The copies are swept at the start of every pick: pickImage's own + * copies live in the cache for ever, and an audio file is not an + * image. So a path handed out by one pick is gone by the next — read + * it in the same breath. + */ + @Command + fun pickFile(invoke: Invoke) { + val args = invoke.parseArgs(PickFileArgs::class.java) + sweepPicked() + val types = (args.mimeTypes ?: "").split(',').map { it.trim() }.filter { it.isNotEmpty() } + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + if (types.size == 1) { + intent.type = types[0] + } else { + intent.type = "*/*" + if (types.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray()) + } + startActivityForResult(invoke, intent, "filePicked") + } + + @ActivityCallback + fun filePicked(invoke: Invoke, result: ActivityResult) { + when (result.resultCode) { + Activity.RESULT_OK -> { + val uri = result.data?.data + if (uri == null) { invoke.reject("no file came back"); return } + val info = openableInfo(uri) + val name = info.first ?: "picked" + val stated = info.second + val out = JSObject() + out.put("name", name) + /* the document's own path first, and nothing copied: a row + naming a file beside it still resolves against the real + folder, and the size stops mattering */ + val here = documentPath(uri) + if (here != null) { + out.put("path", here) + out.put("copied", false) + if (stated != null) out.put("size", stated) + invoke.resolve(out) + return + } + /* the same Invoke the pick was made with — PluginHandle + holds it in the result closure — so the cap is read back + off the caller's own arguments rather than stashed in a + field a recreated activity would have reset to zero */ + val cap = invoke.parseArgs(PickFileArgs::class.java).maxCopyBytes.toLong() + if (cap <= 0L) { invoke.reject("that file is not on this phone's storage"); return } + if (stated != null && stated > cap) { + invoke.reject("that file is " + (stated / (1024 * 1024)) + " MB — too large to copy here") + return + } + try { + val copy = copyInto(uri, name, cap) + if (copy == null) { invoke.reject("that file could not be read"); return } + out.put("path", copy.absolutePath) + out.put("copied", true) + out.put("size", copy.length()) + invoke.resolve(out) + } catch (e: TooBig) { + invoke.reject(e.message ?: "that file is too large to copy here") + } catch (e: Exception) { + invoke.reject("that file could not be read: " + e.message) + } + } + Activity.RESULT_CANCELED -> invoke.reject("cancelled") + else -> invoke.reject("the picker failed") + } + } + + /** DISPLAY_NAME and SIZE off the provider. The name is load-bearing: + * the Rust parser picks the format off the extension and titles the + * draft from the stem, so a copy under an invented name would read + * every playlist as plain text. SIZE is often absent, and absent is + * not zero — the copy counts for itself. */ + private fun openableInfo(uri: Uri): Pair { + var name: String? = null + var size: Long? = null + try { + activity.contentResolver.query(uri, null, null, null, null)?.use { c -> + if (c.moveToFirst()) { + val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (ni >= 0 && !c.isNull(ni)) name = c.getString(ni) + val si = c.getColumnIndex(OpenableColumns.SIZE) + if (si >= 0 && !c.isNull(si)) size = c.getLong(si) + } + } + } catch (_: Exception) { /* a provider that answers nothing still has a document */ } + return Pair(name, size) + } + + /** The document's own path, when this phone has one for it: the + * Downloads provider's "raw:/storage/…", and the external-storage + * provider's "primary:Music/set.m3u" — the DOCUMENT sibling of the + * tree id pathOf resolves. Null when it names no path, and null when + * this app may not read the one it names (all-files access is not + * always granted, and a path Rust cannot open is worse than none). */ + private fun documentPath(uri: Uri): String? { + val id = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { return null } + val path = if (id.startsWith("raw:")) { + id.substring(4) + } else if (uri.authority == "com.android.externalstorage.documents") { + val i = id.indexOf(':') + if (i < 0) return null + val volume = id.substring(0, i) + val rel = id.substring(i + 1).trimEnd('/') + val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume" + if (rel.isEmpty()) root else "$root/$rel" + } else { + return null + } + return if (java.io.File(path).canRead()) path else null + } + + private fun pickedDir(): java.io.File { + val dir = java.io.File(activity.cacheDir, "picked") + if (!dir.isDirectory) dir.mkdirs() + return dir + } + + /** The last pick's copy, gone. A playlist is kilobytes and a DJ set is + * not, and nothing here is worth keeping once the caller has read it. */ + private fun sweepPicked() { + try { pickedDir().listFiles()?.forEach { it.delete() } } catch (_: Exception) { /* the copy is a courtesy */ } + } + + /** The picked document, copied under its own name so the extension + * survives, counted as it goes so a provider that understated its size + * cannot fill the cache; a partial copy is deleted. */ + private fun copyInto(uri: Uri, name: String, cap: Long): java.io.File? { + val target = java.io.File(pickedDir(), safeName(name)) + val input = activity.contentResolver.openInputStream(uri) ?: return null + var total = 0L + try { + input.use { i -> + target.outputStream().use { o -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = i.read(buf) + if (n < 0) break + total += n.toLong() + if (total > cap) throw TooBig("that file is larger than " + (cap / (1024 * 1024)) + " MB") + o.write(buf, 0, n) + } + } + } + } catch (e: Exception) { + target.delete() + throw e + } + return target + } + + /** A name the cache can hold: the document's own, minus anything that + * could walk out of the folder. What the CALLER parses by is the + * display name, which travels beside the path untouched. */ + private fun safeName(name: String): String { + val base = name.substringAfterLast('/').substringAfterLast('\\').trim() + val clean = base.replace(Regex("[^A-Za-z0-9 ._()\\[\\]+&,'-]"), "_") + return if (clean.isEmpty() || clean == "." || clean == "..") "picked" else clean.take(120) + } diff --git a/handoff/rounds/ins-r36-kt-top.txt b/handoff/rounds/ins-r36-kt-top.txt new file mode 100644 index 0000000..55fa542 --- /dev/null +++ b/handoff/rounds/ins-r36-kt-top.txt @@ -0,0 +1,16 @@ +/** What a pick may offer and what it may copy. The filter is the document + * sheet's own, comma-separated ("audio/mpeg,audio/flac"); blank offers + * every type. `maxCopyBytes` is how big a document may be COPIED into the + * app's cache when it has no path of its own on this phone — 0 refuses the + * copy, which is what an audio-sized pick wants. Both are plain scalars on + * purpose: String / Int / Boolean is what the other plugins' args prove. */ +@InvokeArg +class PickFileArgs { + var mimeTypes: String? = null + var maxCopyBytes: Int = 0 +} + +/** The copy walked past what the caller allowed. Its own exception, so the + * partial file can be deleted and the caller told in megabytes rather than + * in "could not be read". */ +private class TooBig(message: String) : Exception(message) diff --git a/handoff/rounds/ins-r36-mock-expect.txt b/handoff/rounds/ins-r36-mock-expect.txt new file mode 100644 index 0000000..349e695 --- /dev/null +++ b/handoff/rounds/ins-r36-mock-expect.txt @@ -0,0 +1,12 @@ + "import-file-sheet": (i) => i.half.includes("up") && /Playlist file/.test(i.halfText), + /* impSrc is the round's own assertion, and the "#" is the whole of it: + the cache copy on disk is "Warehouse set _3.m3u8", so a draft read + off the PATH says that and a draft read off the picked document's + display name says this. Both this and the title below go wrong the + moment the name stops travelling beside the path. */ + "import-file-review": (i) => i.title.startsWith("Import") && /M3U/.test(i.impFmt) && i.impSrc === "Warehouse set #3.m3u8" + && i.imp === 3 && /1 in library/.test(i.impCounts) && /Create playlist \(3 tracks\)/.test(i.impBtn), + "import-file-created": (i) => i.title.startsWith("Warehouse set #3") && i.plrows === 3, + "import-file-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, + "import-file-refused": (i) => !i.half.includes("up") && /not on this phone/.test(i.toast), + "import-file-cleared": (i) => !i.toast, diff --git a/handoff/rounds/ins-r36-mock-probe.txt b/handoff/rounds/ins-r36-mock-probe.txt new file mode 100644 index 0000000..916a465 --- /dev/null +++ b/handoff/rounds/ins-r36-mock-probe.txt @@ -0,0 +1,15 @@ + if (cmd === "playlist_import_probe") { + const path = String(args?.path || ""); + if (!path) return Promise.resolve(null); /* the desktop's dialog, dismissed */ + /* the NAME decides the format, the way playlist_formats::detect_format + does — the extension first, the stem as the draft's title. The path + is only where the bytes were. */ + const name = String(args?.name || path.split(/[\\/]/).filter(Boolean).pop() || "Playlist"); + const ext = (name.split(".").pop() || "").toLowerCase(); + const format = ext === "m3u" || ext === "m3u8" ? "m3u" : ext === "pls" ? "pls" : ext === "xspf" ? "xspf" + : ext === "csv" ? "csv" : ext === "json" ? "soulstream" : "text"; + /* the paste door's own three lines, so the verdicts are already + proven: one in the library, one stream, one unresolved */ + const entries = impParse("Daft Punk - One More Time\nAphex Twin - Track 1\nID - ID"); + return new Promise((r) => setTimeout(() => r(impDraft(entries, name, format, name.replace(/\.[^.]*$/, ""))), 500)); + } diff --git a/handoff/rounds/ins-r36-mock-steps.txt b/handoff/rounds/ins-r36-mock-steps.txt new file mode 100644 index 0000000..7071737 --- /dev/null +++ b/handoff/rounds/ins-r36-mock-steps.txt @@ -0,0 +1,18 @@ + /* the playlist-FILE door, the fifth: the phone's own document picker, + and the picked document's NAME deciding both the format and the + draft's title — then the two answers a picker can give that are not a + file, armed through the mock's fail door */ + { do: `__click('.newpl', 'Import')`, name: "import-file-sheet", wait: 700 }, + { do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-review", wait: 2200 }, + { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-file-created", wait: 1500 }, + { do: `history.back()`, wait: 900 }, + { do: `__ssMock.fail('plugin:storage|pick_file', 'cancelled')`, wait: 200 }, + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-cancelled", wait: 1400 }, + { do: `__ssMock.fail('plugin:storage|pick_file', 'that file is not on this phone')`, wait: 200 }, + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-refused", wait: 1400 }, + /* disarmed and the red dismissed, or every step after this one inherits + both — the toast stays until it is tapped */ + { do: `__ssMock.fail('plugin:storage|pick_file', '')`, wait: 200 }, + { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, name: "import-file-cleared", wait: 600 }, diff --git a/handoff/rounds/ins-r36-perm-command.txt b/handoff/rounds/ins-r36-perm-command.txt new file mode 100644 index 0000000..e26a7bd --- /dev/null +++ b/handoff/rounds/ins-r36-perm-command.txt @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-pick-file" +description = "Enables the pick_file command without any pre-configured scope." +commands.allow = ["pick_file"] + +[[permission]] +identifier = "deny-pick-file" +description = "Denies the pick_file command without any pre-configured scope." +commands.deny = ["pick_file"] diff --git a/handoff/rounds/ins-r36-perm-reference.txt b/handoff/rounds/ins-r36-perm-reference.txt new file mode 100644 index 0000000..3f00bcd --- /dev/null +++ b/handoff/rounds/ins-r36-perm-reference.txt @@ -0,0 +1,155 @@ +## Default Permission + +The phone's storage: pick a library folder (a Storage Access Framework tree, +resolved to a path when the volume allows), pick a photo or a file (the +document picker, answered with a path — the document's own where this phone +has one, else a bounded copy in the app's cache), and Android's all-files +access — whether it is granted, and the system screen that grants it. + +#### This default permission set includes the following: + +- `allow-pick-folder` +- `allow-all-files-access` +- `allow-request-all-files-access` +- `allow-pick-image` +- `allow-pick-file` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`storage:allow-all-files-access` + + + +Enables the all_files_access command without any pre-configured scope. + +
+ +`storage:deny-all-files-access` + + + +Denies the all_files_access command without any pre-configured scope. + +
+ +`storage:allow-pick-file` + + + +Enables the pick_file command without any pre-configured scope. + +
+ +`storage:deny-pick-file` + + + +Denies the pick_file command without any pre-configured scope. + +
+ +`storage:allow-pick-folder` + + + +Enables the pick_folder command without any pre-configured scope. + +
+ +`storage:deny-pick-folder` + + + +Denies the pick_folder command without any pre-configured scope. + +
+ +`storage:allow-pick-image` + + + +Enables the pick_image command without any pre-configured scope. + +
+ +`storage:deny-pick-image` + + + +Denies the pick_image command without any pre-configured scope. + +
+ +`storage:allow-request-all-files-access` + + + +Enables the request_all_files_access command without any pre-configured scope. + +
+ +`storage:deny-request-all-files-access` + + + +Denies the request_all_files_access command without any pre-configured scope. + +
diff --git a/handoff/rounds/ins-r36-perm-schema.txt b/handoff/rounds/ins-r36-perm-schema.txt new file mode 100644 index 0000000..deca66f --- /dev/null +++ b/handoff/rounds/ins-r36-perm-schema.txt @@ -0,0 +1,366 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the all_files_access command without any pre-configured scope.", + "type": "string", + "const": "allow-all-files-access", + "markdownDescription": "Enables the all_files_access command without any pre-configured scope." + }, + { + "description": "Denies the all_files_access command without any pre-configured scope.", + "type": "string", + "const": "deny-all-files-access", + "markdownDescription": "Denies the all_files_access command without any pre-configured scope." + }, + { + "description": "Enables the pick_file command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-file", + "markdownDescription": "Enables the pick_file command without any pre-configured scope." + }, + { + "description": "Denies the pick_file command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-file", + "markdownDescription": "Denies the pick_file command without any pre-configured scope." + }, + { + "description": "Enables the pick_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-folder", + "markdownDescription": "Enables the pick_folder command without any pre-configured scope." + }, + { + "description": "Denies the pick_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-folder", + "markdownDescription": "Denies the pick_folder command without any pre-configured scope." + }, + { + "description": "Enables the pick_image command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-image", + "markdownDescription": "Enables the pick_image command without any pre-configured scope." + }, + { + "description": "Denies the pick_image command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-image", + "markdownDescription": "Denies the pick_image command without any pre-configured scope." + }, + { + "description": "Enables the request_all_files_access command without any pre-configured scope.", + "type": "string", + "const": "allow-request-all-files-access", + "markdownDescription": "Enables the request_all_files_access command without any pre-configured scope." + }, + { + "description": "Denies the request_all_files_access command without any pre-configured scope.", + "type": "string", + "const": "deny-request-all-files-access", + "markdownDescription": "Denies the request_all_files_access command without any pre-configured scope." + }, + { + "description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`", + "type": "string", + "const": "default", + "markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`" + } + ] + } + } +} \ No newline at end of file diff --git a/handoff/rounds/ins-r36-phone-expect.txt b/handoff/rounds/ins-r36-phone-expect.txt new file mode 100644 index 0000000..b05b470 --- /dev/null +++ b/handoff/rounds/ins-r36-phone-expect.txt @@ -0,0 +1,2 @@ + "import-file-picker": (i) => !i.half.includes("up") && !i.toast, + "import-file-dismissed": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, diff --git a/handoff/rounds/ins-r36-phone-steps.txt b/handoff/rounds/ins-r36-phone-steps.txt new file mode 100644 index 0000000..2939ab3 --- /dev/null +++ b/handoff/rounds/ins-r36-phone-steps.txt @@ -0,0 +1,5 @@ + /* the file door's picker, the same way: opened, dismissed with BACK. + Nothing is read, nothing is copied into the cache, nothing is made */ + { do: `__click('.newpl', 'Import')`, wait: 700 }, + { do: `__click('.halfsheet .row', 'Playlist file')`, name: "import-file-picker", wait: 2600 }, + { adb: ["shell", "input", "keyevent", "4"], name: "import-file-dismissed", wait: 2000 }, diff --git a/handoff/rounds/ins-r36-plugin-cmd.txt b/handoff/rounds/ins-r36-plugin-cmd.txt new file mode 100644 index 0000000..7e44d6c --- /dev/null +++ b/handoff/rounds/ins-r36-plugin-cmd.txt @@ -0,0 +1,33 @@ +/// A FILE from the phone: the document picker with the caller's own MIME +/// filter, answered with a path the Rust side can open. A document already +/// on this phone's storage is handed over as it lies; one that has no path +/// is copied into the app's cache under its own name, and only as far as +/// `max_copy_bytes` allows — zero refuses the copy, which is what a pick +/// that could be hundreds of megabytes wants. +#[tauri::command] +async fn pick_file( + app: tauri::AppHandle, + mime_types: Option, + max_copy_bytes: Option, +) -> Result { + #[cfg(mobile)] + { + use tauri::Manager; + let storage = app.state::>(); + storage + .0 + .run_mobile_plugin::( + "pickFile", + PickFileArgs { + mime_types, + max_copy_bytes: max_copy_bytes.unwrap_or(0), + }, + ) + .map_err(|e| e.to_string()) + } + #[cfg(not(mobile))] + { + let _ = (app, mime_types, max_copy_bytes); + Err("the file picker here is the desktop's dialog".into()) + } +} diff --git a/handoff/rounds/ins-r36-plugin-doc.txt b/handoff/rounds/ins-r36-plugin-doc.txt new file mode 100644 index 0000000..0899ec0 --- /dev/null +++ b/handoff/rounds/ins-r36-plugin-doc.txt @@ -0,0 +1,20 @@ +//! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree +//! is not on a volume with a path), `all_files_access` → `{ granted }`, +//! `request_all_files_access`, which opens the system screen, `pick_image` +//! → `{ path }` (a photo, copied into the app's cache), and `pick_file` → +//! `{ path, name, size, copied }` — the document picker with the caller's +//! own MIME filter. +//! +//! `pick_file` copies as little as it can, because an image is small and an +//! audio file is not. A document already on this phone's storage hands its +//! own path over as it lies, so picking a 300 MB file costs nothing; one +//! with no path of its own is copied into `cacheDir/picked` under its own +//! display name, and only as far as `maxCopyBytes` allows — zero refuses +//! the copy outright. Those copies are swept at the start of every pick, so +//! a path from one pick is gone by the next: read it in the same breath. +//! The display name travels beside the path because it is load-bearing — +//! the playlist parser picks its format off the extension and titles the +//! draft from the stem. +//! +//! On the desktop the plugin is registered so the permission set resolves, +//! and every command answers "not here". diff --git a/handoff/rounds/ins-r36-plugin-types.txt b/handoff/rounds/ins-r36-plugin-types.txt new file mode 100644 index 0000000..97b6ae9 --- /dev/null +++ b/handoff/rounds/ins-r36-plugin-types.txt @@ -0,0 +1,25 @@ +/// What a pick offers and what it may copy. `mime_types` is the document +/// sheet's own filter, comma-separated; empty offers every type. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PickFileArgs { + pub mime_types: Option, + /// How big a document may be COPIED into the app's cache when it has no + /// path of its own on this phone. Zero refuses the copy. + pub max_copy_bytes: u32, +} + +/// A picked document. `path` is the document's own when this phone has one +/// for it, else the cache copy's (`copied`); `name` is the display name the +/// provider gave it, which is what decides a playlist's format and title. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PickedFile { + pub path: Option, + #[serde(default)] + pub name: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub copied: bool, +} diff --git a/handoff/rounds/ins-r36-probe-head.txt b/handoff/rounds/ins-r36-probe-head.txt new file mode 100644 index 0000000..fa431fd --- /dev/null +++ b/handoff/rounds/ins-r36-probe-head.txt @@ -0,0 +1,54 @@ +/// Open a playlist file and stage it for review. Parsing and resolution only — +/// nothing is written until `playlist_import_commit`. +/// +/// The phone has no dialog here, so it picks the file itself (the storage +/// plugin's document picker) and hands the path in — with the picked +/// document's own `name` beside it, because a document that had no path of +/// its own arrives as a copy in the app's cache, and the NAME is what +/// `detect_format` reads the extension off and what titles the draft. The +/// desktop passes neither and opens its own dialog. +#[tauri::command] +pub async fn playlist_import_probe( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + path: Option, + name: Option, +) -> Result, String> { + let picked = match path { + Some(p) if !p.trim().is_empty() => Some(p), + _ => { + let (tx, rx) = tokio::sync::oneshot::channel::>(); + #[cfg(desktop)] + { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .file() + .add_filter( + "Playlist", + &["m3u", "m3u8", "pls", "xspf", "csv", "json", "txt"], + ) + .pick_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + } + #[cfg(mobile)] + { + let _ = &app; + let _ = tx.send(None); + } + rx.await.unwrap_or(None) + } + }; + let Some(source) = picked else { + return Ok(None); + }; + let source_path = PathBuf::from(&source); + // The picked name wins over the path's own: a cache copy is named after + // the document, but only the document's name is guaranteed to be it. + let name = match name { + Some(n) if !n.trim().is_empty() => n, + _ => source_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| source.clone()), + }; diff --git a/handoff/rounds/ins-r37-css.txt b/handoff/rounds/ins-r37-css.txt new file mode 100644 index 0000000..06b59d7 --- /dev/null +++ b/handoff/rounds/ins-r37-css.txt @@ -0,0 +1,23 @@ +/* ── dragged into place: the playlist's rows and the queue's ─────────── + Almost nothing, because both lists are already the app's own rows. No + row carries `touch-action: none` — a flick that starts on one still + scrolls the list; the gesture takes the scroll away for itself only + after the 320ms lift, and in JS (src/mobile/reorder.js). The spring is + the app's own spatial one, and the global reduce-motion rule above + flattens it for nothing. */ +.tracks.dragging .trk, +.q-face.dragging .row { + transition: background var(--m-fast), transform var(--m-base); +} +/* the row in hand: off the spring, above its neighbours, lifted off the page */ +.tracks.dragging .trk.lifted, +.q-face.dragging .row.lifted { + transition: none; + position: relative; + z-index: 5; + background: var(--hover); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); +} +/* why the rows will not move while the filter has something in it */ +.pl-note { padding: 2px var(--pad) 8px; font-size: 12px; color: var(--text-muted); } diff --git a/handoff/rounds/ins-r37-doc-drag.txt b/handoff/rounds/ins-r37-doc-drag.txt new file mode 100644 index 0000000..775e1b2 --- /dev/null +++ b/handoff/rounds/ins-r37-doc-drag.txt @@ -0,0 +1,41 @@ +- **Drag to reorder** (`src/mobile/reorder.js` — one Svelte action, on the + container, for the playlist page's rows and the player sheet's queue + alike). Press and hold a row for 320ms and it lifts (with a short tap in + the phone's motor), the rows below make room, and letting go lands it. + Release without having moved and the row's sheet opens exactly as a long + press always opened it — which matters most on the queue, where a row has + no ⋯ and `oncontextmenu` is the only door to Play now, Move to next and + Remove from queue. A press that moves more than 8px before the hold lands + is a scroll, and the gesture lets go of it. + Nothing carries `touch-action: none`: a flick that starts on a row still + scrolls the list. `touch-action` is latched at touchstart and cannot be + flipped mid-gesture, so the page's own scroll is refused instead by a + non-passive `touchmove` listener added at the lift — which works because + the finger has been still for 320ms and the browser has not started a + scroll yet. A click and a contextmenu guard sit on the container in the + CAPTURE phase while a lift is live (a released drag would otherwise fire + the row's own click and play the track, and Android's own 500ms long + press would open the sheet in the middle of the drag); both are off when + no lift is live, so a tap and a long press behave as they always have. + Every row is measured once at the lift, in the scroller's CONTENT space, + so the edge pull — within 64px of either edge, ramped, up to 14px a + frame — leaves the snapshot true and the row keeps up with the finger. + Each displaced row moves by the exact difference between its own top and + its neighbour's rather than by one assumed row height (the desktop's + `rowH`), because the queue face puts an album separator between rows. + The drop reuses the mutations the Move rows already use: `moveItem` and + `moveInQueue` each take a numeric target now as well as `"up"` / + `"down"` / `"top"`, so there is still exactly one call to + `playlist_set_order` with the whole id order. It is optimistic — the rows + go where you put them at once, and come back with the error toast if the + backend says no. Two refusals guard the middle of a gesture: the playlist + drops nothing if a refetch has replaced its rows (`pls.dragging` also + defers the page's own re-read while a drag is live, the phone's version + of the desktop's `skippedWhileDrag`), and the queue drops nothing if + `player.qi` has moved, because a track boundary shifts the whole rendered + window and the absolute indices would then address the wrong rows. + Dragging is off while the playlist's filter has anything in it — a + filtered list's visible order is not the stored order — and the note + under the field says so; the sheet's Move rows address rows by id and + stay correct under a filter, which is why they are the way to reorder + without clearing it. diff --git a/handoff/rounds/ins-r37-helpers.txt b/handoff/rounds/ins-r37-helpers.txt new file mode 100644 index 0000000..66e89e0 --- /dev/null +++ b/handoff/rounds/ins-r37-helpers.txt @@ -0,0 +1,40 @@ +/* a drag, as the events a finger sends: press, hold past the action's own + 320ms, walk down (or up) to just past the target row's middle, let go. + Both rows are measured BEFORE the press, because the action takes its + own snapshot at the lift and that is the geometry it will compare + against. Returns false when the list is too short to drag — a real + library is not obliged to hold three rows — so a thin walk says so in + its expectation rather than dying here. cdp.evaluate awaits promises. */ +window.__drag = function (sel, from, to, holdMs) { + var rows = document.querySelectorAll(sel); + var a = rows[from], b = rows[to]; + if (rows.length < 2 || !a || !b) return false; + var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); + var x = Math.round(ra.left + Math.min(40, ra.width / 2)); + var y0 = Math.round(ra.top + ra.height / 2); + var y1 = Math.round(rb.top + rb.height / 2 + (to > from ? 4 : -4)); + function ev(type, cy) { + a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 7, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: cy })); + } + var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }; + return (async function () { + ev('pointerdown', y0); + await nap(holdMs || 420); + for (var i = 1; i <= 8; i++) { ev('pointermove', Math.round(y0 + (y1 - y0) * (i / 8))); await nap(24); } + await nap(60); + ev('pointerup', y1); + return true; + })(); +}; +/* the other half of the same gesture: pressed and released without moving, + which is still the row's sheet */ +window.__hold = function (sel, i, holdMs) { + var a = document.querySelectorAll(sel)[i || 0]; + if (!a) throw new Error('no row to hold for ' + sel); + var r = a.getBoundingClientRect(); + var x = Math.round(r.left + Math.min(40, r.width / 2)), y = Math.round(r.top + r.height / 2); + function ev(type) { + a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 8, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: y })); + } + return new Promise(function (res) { ev('pointerdown'); setTimeout(function () { ev('pointerup'); res(true); }, holdMs || 420); }); +}; diff --git a/handoff/rounds/ins-r37-mock-expect.txt b/handoff/rounds/ins-r37-mock-expect.txt new file mode 100644 index 0000000..7429129 --- /dev/null +++ b/handoff/rounds/ins-r37-mock-expect.txt @@ -0,0 +1,14 @@ + /* the order after a drop, from the store the page publishes — which is + what the backend kept, the page having re-read its rows by now */ + "playlist-dragged": (i) => i.plOrder === "Track 4|Track 1|Track 2|Track 3|One More Time|ID" && i.plfirst.startsWith("Track 4"), + "playlist-dragged-back": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plfirst.startsWith("Track 1"), + /* a filtered list's visible order is not the stored one, so the drag is + refused outright and the note under the field says why */ + "playlist-drag-filtered": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plrows === 4 && /Clear the search/.test(i.plNote), + /* pressed and released without moving: still the row's sheet */ + "playlist-drag-held": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText), + /* the optimistic order snapped back, and the app said so */ + "playlist-drag-refused": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && !!i.toast, + "queue-drag-arm": (i) => { globalThis.__q0 = i.qOrder; return i.qrows >= 3 && !!i.qOrder; }, + "queue-dragged": (i) => { const a = String(globalThis.__q0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); }, + "queue-dragged-back": (i) => !!i.qOrder && i.qOrder === globalThis.__q0, diff --git a/handoff/rounds/ins-r37-mock-qsteps.txt b/handoff/rounds/ins-r37-mock-qsteps.txt new file mode 100644 index 0000000..936ff65 --- /dev/null +++ b/handoff/rounds/ins-r37-mock-qsteps.txt @@ -0,0 +1,5 @@ + /* and dragged: the first upcoming row two places down, then back up. + The order is read off player.queue, not off the rows */ + { name: "queue-drag-arm", wait: 300 }, + { do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 900 }, + { do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 900 }, diff --git a/handoff/rounds/ins-r37-mock-steps.txt b/handoff/rounds/ins-r37-mock-steps.txt new file mode 100644 index 0000000..f602235 --- /dev/null +++ b/handoff/rounds/ins-r37-mock-steps.txt @@ -0,0 +1,14 @@ + /* and dragged: the fourth row lifted and dropped at the top, dragged + back, refused while the filter is on, still opening the sheet when a + press is released without moving, and — with the backend armed to + say no — snapping back and saying so */ + { do: `__drag('.pl-rows .trk', 3, 0)`, name: "playlist-dragged", wait: 1400 }, + { do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-dragged-back", wait: 1400 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 }, + { do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-drag-filtered", wait: 1000 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 }, + { do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 600 }, + { do: `__ssMock.fail('playlist_set_order', 'database is locked')`, wait: 200 }, + { do: `__drag('.pl-rows .trk', 0, 2)`, name: "playlist-drag-refused", wait: 1400 }, + { do: `(function () { __ssMock.fail('playlist_set_order', ''); __ss.player.error = ''; return true; })()`, wait: 400 }, diff --git a/handoff/rounds/ins-r37-phone-expect.txt b/handoff/rounds/ins-r37-phone-expect.txt new file mode 100644 index 0000000..a3a5e6c --- /dev/null +++ b/handoff/rounds/ins-r37-phone-expect.txt @@ -0,0 +1,13 @@ + /* the drags, tolerant of a real library the walk did not choose: a + one-track record makes a one-row playlist, and a one-row list has + nowhere to drag to. Anything longer is asserted exactly, against the + order stashed one step earlier — and the real core may answer faster + than the mock does, so every one of these reads settled state. The + tolerance stops at the rows: once there are enough of them the stash + must be there too, or a probe that reads nothing would pass silently */ + "playlist-drag-arm": (i) => { globalThis.__pl0 = i.plOrder; return i.plrows >= 1 && !!i.plOrder; }, + "playlist-dragged": (i) => { if (i.plrows < 2) return true; const a = String(globalThis.__pl0 || "").split("|"); if (a.length < 2) return false; const [x] = a.splice(1, 1); a.unshift(x); return i.plOrder === a.join("|"); }, + "playlist-dragged-back": (i) => i.plrows < 2 || (!!i.plOrder && i.plOrder === globalThis.__pl0), + "queue-drag-arm": (i) => { globalThis.__pq0 = i.qOrder; return i.qrows < 3 || !!i.qOrder; }, + "queue-dragged": (i) => { if (i.qrows < 3) return true; const a = String(globalThis.__pq0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); }, + "queue-dragged-back": (i) => i.qrows < 3 || (!!i.qOrder && i.qOrder === globalThis.__pq0), diff --git a/handoff/rounds/ins-r37-phone-plsteps.txt b/handoff/rounds/ins-r37-phone-plsteps.txt new file mode 100644 index 0000000..c73d231 --- /dev/null +++ b/handoff/rounds/ins-r37-phone-plsteps.txt @@ -0,0 +1,6 @@ + /* dragged on the real core: the second row to the top and straight + back, so the playlist is exactly as it was when it is deleted four + steps from here. Nothing outside the walk's own playlist is touched */ + { name: "playlist-drag-arm", wait: 400 }, + { do: `__drag('.pl-rows .trk', 1, 0)`, name: "playlist-dragged", wait: 2000 }, + { do: `__drag('.pl-rows .trk', 0, 1)`, name: "playlist-dragged-back", wait: 2000 }, diff --git a/handoff/rounds/ins-r37-phone-qsteps.txt b/handoff/rounds/ins-r37-phone-qsteps.txt new file mode 100644 index 0000000..f901ac1 --- /dev/null +++ b/handoff/rounds/ins-r37-phone-qsteps.txt @@ -0,0 +1,5 @@ + /* and the queue, on the real one: a row down two and back up. This + writes the session key and re-arms the preload, nothing else */ + { name: "queue-drag-arm", wait: 400 }, + { do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 1000 }, + { do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 1000 }, diff --git a/handoff/rounds/ins-r37-pl-glue.txt b/handoff/rounds/ins-r37-pl-glue.txt new file mode 100644 index 0000000..b8e3f35 --- /dev/null +++ b/handoff/rounds/ins-r37-pl-glue.txt @@ -0,0 +1,40 @@ + + /* ── dragged into place ─────────────────────────────────────────── + Press and hold a row and it lifts, the rows below make room, and + letting go lands it (../reorder.js). A press released without moving + still opens the row's sheet, so nothing that worked yesterday stops + working. The sheet's Move rows stay too: they are the accessible + path, and the one that is still honest under a filter — a filtered + list's visible order is NOT the stored order, so the drag itself is + refused while the search has anything in it, the way the desktop + refuses it ("reordering a partial view would be a lie about the + saved sequence"), and the note under the field says so. */ + const canDrag = $derived(!q.trim() && items.length > 1); + let dragFrom = null; + function dragLift() { pls.dragging = true; dragFrom = items; } + function dragHold(i) { const it = visible[i]; if (it) sheet(it); } + /* a drop clears the flag when the write settles, not when the finger + lifts: releasing it earlier would let the deferred re-read land the + OLD order between the two */ + function dragEnd(dropped) { if (!dropped) pls.dragging = false; } + function dragDrop(from, to) { + /* a refetch that landed mid-drag replaced the rows under us: the + indices the gesture collected address a list that is gone */ + if (items !== dragFrom || q.trim()) { pls.dragging = false; return; } + const snap = items.slice(); + const next = snap.slice(); + const [row] = next.splice(from, 1); + if (!row) { pls.dragging = false; return; } + next.splice(to, 0, row); + /* optimistic: the row is where you put it before the backend has + been asked, and comes back if the answer is no */ + items = next; + moveItem(pl, snap, row, to) + .catch((e) => { items = snap; player.error = String(e); }) + .finally(() => { pls.dragging = false; }); + } + /* `enabled` is a function, not a value: an action's `update` is + deprecated in Svelte 5, so an options object that has to change + identity to be heard is not something to lean on. This one is asked + at the press, every press. */ + const rowDrag = { enabled: () => canDrag, rowSel: ".trk", skipSel: ".r", scrollSel: ".screen", onLift: dragLift, onDrop: dragDrop, onHold: dragHold, onEnd: dragEnd }; diff --git a/handoff/rounds/ins-r37-probe.txt b/handoff/rounds/ins-r37-probe.txt new file mode 100644 index 0000000..adfde88 --- /dev/null +++ b/handoff/rounds/ins-r37-probe.txt @@ -0,0 +1,6 @@ + /* the ORDER, read from the stores rather than off the screen: after + a drop the page re-reads its rows, so this is what the backend + kept, not what the drag painted */ + plOrder: (function () { try { return __ss.pls.pls.current.items.map(function (x) { return x.title; }).join('|'); } catch (e) { return ''; } })(), + qOrder: (function () { try { return __ss.player.queue.slice(__ss.player.qi + 1, __ss.player.qi + 9).map(function (x) { return x.t.title; }).join('|'); } catch (e) { return ''; } })(), + plNote: (document.querySelector('.pl-note') || {}).textContent || '', diff --git a/handoff/rounds/ins-r37-reorder.txt b/handoff/rounds/ins-r37-reorder.txt new file mode 100644 index 0000000..8f5c1a8 --- /dev/null +++ b/handoff/rounds/ins-r37-reorder.txt @@ -0,0 +1,279 @@ +/* Drag to reorder — the playlist page's rows and the player sheet's queue, + one gesture for both. A phone should drag better than a desktop does: + the desktop has a grip and a mouse (src/views/Playlist.svelte), the phone + has a finger that is also how the list is scrolled, so most of this file + is about telling those two apart and then getting out of the scroller's + way. + + The contract, and it is strictly additive: + + hold 320ms the row lifts (and the phone taps back) + move the rows below make room; let go and it lands + release, unmoved the row's sheet opens, exactly as it always did + + That last line is load-bearing. A queue row has no ⋯ — `oncontextmenu` + is its only door to Play now, Move to next and Remove from queue — so a + long press that merely started a drag would strand that sheet. + + A Svelte action on the CONTAINER, in util.js's `drag` idiom: armed on + pointerdown, live only once the hold has landed, every pointer capture + in a try/catch (a synthetic pointer id is not capturable, and that must + not be fatal — it is what the walk drives this with). + + use:reorder={{ enabled, rowSel, skipSel, scrollSel, + onLift(from), onDrop(from, to), onHold(index), onEnd(dropped) }} + + `enabled` may be a value or a FUNCTION, and the playlist passes a + function on purpose: an action's `update` is deprecated in Svelte 5, so + an option object that has to change identity to be heard is one this + cannot lean on. Asked at the press instead, the answer is always the + current one. +*/ + +/** ms of stillness before a press becomes a lift: past a tap, and short of + * Android's own 500ms contextmenu, so the gesture is ours by then */ +const HOLD = 320; +/** px of movement before that: the list is being scrolled, let go of it */ +const SLOP = 8; +/** px from the scroller's edge that pulls the list along */ +const EDGE = 64; +/** px per frame at the very edge */ +const MAXSTEP = 14; + +export function reorder(el, opts) { + let o = opts; + let armed = null; /* { id, x, y, row } — pressed, the hold not yet landed */ + let live = null; /* the lift: the rows, their rects, the slot we are over */ + let holdTimer = 0; + /* a released drag would otherwise fire the row's own click and play the + track, and Android's long press would open the row's sheet in the + middle of one. Both are swallowed at the container in the CAPTURE + phase — which beats the row's handler and Svelte's delegated root + listener alike — and only while a lift is live. */ + let suppressUntil = 0; + + const contentY = (clientY) => (live.scroller ? clientY - live.box.top + live.scroller.scrollTop : clientY); + /** asked at the press, at the lift and at the drop, never cached */ + const ok = () => { const v = o && o.enabled; return typeof v === "function" ? !!v() : !!v; }; + + function attach() { + /* on the window, not the row: the gesture has to survive a capture + that never happened */ + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + } + function detach() { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + } + /* `touch-action` is latched at touchstart and cannot be flipped + mid-gesture, and touch-action: none on a row would cost the list the + flick that scrolls it — most of the page. So the scroll is refused + here instead: after 320ms of stillness the browser has not started + one yet, and eating the first touchmove after the lift cancels it. + Registered imperatively because it must be NON-passive; Svelte + registers touch handlers passively, and a passive listener may not + preventDefault. */ + const eatTouch = (e) => { if (live && e.cancelable) e.preventDefault(); }; + const guard = (e) => { + if (Date.now() > suppressUntil) return; + e.preventDefault(); + e.stopPropagation(); + }; + + function down(e) { + if (live || armed || !ok()) return; + if (e.button > 0) return; + const t = e.target; + if (!t || !t.closest) return; + if (o.skipSel && t.closest(o.skipSel)) return; /* the row's own ⋯ */ + const row = t.closest(o.rowSel); + if (!row || !el.contains(row)) return; + armed = { id: e.pointerId, x: e.clientX, y: e.clientY, row }; + clearTimeout(holdTimer); + holdTimer = setTimeout(lift, HOLD); + attach(); + } + + function lift() { + holdTimer = 0; + if (!armed || live || !ok()) { if (armed) { armed = null; detach(); } return; } + const a = armed; + const rows = [].slice.call(el.querySelectorAll(o.rowSel)); + const from = rows.indexOf(a.row); + if (from < 0 || rows.length < 2) { armed = null; detach(); return; } + const scroller = o.scrollSel ? el.closest(o.scrollSel) : null; + const box = scroller ? scroller.getBoundingClientRect() : { top: 0 }; + const top0 = scroller ? scroller.scrollTop : 0; + /* every row measured once, in the scroller's CONTENT space. That is + what makes the edge pull work: as scrollTop grows the snapshot + stays true and the delta grows on its own, so the row keeps up + with the finger. Measured rather than one assumed row height — + the desktop's `rowH` — because the queue face puts an album + separator between rows. */ + const snap = rows.map((r) => { + const b = r.getBoundingClientRect(); + const top = b.top - box.top + top0; + return { el: r, top, mid: top + b.height / 2 }; + }); + live = { + id: a.id, row: a.row, from, to: from, snap, scroller, box, + grabY: a.y - box.top + top0, y: a.y, y0: a.y, x0: a.x, + moved: false, raf: 0, shift: snap.map(() => 0), + }; + armed = null; + el.classList.add("dragging"); + a.row.classList.add("lifted"); + try { a.row.setPointerCapture(a.id); } catch { /* synthetic ids are not capturable */ } + window.addEventListener("touchmove", eatTouch, { passive: false }); + el.addEventListener("click", guard, true); + el.addEventListener("contextmenu", guard, true); + suppressUntil = Date.now() + 400; + try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* no motor, or none allowed */ } + if (o.onLift) o.onLift(from); + paint(); + } + + function paint() { + if (!live) return; + live.raf = 0; + const s = live.scroller; + if (s) { + const b = s.getBoundingClientRect(); + live.box = b; + /* the edges pull: near the top or the bottom of the scroller the + list comes to the finger, rather than the finger having to + leave the screen to reach the end of a long playlist */ + const above = live.y - b.top; + const below = b.bottom - live.y; + let step = 0; + if (above < EDGE) step = -Math.ceil(((EDGE - Math.max(0, above)) / EDGE) * MAXSTEP); + else if (below < EDGE) step = Math.ceil(((EDGE - Math.max(0, below)) / EDGE) * MAXSTEP); + if (step) { + const was = s.scrollTop; + s.scrollTop = Math.max(0, Math.min(s.scrollHeight - s.clientHeight, was + step)); + /* the list actually moved: keep the frames coming even while + the finger is still */ + if (s.scrollTop !== was) live.raf = requestAnimationFrame(paint); + } + } + const dy = contentY(live.y) - live.grabY; + live.row.style.transform = "translateY(" + Math.round(dy) + "px)"; + /* where the lifted row's own middle now sits decides the slot */ + const mid = live.snap[live.from].mid + dy; + let to = live.from; + for (let i = 0; i < live.snap.length; i++) { + if (i === live.from) continue; + if (i < live.from && mid < live.snap[i].mid) to = Math.min(to, i); + else if (i > live.from && mid > live.snap[i].mid) to = Math.max(to, i); + } + /* each displaced row takes its neighbour's slot — the exact + difference of their measured tops, not a row height. Only the + rows whose shift CHANGED are written to, so a frame that moves + nothing costs nothing. */ + for (let i = 0; i < live.snap.length; i++) { + if (i === live.from) continue; + let sh = 0; + if (to > live.from && i > live.from && i <= to) sh = live.snap[i - 1].top - live.snap[i].top; + else if (to < live.from && i >= to && i < live.from) sh = live.snap[i + 1].top - live.snap[i].top; + if (sh !== live.shift[i]) { + live.shift[i] = sh; + live.snap[i].el.style.transform = sh ? "translateY(" + Math.round(sh) + "px)" : ""; + } + } + live.to = to; + } + + function move(e) { + if (live) { + if (e.pointerId !== live.id) return; + live.y = e.clientY; + /* the same slop the arming uses, not a hair's breadth: a finger + held still for 320ms still wanders a few px, and a press + "released without moving" has to open the row's sheet the way + it always did — on the queue there is no other door to it. */ + if (Math.abs(e.clientY - live.y0) > SLOP || Math.abs(e.clientX - live.x0) > SLOP) live.moved = true; + suppressUntil = Date.now() + 400; + if (e.cancelable) e.preventDefault(); + /* one frame, however many moves arrive in it */ + if (!live.raf) live.raf = requestAnimationFrame(paint); + return; + } + if (!armed || e.pointerId !== armed.id) return; + if (Math.abs(e.clientY - armed.y) > SLOP || Math.abs(e.clientX - armed.x) > SLOP) { + /* it moved before the hold landed: that was a scroll */ + clearTimeout(holdTimer); + holdTimer = 0; + armed = null; + detach(); + } + } + + function finish(e) { + if (e && live && e.pointerId !== live.id) return; + if (e && !live && armed && e.pointerId !== armed.id) return; + clearTimeout(holdTimer); + holdTimer = 0; + detach(); + armed = null; + const was = live; + live = null; + if (!was) return; + if (was.raf) cancelAnimationFrame(was.raf); + window.removeEventListener("touchmove", eatTouch); + /* every mark cleared BEFORE the mutation. The playlist is keyed by + id so a drop MOVES nodes; the queue is keyed by index so it + re-fills them — either way an inline transform left on one is a + bug you can see. */ + el.classList.remove("dragging"); + was.row.classList.remove("lifted"); + was.row.style.transform = ""; + for (let i = 0; i < was.snap.length; i++) was.snap[i].el.style.transform = ""; + try { was.row.releasePointerCapture(was.id); } catch { /* never captured */ } + suppressUntil = Date.now() + 350; + setTimeout(unguard, 420); + /* asked once more here: a filter typed with a finger still down + would make the landing index a lie about the saved order */ + /* a cancelled pointer is not a drop: Android takes the touch away + when it decides the gesture is its own, and landing the row then + would be a reorder nobody asked for. Unmoved it is still the long + press it looks like, so the sheet opens either way. */ + const cancelled = !!e && e.type === "pointercancel"; + const dropped = !cancelled && was.to !== was.from && ok(); + if (dropped) { if (o.onDrop) o.onDrop(was.from, was.to); } + else if (!was.moved) { if (o.onHold) o.onHold(was.from); } + if (o.onEnd) o.onEnd(dropped); + } + function unguard() { + if (live) return; /* another lift has taken the guards over */ + el.removeEventListener("click", guard, true); + el.removeEventListener("contextmenu", guard, true); + } + + el.addEventListener("pointerdown", down); + return { + /* deprecated in Svelte 5 and not relied on — `enabled` is asked for + through a function — but honoured if it ever does arrive */ + update(next) { o = next; }, + destroy() { + clearTimeout(holdTimer); + /* torn down with a row still in hand — a navigation that happened + under the finger. onEnd is what releases the page's deferred + re-read, so it has to be told, or the playlist would stop + re-reading its rows for the rest of the session. */ + if (live) { + if (live.raf) cancelAnimationFrame(live.raf); + live = null; + if (o && o.onEnd) { try { o.onEnd(false); } catch { /* the page is leaving anyway */ } } + } + armed = null; + detach(); + window.removeEventListener("touchmove", eatTouch); + el.removeEventListener("pointerdown", down); + el.removeEventListener("click", guard, true); + el.removeEventListener("contextmenu", guard, true); + }, + }; +} diff --git a/handoff/rounds/ins-r37-sheet-glue.txt b/handoff/rounds/ins-r37-sheet-glue.txt new file mode 100644 index 0000000..8f2c37b --- /dev/null +++ b/handoff/rounds/ins-r37-sheet-glue.txt @@ -0,0 +1,22 @@ + + /* ── the queue, dragged ─────────────────────────────────────────── + The same gesture the playlist page has (./reorder.js). Press and + release without moving and the row's sheet opens exactly as the long + press always opened it — which matters more here than anywhere: a + queue row has no ⋯, so that sheet is the only door to Play now, Move + to next and Remove from queue. */ + let qiAt = -1; + function queueLift() { qiAt = player.qi; } + function queueDrop(from, to) { + /* a track boundary crossed mid-drag moves the whole rendered + window, and these are absolute indices: they would address the + wrong rows. Drop nothing rather than the wrong thing. */ + if (player.qi !== qiAt) return; + moveInQueue(qiAt + 1 + from, qiAt + 1 + to); + } + function queueHold(k) { + const i = player.qi + 1 + k; + const e = player.queue[i]; + if (e) openHalf("track", { album: e.album, track: e.t, queueIndex: i }); + } + const queueDrag = { enabled: true, rowSel: ".row", scrollSel: ".np-face", onLift: queueLift, onDrop: queueDrop, onHold: queueHold }; diff --git a/handoff/rounds/ins-r38-css.txt b/handoff/rounds/ins-r38-css.txt new file mode 100644 index 0000000..1932fba --- /dev/null +++ b/handoff/rounds/ins-r38-css.txt @@ -0,0 +1,47 @@ +/* ── the whole discography ─────────────────────────────────────────────── + The artist page's "All". Nearly all of it is the app's own parts — .sec, + .segtabs, .row, .eds/.ed, .more-link, .btn — so what is here is the head, + the year written into the list where the desktop has a sticky rail, the + editions line under a record, and the editions themselves widened out of + the record page's 210px rail into the width this list already has. */ +.dg-head { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 6px; } +.dg-head .b { flex: 1; min-width: 0; } +.dg-head .t { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dg-head .m { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; } +.dg-por { + width: 44px; height: 44px; + border-radius: 50%; + overflow: hidden; + flex: none; + background: var(--surface); + box-shadow: var(--ring); + display: grid; + place-items: center; + color: var(--text-muted); +} +.dg-por img { width: 100%; height: 100%; object-fit: cover; } +.dg-sec { padding-top: 12px; } +/* the year. A phone list is read top to bottom, so the span the desktop's + rail jumps to is simply written into it. */ +.dg-group { + padding: 16px var(--pad) 5px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.4px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.dg-group em { font-style: normal; font-weight: 650; letter-spacing: 0; color: var(--text-muted); } +/* both of these line up under the row's TITLE, not under its cover: the + pad, the 46px cover and the row's 12px gap */ +.dg-eline { + display: flex; + align-items: center; + gap: 6px; + padding: 0 var(--pad) 9px calc(var(--pad) + 58px); + font-size: 12px; + font-weight: 750; + color: var(--accent-hover); +} +.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); } +.dg-eds .ed { width: 100%; } diff --git a/handoff/rounds/ins-r38-disco.txt b/handoff/rounds/ins-r38-disco.txt new file mode 100644 index 0000000..95ae7f6 --- /dev/null +++ b/handoff/rounds/ins-r38-disco.txt @@ -0,0 +1,57 @@ +/* ── the whole discography, on a page of its own ───────────────────────── + The artist page's tabs are one lane per category; the discography page is + the same filing seen whole. Both read ONE classifier, and it is this one. + + The desktop cannot do that: CatalogDiscography.svelte takes the artist + page's already-computed tab list as the same array object, because the + desktop's filing depends on a background description harvest and on + deferred MusicBrainz type hints that a re-run would not read + (CatalogDiscography.svelte:28-46). `discoTabs` here is pure and reads only + what the catalog page carries, so the phone simply calls it twice over the + same cached albums and the two pages can never disagree. */ + +/** "All" in front of the categories — built by CONCATENATING the per-category + * families, never by re-folding the whole set. A single that shares an + * album's base title (Pond released "Terrestrials" in 2026 as an album AND + * as a single) would fold into it as a phantom edition, and one of the two + * would vanish from its own tab. */ +export function allTabs(tabs) { + const ts = tabs || []; + if (!ts.length) return []; + return [{ key: "all", label: "All", items: ts.flatMap((t) => t.items) }, ...ts]; +} + +/** The four orders the page offers, and what each is called. The sort sheet + * in HalfSheet.svelte writes the same four out inline — it is edited by + * every other round and a new import there is a collision waiting to + * happen — so if a label changes it changes in both places. */ +export const ORDER_LABEL = { new: "Newest first", old: "Oldest first", az: "A–Z", za: "Z–A" }; + +/** The desktop's sort (CatalogDiscography.svelte:71-78): dates on + * `release_date`, names on the title, either direction. */ +export function orderFamilies(items, order) { + const list = [...(items || [])]; + const byName = order === "az" || order === "za"; + list.sort((a, b) => (byName + ? (a.title ?? "").localeCompare(b.title ?? "") + : (a.release_date ?? "").localeCompare(b.release_date ?? ""))); + return order === "new" || order === "za" ? list.reverse() : list; +} + +/** [{ key: "2001", items }] — the year headers, cut out of the list in the + * order it is already in rather than re-sorted, which is why a name order + * must not be grouped: the years would repeat out of sequence. `on` false + * gives the whole list back under one null key, so the caller renders one + * loop either way. */ +export function byYear(items, on) { + const list = items || []; + if (!on) return list.length ? [{ key: null, items: list }] : []; + const out = []; + for (const r of list) { + const y = String(r.release_date || "").slice(0, 4) || "—"; + const last = out[out.length - 1]; + if (last && last.key === y) last.items.push(r); + else out.push({ key: y, items: [r] }); + } + return out; +} diff --git a/handoff/rounds/ins-r38-doc.txt b/handoff/rounds/ins-r38-doc.txt new file mode 100644 index 0000000..d2e3105 --- /dev/null +++ b/handoff/rounds/ins-r38-doc.txt @@ -0,0 +1,27 @@ +The **whole discography** is a page of its own +(`src/mobile/screens/Discography.svelte`), reached from the artist page's +Discography head with **All**, and from under its grid with "Show all N +Albums" — which is also where that grid now stops: it shows twelve of the +active tab, because before this an artist with 140 releases rendered 140 +cards. The page is the desktop's `CatalogDiscography.svelte` reduced to the +four things it has that the artist page's tabs do not: **All** in front of +the categories (built by CONCATENATING the per-category families, never by +re-folding the whole set — a single sharing an album's base title would fold +into it as a phantom edition), a **sort** (newest, oldest, A–Z, Z–A), a +**grouping** by year, and the **editions kept whole**, opened in place under +their own record. Three of the desktop's are deliberately left behind: the +per-record tracklists (140 records is 140 `catalog_album` calls over mobile +data, for rows one tap away on the record page), the per-record accent +sampled from each cover, and the scroll-led banner. The year is written into +the list where the desktop has a sticky year rail — a phone list is read top +to bottom. The order and the grouping live on the nav entry and are edited +from the **Sort & group** sheet, Library's own idiom; under a name order By +year is not offered, because the years would repeat out of sequence, and the +sheet says so. The list is capped at sixty rows with a "+ N more releases" +button (Browse's rule), and the cap is taken BEFORE the grouping, so the last +year on screen can be a part of itself. Filing, families and labels all come +from `src/mobile/disco.js` — the artist page's own classifier, called a +second time over the same cached albums. The desktop cannot do that: its +filing depends on a background description harvest and deferred MB type +hints a second page would not read, which is why `CatalogDiscography.svelte` +takes the artist page's computed tab list as the same array object instead. diff --git a/handoff/rounds/ins-r38-halfsheet.txt b/handoff/rounds/ins-r38-halfsheet.txt new file mode 100644 index 0000000..652d0d8 --- /dev/null +++ b/handoff/rounds/ins-r38-halfsheet.txt @@ -0,0 +1,24 @@ + {:else if kind === "discosort"} + {@const c = top().c} + {@const ord = c.order || "new"} + {@const byName = ord === "az" || ord === "za"} + {@const grp = byName ? "none" : (c.group ?? "year")} + +
Sort & group
+
Order
+
+ {#each [["new", "Newest first"], ["old", "Oldest first"], ["az", "A–Z"], ["za", "Z–A"]] as [k, l] (k)} + + {/each} +
+
Group
+
+ {#each [["year", "By year"], ["none", "None"]] as [k, l] (k)} + + {/each} +
+ {#if byName}
By year needs a date order — under A–Z the years would repeat out of sequence.
{/if} +
diff --git a/handoff/rounds/ins-r38-mobile-meta.txt b/handoff/rounds/ins-r38-mobile-meta.txt new file mode 100644 index 0000000..dc7a2a8 --- /dev/null +++ b/handoff/rounds/ins-r38-mobile-meta.txt @@ -0,0 +1,10 @@ + /* the whole discography wears the artist's own ground, but no + hero of its own: it is a list, and the bar is over it from the + first pixel rather than after 210 of them */ + case "disco": { + const as = albumsBy(e.c.name); + const cat = catalog.artists[artistKey(e.c.name)]?.page; + const p = cat?.banner || cat?.artist?.picture + || as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null; + return { title: e.c.name, kicker: "Discography", amb: p }; + } diff --git a/handoff/rounds/ins-r38-mock-big.txt b/handoff/rounds/ins-r38-mock-big.txt new file mode 100644 index 0000000..e5b89af --- /dev/null +++ b/handoff/rounds/ins-r38-mock-big.txt @@ -0,0 +1,20 @@ +/* A prolific artist, so the discography page's cap is WALKED rather than + argued about: 140 records, four a year from 1985, each plainly titled for + the same reason SLOW_ALBUMS' are — the classifier must leave every one of + them on the Albums tab, or the counts the walk asserts stop meaning + anything. Deliberately NOT in RELATED: a fifth card in the rail would move + the artist page's own card census (cat-artist-tab counts 2 images and 2 + placeholders there). The walk reaches it with a seed, so no search + answers for it either. */ +const CAT_BIG = { id: 416, name: "Ember Parade", picture: null, album_count: 140, fans: 91000 }; +const BIG_ALBUMS = Array.from({ length: 140 }, (_, i) => ({ + id: 41600 + i, + title: "Field Notes " + (i + 1), + artist: CAT_BIG.name, + cover: svgArt((i * 37) % 360, "F"), + release_date: (1985 + Math.floor(i / 4)) + "-" + String((i % 4) * 3 + 2).padStart(2, "0") + "-15", + record_type: "album", + track_count: 8 + (i % 5), + edition_group: null, + is_soundtrack: false, +})); diff --git a/handoff/rounds/ins-r38-mock-expect.txt b/handoff/rounds/ins-r38-mock-expect.txt new file mode 100644 index 0000000..b09040c --- /dev/null +++ b/handoff/rounds/ins-r38-mock-expect.txt @@ -0,0 +1,20 @@ + /* the whole discography: All in front of the six categories the eleven + mock rows file into, the ten families they fold to, a year header per + year, the desktop's own summary line and the order the head shows. + Two of those rows are dated relative to today (dayIso(-9), dayIso(21)), + so a run across a New Year splits their year in two — hence the range + rather than a number. */ + "disco-all": (i) => i.dgTabs === 7 && i.dgRows === 10 && i.dgGroups >= 8 && i.dgGroups <= 9 + && /^10 releases/.test(i.dgSum) && /Newest/.test(i.dgOrder) + && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, + "disco-albums": (i) => i.dgRows === 4 && i.dgGroups === 4, + /* Discovery and its Deluxe — the one edition_group in the mock, and the + thing a flat list of releases loses */ + "disco-editions": (i) => i.dgEds === 2 && i.dgRows === 10, + "disco-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText), + /* the rule this page has to get right: a name order cannot be grouped by + year, so the grouping falls away with it */ + "disco-az": (i) => i.dgFirst === "Alive 1997" && i.dgGroups === 0 && /A–Z/.test(i.dgOrder), + "disco-many-artist": (i) => i.title.startsWith("Ember Parade") && i.disco === 12 && /Show all 140/.test(i.dgAll), + "disco-many": (i) => i.dgRows === 60 && /\+ 80 more/.test(i.dgMore), + "disco-many-all": (i) => i.dgRows === 140 && !i.dgMore, diff --git a/handoff/rounds/ins-r38-mock-steps.txt b/handoff/rounds/ins-r38-mock-steps.txt new file mode 100644 index 0000000..1c8059a --- /dev/null +++ b/handoff/rounds/ins-r38-mock-steps.txt @@ -0,0 +1,21 @@ + /* the whole discography, the page the artist page's "All" opens: the + eleven mock rows folded to ten families across six categories, the + year headers, a category tab, the one linked family opened in place, + and the sort sheet taking it to a name order — which is where the + grouping has to fall away */ + { do: `__click('.sec .all', 'All')`, name: "disco-all", wait: 1400 }, + { do: `__click('.dg-tabs button', 'Albums')`, name: "disco-albums", wait: 900 }, + { do: `__click('.dg-tabs button', 'All')`, wait: 800 }, + { do: `__click('.dg-eline', null)`, name: "disco-editions", wait: 800 }, + { do: `__click('.dg-sec .all', null)`, name: "disco-sheet", wait: 900 }, + { do: `__click('.halfsheet .chip', 'A–Z')`, name: "disco-az", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 700 }, + { do: `history.back()`, wait: 800 }, + /* and the cap, on an artist with 140 records: the grid on the artist + page is a slice with the way down under it, the page itself opens on + the tab that link was showing, and sixty rows extend to all of them */ + { do: `__ss.push('artist', { name: 'Ember Parade', seed: { id: 416, name: 'Ember Parade', picture: null, album_count: 140, fans: 91000 } })`, name: "disco-many-artist", wait: 1800 }, + { do: `__click('.more-link.dg-all', 'Show all')`, name: "disco-many", wait: 1500 }, + { do: `__click('.dg-more', null)`, name: "disco-many-all", wait: 1400 }, + { do: `history.back()`, wait: 800 }, + { do: `history.back()`, wait: 900 }, diff --git a/handoff/rounds/ins-r38-phone-expect.txt b/handoff/rounds/ins-r38-phone-expect.txt new file mode 100644 index 0000000..de4bf82 --- /dev/null +++ b/handoff/rounds/ins-r38-phone-expect.txt @@ -0,0 +1,8 @@ + /* the real core answers faster than the mock does, and it answers with + Kyle's own artists, so none of this asserts a count, a title or a + state with a window shorter than the still: only that the page is + there, listing something, in the order the sheet was just told to + use, and that nothing went wrong saying so */ + "dg-page": (i) => i.dgTabs >= 1 && i.dgRows >= 1 && !i.toast && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, + "dg-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText) && !i.toast, + "dg-az": (i) => i.dgRows >= 1 && /A–Z/.test(i.dgOrder) && !i.toast, diff --git a/handoff/rounds/ins-r38-phone-steps.txt b/handoff/rounds/ins-r38-phone-steps.txt new file mode 100644 index 0000000..b65b7e1 --- /dev/null +++ b/handoff/rounds/ins-r38-phone-steps.txt @@ -0,0 +1,14 @@ + /* the whole discography on the real core, on the same artist the walk + already opened above — so the catalog store is warm and the page has + its releases the moment it is pushed. It reads and it sorts; nothing + is downloaded, played, joined, sent, signed in to or written. */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, + { do: `__click('.screen button.row', null)`, wait: 500 }, + { wait: 2500 }, + { do: `__click('.sec .all', 'All')`, name: "dg-page", wait: 1600 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "dg-sheet", wait: 1000 }, + { do: `__click('.halfsheet .chip', 'A–Z')`, name: "dg-az", wait: 1000 }, + { do: `document.querySelector('.scrim').click()`, wait: 700 }, + { do: `history.back()`, wait: 800 }, + { do: `history.back()`, wait: 900 }, diff --git a/handoff/rounds/ins-r38-probe.txt b/handoff/rounds/ins-r38-probe.txt new file mode 100644 index 0000000..6d3bf5e --- /dev/null +++ b/handoff/rounds/ins-r38-probe.txt @@ -0,0 +1,13 @@ + /* the whole-discography page: its rows, its tabs (All in front), the + year headers, an opened edition rail, the cap's own button, the + first row's title, the order the head is showing and the summary + line — plus, back on the artist page, the link down to it */ + dgRows: document.querySelectorAll('.dg-row').length, + dgTabs: document.querySelectorAll('.dg-tabs button').length, + dgGroups: document.querySelectorAll('.dg-group').length, + dgEds: document.querySelectorAll('.dg-eds .ed').length, + dgMore: (document.querySelector('.dg-more') || {}).textContent || '', + dgFirst: (document.querySelector('.dg-row .t') || {}).textContent || '', + dgOrder: (document.querySelector('.dg-sec .all') || {}).textContent || '', + dgSum: (document.querySelector('.dg-head .m') || {}).textContent || '', + dgAll: (document.querySelector('.more-link.dg-all') || {}).textContent || '', diff --git a/handoff/rounds/ins-r38-screen.txt b/handoff/rounds/ins-r38-screen.txt new file mode 100644 index 0000000..ff876b5 --- /dev/null +++ b/handoff/rounds/ins-r38-screen.txt @@ -0,0 +1,196 @@ + + +
+ + {#if porSrc}{:else}{/if} + +
+
{shownName}
+
{items.length} release{items.length === 1 ? "" : "s"} · {someUnknown ? "≥" : ""}{known} tracks
+
+
+ +{#if tabs.length > 1} +
+ {#each tabs as t (t.key)} + + {/each} +
+{/if} + +{#if items.length} +
+

{active?.label ?? "All"} · {items.length} release{items.length === 1 ? "" : "s"}

+ +
+ {#each groups as g (g.key ?? "_all")} + {#if g.key} +
{g.key} · {g.items.length} release{g.items.length === 1 ? "" : "s"}
+ {/if} + {#each g.items as f (familyKeyOf(f))} + {@const l = familyLib(f)} + {@const key = familyKeyOf(f)} + {@const eds = (f.editions?.length ?? 1) > 1 ? f.editions : null} +
+ + {#if eds} + + {#if openEds.has(key)} +
+ {#each eds as e, i (e.id)} + + {/each} +
+ {/if} + {/if} +
+ {/each} + {/each} + {#if items.length > cap} +
+ {/if} +{:else if !cat || cat.status === "loading"} +
Asking the catalog…
+{:else} +
Nothing to list.The catalog has no releases for {shownName}{cat.status === "error" ? " — it did not answer" : ""}.
+{/if} + +
diff --git a/handoff/rounds/ins-r39-doc-seen.txt b/handoff/rounds/ins-r39-doc-seen.txt new file mode 100644 index 0000000..74bf000 --- /dev/null +++ b/handoff/rounds/ins-r39-doc-seen.txt @@ -0,0 +1,34 @@ +toast says it in English. + +**What the walk has seen.** The round that wrote all of the above was straight +about what it had not proved: six of these fixes shipped with no expectation at +all, because neither `loadHome` nor `loadLyrics` was reachable from the `__ss` +handle the walk drives the app through, and both answer a second ask with +nothing — `loadHome` is gated on `started`, `loadLyrics` on the track's own +`key` — so no amount of tapping re-runs them. They are driven now: Home's +`catalogDown`, the Gaps line, a catalog record's stub, the artist page's two +different sentences, a playlist that would not read, the lyrics `failed` face, +and `failed · ` on a transfer row. Each is armed with +`__ssMock.fail`, asserted both by the words a person reads and by the Rust +string being absent from them, and disarmed by the step that proves the face +heals. The handle grew four names for it and no more — `home` and `loadHome`, +`lyr` and `loadLyrics` — because a test seam that exports the internals +wholesale stops being a seam. The toast's own **Try again** is pressed for the +first time too, through You → **Rescan library**: `lib.error` is written by +`loadLibrary` alone, and that row is the only door in the app that reaches it +a second time. + +What is still unseen, honestly. **Three sites draw a raw backend string**, so +the rule above has holes: `screens/Browse.svelte` — a peer's shares that would +not read, which is a full error face, Try again and all, that was simply missed +— and `screens/Import.svelte`, twice. `SyncRoom.svelte` is a fourth, by way of +`saveTapSync`'s catch. **Six error faces have no expectation either**: Browse's, +Community's "The server did not answer.", Room's "Not in this room.", Album's +"That record is gone.", Playlist's "Playlist not found." and Peer's "Not +compared yet." — all reachable, the first two through the mock's weather and +the rest by pushing a nav entry with a dead id. And of the probe's fields, six +are still read out on every step and asserted on none: `accent`, `xlive`, `hd`, +`hwant`, `navMs` and `a11yWho`. The cheapest real win left in the harness is a +global `hd === hwant` check on every named step — the history mirror is how the +blank page after a confirmed delete was found — but it would fire during the +first-run reloads and needs its own thinking about the suppress window first. diff --git a/handoff/rounds/ins-r39-doc-walk.txt b/handoff/rounds/ins-r39-doc-walk.txt new file mode 100644 index 0000000..f7744a8 --- /dev/null +++ b/handoff/rounds/ins-r39-doc-walk.txt @@ -0,0 +1,9 @@ +because the folder is gone — then all of it cleared and the app left as it was +found. After the weather come the faces that failure actually draws, each one +armed by the mock and read back by its own words: Home with every follow's +catalog page refused, the Gaps line and its second tap, a catalog record's stub +and its Try again, an artist page whose lookup failed beside one that was +answered with nothing, a playlist that would not read, the lyrics face on a +lookup that never came back, a transfer that was refused, and the library +toast's own Try again pressed for the first time — then all of that disarmed +too, and asserted disarmed. diff --git a/handoff/rounds/ins-r39-doc-weather.txt b/handoff/rounds/ins-r39-doc-weather.txt new file mode 100644 index 0000000..cf5c39c --- /dev/null +++ b/handoff/rounds/ins-r39-doc-weather.txt @@ -0,0 +1,6 @@ +two safe steps of its own (`trouble-words`: eleven real backend strings through +`__ss.say` on the device, every output asserted free of Rust; and +`trouble-xfer`: one already-failed transfer row written into the store and +taken away again — nothing fetched, and a terminal row is not in flight, so +nothing reaches the shade). Fifteen more mock steps drive the failure faces +themselves, which are described under **When things go wrong**. A mock step can diff --git a/handoff/rounds/ins-r39-expect.txt b/handoff/rounds/ins-r39-expect.txt new file mode 100644 index 0000000..2b986fa --- /dev/null +++ b/handoff/rounds/ins-r39-expect.txt @@ -0,0 +1,40 @@ + /* ── the faces no walk had seen ───────────────────────────────────── + Each of these fails if round thirty-one's fix is undone: stop reading + `slot.error` and Home's line empties; drop the `gapErr` branch and + the Gaps line falls back to "See what's missing" and can be tapped + for ever; put `lyr.failed` back and the face says "No lyrics found" + about a lookup that never answered; take `say()` out of `stateLine` + and the raw string is on the row again. Every one asserts the + sentence a person reads AND that the Rust string is not in it — the + second half is the round-thirty-one rule, and the half that survives + any rewording of the first. */ + "faces-home-down": (i) => i.homeSlots > 0 && i.homeErr === i.homeSlots + && /^Couldn't reach the catalog, so there's nothing new/.test(i.catDown) + && !/New from your artists/.test(i.secs), + "faces-gap-down": (i) => /Couldn't reach the catalogue — tap to try again/.test(i.gapLine) && !i.gapCards, + "faces-gap-again": (i) => /more released/.test(i.gapLine) && !/Couldn't reach/.test(i.gapLine) && i.gapCards >= 3, + "faces-album-down": (i) => /^The catalog did not answer/.test(i.why) && /The network didn't answer/.test(i.why) + && !/io:|connection reset/.test(i.why) && i.again && !i.trks, + "faces-album-again": (i) => !i.why && !i.again && i.trks > 0, + /* the pair that proves the distinction is real and not decoration */ + "faces-artist-down": (i) => /only what this phone holds is here/.test(i.catNote) && /didn't answer/.test(i.stubText) + && !/io:|address information/.test(i.catNote + i.stubText), + "faces-artist-none": (i) => !i.catNote && /had nothing to add/.test(i.stubText) && !/didn't answer/.test(i.stubText), + "faces-playlist-down": (i) => /^Couldn't read this playlist/.test(i.why) && /library folder isn't readable/.test(i.why) + && i.again && !i.plrows && !/database file/.test(i.why), + "faces-playlist-again": (i) => !i.why && !i.again && i.plrows > 0, + "faces-lyrics-down": (i) => i.lyrFailed === "yes" && !i.lyrLines && /^Couldn't look up the words/.test(i.lyrNote) + && i.lyrTools && !/io:|address information/.test(i.lyrNote), + "faces-lyrics-again": (i) => i.lyrFailed === "no" && i.lyrLines >= 3 && !i.lyrNote, + "faces-xfer-failed": (i) => i.title.startsWith("Transfers") + && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) + && !/transfer rejected/.test(i.xferFail), + "faces-library-toast": (i) => /library folder isn't readable right now/.test(i.toast) && i.retry === "Try again" + && !/database file/.test(i.toast), + "faces-library-again": (i) => i.title.startsWith("You") && !i.toast && !i.retry, + /* the disarm, proved: no toast, no armed action, no stub, Home's slots + healthy and its rails back — so the first run below is not quietly + running on something this block left standing */ + "faces-clear": (i) => !i.toast && !i.why && !i.catDown && !i.retry + && i.homeSlots > 0 && i.homeErr === 0 + && /New from your artists/.test(i.secs) && /^(Good |Still)/.test(i.title), diff --git a/handoff/rounds/ins-r39-phone-expect.txt b/handoff/rounds/ins-r39-phone-expect.txt new file mode 100644 index 0000000..b876511 --- /dev/null +++ b/handoff/rounds/ins-r39-phone-expect.txt @@ -0,0 +1,15 @@ + /* the row's own words on the device, and then its absence. The "gone" + clause is written against THIS row's sentence rather than against + `xferFail` being empty: a real failed transfer of Kyle's sitting in + the list is not a reason to fail the step, and the real core + answering faster than a mock would cannot change either reading — + both are settled state, written and removed by the step itself. The + screen underneath is deliberately not named: sibling rounds append + their own phone steps before `end` too, so whichever screen the back + lands on is theirs, not this round's. What IS asserted is that the + back popped at all — a sheet left up would close instead and the bar + would still say Transfers. */ + "trouble-xfer": (i) => i.title.startsWith("Transfers") + && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) + && !/transfer rejected/.test(i.xferFail), + "trouble-xfer-gone": (i) => !/^Transfers/.test(i.title) && !/File not shared/.test(i.xferFail), diff --git a/handoff/rounds/ins-r39-phone-steps.txt b/handoff/rounds/ins-r39-phone-steps.txt new file mode 100644 index 0000000..eaddcbb --- /dev/null +++ b/handoff/rounds/ins-r39-phone-steps.txt @@ -0,0 +1,11 @@ + /* the one face of the six that a real build can show without arming + anything: `__ssMock` does not exist here. One already-terminal row + goes into the transfer store — the same thing the shade steps above + do — and comes straight back out. Nothing is downloaded, no room is + joined, no message is sent, nobody is signed in, and neither the + config nor the library is written. A `failed` row is not in flight, + so no notice is posted either. The id is far above the backend's own + counter and `list()` sorts id-descending, so it is the first row on + the screen even if Kyle's own session is carrying real failures. */ + { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "trouble-xfer", wait: 1400 }, + { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, name: "trouble-xfer-gone", wait: 1000 }, diff --git a/handoff/rounds/ins-r39-probe.txt b/handoff/rounds/ins-r39-probe.txt new file mode 100644 index 0000000..c5ba59d --- /dev/null +++ b/handoff/rounds/ins-r39-probe.txt @@ -0,0 +1,20 @@ + /* ── the faces round thirty-one drew and no step had ever read ── + Home's own line and the slots behind it (how many follows it + walked, how many came back empty-handed); the catalog's note on + an artist page; whatever stub is up, whitespace collapsed; the + Gaps line, the BUTTON only — the '.gapline.done' variant is a + div and says something else entirely; the failed transfer's + sentence; and whether the lyrics lookup itself failed, as + opposed to having found nothing. Every backslash here is doubled + and there is not one backtick, comment included — this block + lives inside a template literal, and a backtick would END it + (the file still PARSES, so node --check says nothing: what it + becomes is a tagged template, and smoke.mjs throws on import). */ + catDown: (document.querySelector('.sec-note.cat-down') || {}).textContent || '', + homeSlots: (function () { try { return __ss.home.order.length; } catch (e) { return -1; } })(), + homeErr: (function () { try { return __ss.home.order.filter(function (id) { var s = __ss.home.slots[id]; return !!(s && s.error); }).length; } catch (e) { return -1; } })(), + catNote: (function () { var n = [].slice.call(document.querySelectorAll('.screen .sec-note:not(.cat-down)')).find(function (x) { return /catalog/i.test(x.textContent || ''); }); return n ? n.textContent.replace(/\\s+/g, ' ').trim() : ''; })(), + stubText: ((document.querySelector('.screen .stub') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), + gapLine: ((document.querySelector('button.gapline') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), + xferFail: (document.querySelector('.row.xfer.failed .m') || {}).textContent || '', + lyrFailed: (function () { try { return __ss.lyr.failed ? 'yes' : 'no'; } catch (e) { return ''; } })(), diff --git a/handoff/rounds/ins-r39-steps.txt b/handoff/rounds/ins-r39-steps.txt new file mode 100644 index 0000000..fbb4252 --- /dev/null +++ b/handoff/rounds/ins-r39-steps.txt @@ -0,0 +1,99 @@ + /* ── the faces no walk had seen ───────────────────────────────────── + Round thirty-one gave six screens an honest failure and its own + verifier was straight about what it had shipped unobserved: Home's + `catalogDown`, the lyrics `failed` note, Gaps' `gapErr`, Playlist's + `loadErr`, the catalog record's and the artist page's stubs, and + `failed · ` on a transfer row. It could drive none of them + because neither `loadHome` nor `loadLyrics` was reachable from the + `__ss` handle — both are gated on `started` / `key` and answer a + second ask with nothing at all, so no amount of tapping re-runs + them. The handle carries the two stores and their two loaders now, + and this block is what they were added for. + + It sits HERE, before the first run, and not at the end of the list: + the block below reloads the page twice, and the mock's follows live + in a module-level `let` that a reload empties — after `fresh-off` + nobody is followed, and Home's `catalogDown` (which needs a follow) + and the Gaps shelf (which IS the follows) are both structurally + unreachable. Here the block inherits what the weather left behind: + signed in, on Home, Daft Punk followed, a track on the mini bar. + + Every assertion is on settled state — an armed failure stays armed, + an error stub stays up, the toast stays until it is dismissed — and + the two forced loads return their promise, which `cdp.evaluate` + awaits, so the fetch is over before the step's wait even begins. + Every failure is disarmed by the step that proves the face heals, + and `faces-clear` is the proof that nothing was left armed. */ + { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 }, + /* Home with every follow's catalog page refused. `home.svelte.js` has + always set `slot.error` there; until round thirty-one nothing read + it, and until this step nothing had ever seen the line that does. */ + { do: `(function () { __ssMock.fail('catalog_artist', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadHome(true); })()`, name: "faces-home-down", wait: 1200 }, + /* the same armed failure, on the other screen that asks the same + command: Gaps. Home stays poisoned through these three steps on + purpose — it is asserted above, and the second tap below is what + clears the weather for both. */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, + { do: `__click('.segtabs button', 'Gaps')`, wait: 700 }, + { do: `__click('.gapline', null)`, name: "faces-gap-down", wait: 1400 }, + /* a failed lookup leaves `gapRows[key]` undefined, so the second tap + genuinely asks again rather than merely opening a rail */ + { do: `__ssMock.fail('catalog_artist', '')`, wait: 300 }, + { do: `__click('.gapline', null)`, name: "faces-gap-again", wait: 1600 }, + /* a catalog record. The walk has already made 302127 `ready` and + `lookupAlbum` returns early on anything but an error, so the entry is + forgotten first — the smallest honest way to say "ask again". */ + { do: `(function () { delete __ss.catalog.albums[302127]; return __ssMock.fail('catalog_album', 'io: connection reset by peer'); })()`, wait: 300 }, + { do: `__ss.push('catalogAlbum', { id: 302127, artist: 'Daft Punk', title: 'Discovery' })`, name: "faces-album-down", wait: 1500 }, + { do: `__ssMock.fail('catalog_album', '')`, wait: 300 }, + { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-album-again", wait: 1600 }, + { do: `__ss.back()`, wait: 700 }, + /* the artist page has TWO of these and they say different things: a + lookup that FAILED is "didn't answer", one that came back empty is + "had nothing to add". `bestArtistFor` is what has to fail for the + first — failing `catalog_artist` alone never reaches the page at all + and leaves the status at none, which is the other copy. */ + { do: `__ssMock.fail('catalog_search', 'io: failed to lookup address information: no address associated with hostname')`, wait: 250 }, + { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-down", wait: 1600 }, + { do: `(function () { __ssMock.fail('catalog_search', ''); __ss.back(); return true; })()`, wait: 700 }, + { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-none", wait: 1600 }, + { do: `__ss.back()`, wait: 700 }, + /* a playlist that will not read. Id 1 survives the whole walk — it is + the one renamed to "Late night drive II"; the one deleted was New + Playlist. Try again clears `loadErr` and bumps `again`, which is + what re-arms the effect. */ + { do: `__ssMock.fail('playlist_items', 'unable to open database file')`, wait: 250 }, + { do: `__ss.push('playlist', { id: 1 })`, name: "faces-playlist-down", wait: 1400 }, + { do: `__ssMock.fail('playlist_items', '')`, wait: 300 }, + { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-playlist-again", wait: 1400 }, + { do: `__ss.back()`, wait: 700 }, + /* the lyrics face. Sheet.svelte asks with the UN-forced `loadLyrics()`, + which returns at once while `lyr.key` is already this track's — so + the face alone can never ask again, which is exactly why round + thirty-one could not drive this one. The loader, forced. */ + { do: `(function () { __ss.openSheet(); return true; })()`, wait: 900 }, + { do: `document.querySelectorAll('.fbtn')[1].click()`, wait: 900 }, + { do: `(function () { __ssMock.fail('lyrics_get', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadLyrics(true); })()`, name: "faces-lyrics-down", wait: 1200 }, + { do: `(function () { __ssMock.fail('lyrics_get', ''); return true; })()`, wait: 250 }, + { do: `__ss.loadLyrics(true)`, name: "faces-lyrics-again", wait: 1200 }, + { do: `(function () { document.querySelectorAll('.fbtn')[0].click(); __ss.closeSheet(); return true; })()`, wait: 700 }, + /* a transfer that was refused, written straight into the store the way + the shade steps above do — nothing is fetched, and `failed` is in + DONE, so `xfernote.shape()` sees nothing in flight and posts no + notice. The id is far above the backend's own counter, and `list()` + sorts id-descending, so this row is the first one on the screen. */ + { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "faces-xfer-failed", wait: 1300 }, + { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, wait: 800 }, + /* the one toast action round thirty-one armed that no step had ever + pressed. `lib.error` is written by `loadLibrary` alone — + `watchLibrary`'s own reload swallows its failure — so You → Rescan + library is the only door in the app that reaches it twice. */ + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, + { do: `__ssMock.fail('library_albums', 'unable to open database file')`, wait: 250 }, + { do: `__click('.screen .row', 'Rescan library')`, name: "faces-library-toast", wait: 1500 }, + { do: `__ssMock.fail('library_albums', '')`, wait: 300 }, + { do: `__click('.toast:not(.notice) .na', 'Try again')`, name: "faces-library-again", wait: 1400 }, + /* and all of it put away, so the first run below starts exactly where + it would have started without this block at all */ + { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; __settle(); return __ss.loadHome(true); })()`, wait: 900 }, + { name: "faces-clear", wait: 1200 }, diff --git a/handoff/rounds/ins-r40-doc-map.txt b/handoff/rounds/ins-r40-doc-map.txt new file mode 100644 index 0000000..0d0e579 --- /dev/null +++ b/handoff/rounds/ins-r40-doc-map.txt @@ -0,0 +1,103 @@ +### What each walk actually proves + +The two step lists are not the same test, and which is which had never been +written down. The mock walk is the bigger one (252 named steps) and the only +one that can arm a failure, turn the phone over or answer as a fresh install. +The device walk (121 named steps, up from 88) is the smaller one and the only +one that touches a real core, a real library, a real audio thread and the +system underneath them. The gap between them is where this list lives. + +Writing it down turned up four phone steps — `library-ambience`, `queue`, +`lyrics` and `community` — that were named, screenshotted, and asserted +against nothing at all: the driver reads `expect[s.name] ? expect[s.name](info) +: true`, so a missing key in `PHONE_EXPECT` is a silent pass, and all four had +one. They have expectations now. + +**Proven on the device.** Boot and its census; playback out of the real +library; the ambience following a real cover — the accent is in the still and +asserted by neither walk; the artwork viewer; the queue reordered, and a queue +row's sheet opened; the lyrics face, the nudge, the per-track auto-sync +switch, the tap room and the real-DSP estimate (all of it discarded); a reload +that keeps the audio thread; a library track's own sheet, off a real path; the +Library's shape, its sort and every one of its five tabs; Search and its +recents; a playlist made, filled, dragged, a row removed and put back by Undo, +filtered and deleted; four import doors — including the folder door on a real +folder, and two of their system pickers dismissed with BACK — and the review's +Keep-unresolved switch; the share-sheet intent; six of You's switches and +chips written to the real config and read back out of it, then put back — +volume normalization, word-by-word fill, the upload slots, the streaming +ceiling, the embedded-cover size and the stream cache's retention — and the +listening port both ways and a refused one; the sleep timer; the Wi-Fi-only +guard; the shade's notice read back out of `dumpsys notification`; contacts +read over real IPC; a cold artist page, its ⋯, the whole-discography page and +its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend +strings through `__ss.say`; and a refused transfer's sentence. + +Then, after the step loop, seven things the mock cannot do at all: a +force-stop and the paused restore that follows it (waited out past the +thirteen-second FGS-kill window), the position restored, playback resumed, the +system media session and its ownership, media keys, ten seconds in the +background with the position still advancing, and SoulStream's own mixer +underruns. + +One block in the phone walk pulls bytes, and only when the app is signed in: +the trailing network block types `--query` into Search, streams a peer's copy +into the stream cache, and browses the raw peers and a share. Nothing else in +either list downloads, joins, sends or signs in. + +**Mock-only, and why** (178 named steps). These are the reasons rather than a +partition: a step can sit under more than one of them, and the last bullet is +the only one that says a step could move and has not. + +- *Needs `__ssMock`* — the nine weather steps and the fifteen failure faces. + That object is the mock library's own door and does not exist in a real + build. The SENTENCES those screens print are proven on the device anyway, by + `trouble-words` and `trouble-xfer`; the screens that carry them are not. +- *Needs a fresh install* — the ten `fresh-*` steps. On the device only the + preview door is walked, and it writes nothing. +- *Needs a viewport override* — the nine landscape and portrait steps. A + `view` step on the phone walk is a deliberate failure, because a real + device's orientation is not the harness's to set, so the sideways layout has + never been seen on a device at all. +- *Needs the dev flavour* — `diag` and `diag-back`. `devBuild` is + `import.meta.env.DEV` or the mock key, and the embedded build Kyle carries + reads false, so the row is not there to tap. +- *Would download, save or rewrite his files* — the catalog, peer, browse and + Save steps, the cover re-fetch, the portrait refresh, chosen and pasted + lyrics, a saved sync, the vocal-reduced copy, a hero photo set. +- *Would join, send or sign in* — rooms and their users, buddies, the contact + editor, the taste chips, a user looked up. +- *Would change what is playing* — `queue-removed` and `queue-moved-up`. The + device opens that sheet and reads it; it does not tap a row in it. +- *Destroys his data* — `history-confirm` and `history-cleared`. Never on his + phone. +- *Structurally empty on this phone* — Home's feed sections and the Gaps + shelf. Follows live in the library folder and the phone's own folder starts + without any, so on the device `gaps` can only be asked to render and to stay + quiet. +- *No reason but time* — not every remaining step has a reason above it. + These would be safe on the device and simply have not been moved: the + sheet's own Move-up and Move-down rows and a drag refused under a filter + (all three on the walk's own playlist), rename, unpin, `wifi-off`, the + empty search and the did-you-mean pair (both wanting a query his own + library can answer for), and the several `-again` steps that only put a + screen back. This round stopped where it stopped. + +**Conditional on the device.** Six of the new steps pass vacuously when his +own data cannot produce the condition, and are named here so they do not +become the next generation of silent passes: `gaps` (no follows), +`queue-row-sheet` (nothing after the walked track), `playlist-drag-held` and +the drag-refusal clause of `playlist-filter` (both need a playlist longer than +one row — a one-track record makes a one-row playlist, and `reorder.js` +refuses to lift a list that short), `import-keep-off` (a paste that resolved +completely), and `you-keep` (the cache already set to Forever, which the step +will not move because coming back writes the flag and not the number). The row +count `playlist-row-sheet` stashes is what keeps the rest of that block from +passing on a probe that read nothing. + +**Needs a person.** A live room joined and a message sent; a real Save over +cellular with the Wi-Fi switch on; the lock-screen card (`--lockscreen` takes +that still and asserts nothing about it); the first real export written +through Android's create-document sheet, which the walk only ever dismisses +with BACK; the phone physically turned sideways; and a genuine first run on a +device that has never had the app. diff --git a/handoff/rounds/ins-r40-phone-block.txt b/handoff/rounds/ins-r40-phone-block.txt new file mode 100644 index 0000000..a8b4c86 --- /dev/null +++ b/handoff/rounds/ins-r40-phone-block.txt @@ -0,0 +1,104 @@ + /* ── what the device had never been asked ────────────────────────── + Every step below was proven in the mock and nowhere else: the + library's own shape and its sort, a library TRACK's sheet (the + phone had only ever held a CARD), Search's memory, a queue row's + sheet, Home's feed, the sleep timer, the stream cache's chip, the + Wi-Fi-only guard and a port the core has to refuse. + + It navigates itself from Home, so it needs no particular screen in + front of it, and every write it makes it takes back inside itself: + the Library sort is nav state (the entry's own `c.sort`), the sleep + timer is put out by the step after the one that reads it, the cache + chip and the Wi-Fi switch are read first and restored last, and the + refused port writes nothing at all. Nothing here downloads, joins, + sends, signs in, or touches Kyle's library. */ + { do: `__settle()`, wait: 900 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, name: "library", wait: 900 }, + { do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 700 }, + { do: `__click('.chips .chip', 'Recently added')`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 900 }, + /* Gaps is tolerant on purpose: follows live in the library folder and + the phone's own folder starts without any, so the tab can only be + asked to render and to stay quiet. Opening it asks the network for + nothing — each shelf's lookup is behind that shelf's own tap. */ + { do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 1200 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 600 }, + { do: `document.querySelector('.grid2 .release').click()`, wait: 1600 }, + /* a LOCAL track's own sheet, off a real path: Play next, Add to queue, + Add to playlist, and the Format/File/Folder block underneath them. + None of it is tapped — the scrim closes it, the page is popped. */ + { do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + { do: `history.back()`, wait: 800 }, + /* Search's memory, which on the device is this WebView's localStorage. + The chips are the walk's OWN earlier query: the field commits on + Enter or when the screen is left, and the walk left it several + screens ago. Clearing the field is what brings the empty page — + and its recents — back up. */ + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 800 }, + { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, name: "search-recent", wait: 900 }, + { do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1600 }, + { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, wait: 700 }, + /* a queue row's sheet. The walk has dragged these rows for two rounds + and never opened one. Tolerant: the walked track can be the last + queueable row, and then there is no row to press. Nothing in the + sheet is tapped — its rows all mutate the queue. */ + { do: `(function () { var m = document.querySelector('.mini'); if (m) m.click(); return true; })()`, wait: 1200 }, + { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue-face", wait: 900 }, + { do: `(function () { var r = document.querySelector('.q-face .row'); if (r) r.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return !!r; })()`, name: "queue-row-sheet", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 }, + { do: `__ss.closeSheet()`, wait: 700 }, + /* Home's own feed on the real core, and then the sleep timer — the one + thing in this block with a life of its own. It is set, read back off + the More sheet's Sleep-timer subtitle (which IS `sleepLabel()`, and + the only proof the phone can give that the timer took), and put out + by the very next step, with nothing between the two: a walk that + died in that gap would leave a pause waiting on his playback — so + it is the SHORTEST chip in the sheet, not a comfortable one. */ + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-feed", wait: 1200 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 900 }, + { do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 800 }, + { do: `__click('.halfsheet .chip', '15 min')`, name: "sleep-set", wait: 800 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-armed", wait: 900 }, + { do: `(function () { function pick(sel, re) { return [].slice.call(document.querySelectorAll(sel)).find(function (x) { return re.test((x.textContent || '').trim()); }); } var r = pick('.halfsheet .row', /Sleep timer/); if (r) r.click(); return new Promise(function (res) { setTimeout(function () { var c = pick('.halfsheet .chip', /^Off$/); if (c) c.click(); res(!!c); }, 400); }); })()`, name: "sleep-off", wait: 900 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-cleared", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + /* You: the stream cache's retention chip, read first and put back + last. The value is stashed IN THE PAGE as well as in the harness, + because a step's script is fixed before the walk starts and cannot + carry the harness's copy. The last chip is never clicked — "Forever" + does not lengthen the cache, it saves streamed tracks into the + library — so the move is to the neighbour above, or below it when + there is no room above. And when Forever is the chip that is ON, + nothing is touched at all: setRetention("forever") sends the FLAG + and not the number, so a move away and back would put the switch + back and leave `stream_retention_days` on whatever the move wrote. + CONDITIONAL, then, on his own setting — and the expectation knows + which case it is in from the label `you-keep-arm` stashed. */ + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 900 }, + { name: "you-keep-arm", wait: 500 }, + { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var at = cs.findIndex(function (c) { return c.classList.contains('on'); }); if (at < 0) return ''; window.__keep0 = cs[at].textContent.trim(); if (at === cs.length - 1) return window.__keep0; var pick = (at + 1 < cs.length - 1) ? cs[at + 1] : cs[at - 1]; if (pick) pick.click(); return window.__keep0; })()`, name: "you-keep", wait: 900 }, + { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var back = cs.find(function (c) { return c.textContent.trim() === window.__keep0; }); if (back && !back.classList.contains('on')) back.click(); return window.__keep0; })()`, name: "you-keep-back", wait: 900 }, + /* the one guard that exists only because this is a phone. The switch + is this device's localStorage, not the config; `cellular()` reads + `navigator.connection`, so a page-level shadow is the whole fake and + a delete is the whole restore — set and removed one step apart, and + a reload would drop it anyway. If defineProperty throws in the + WebView the catch swallows it, the download is allowed, and the step + fails loudly rather than doing anything. The switch goes back to + whatever Kyle had it on. */ + { name: "wifi-arm", wait: 500 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); window.__wifi0 = r.querySelector('.sw.on') ? 'on' : 'off'; if (window.__wifi0 === 'off') r.click(); return window.__wifi0; })()`, name: "wifi-on", wait: 800 }, + { do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 800 }, + { do: `(function () { try { delete navigator.connection; } catch (e) {} var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); __ss.player.error = ''; return true; })()`, name: "wifi-cleared", wait: 800 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); var now = r.querySelector('.sw.on') ? 'on' : 'off'; if (now !== window.__wifi0) r.click(); return window.__wifi0; })()`, name: "wifi-restored", wait: 800 }, + /* a port the core has to refuse. Nothing is written, and 50300 — the + client's own default, which the port steps above already left + standing — is still standing afterwards. */ + { do: `__click('.screen .row', 'Listening port')`, wait: 800 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 1000 }, + { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 600 }, + { do: `__settle()`, wait: 900 }, diff --git a/handoff/rounds/ins-r40-phone-expect.txt b/handoff/rounds/ins-r40-phone-expect.txt new file mode 100644 index 0000000..e29c72b --- /dev/null +++ b/handoff/rounds/ins-r40-phone-expect.txt @@ -0,0 +1,113 @@ + /* ── round forty: the four steps that could not fail ─────────────── + `library-ambience`, `queue`, `lyrics` and `community` have been + named phone steps with no key in this map since the rounds that + wrote them. The driver reads `expect[s.name] ? expect[s.name](info) + : true`, so each one was a screenshot that always passed — four of + eighty-eight. All four have a mock expectation; none had a phone + one. Each is written to hold against Kyle's own library rather than + a fixture. */ + "library-ambience": (i) => i.title.startsWith("Your library") && i.amb.includes("on:img"), + queue: (i) => i.sheet.includes("up") && i.qFace, + /* the walked track may genuinely have no words on the device, so this + asks for the face and any one of its three honest states */ + lyrics: (i) => i.sheet.includes("up") && (i.lyrLines >= 1 || !!i.lyrNote || i.lyrTools), + /* tolerant of the sign-in card, which is what a signed-out phone shows + here — but NOT of a toast: a sign-in that failed raises one on this + screen, and that is a finding rather than a step to loosen */ + community: (i) => i.title.startsWith("Community") && !i.toast, + /* ── the walk's own playlist, on real SQLite ───────────────────── + The row count is stashed by the sheet step rather than assumed: the + playlist is made out of whichever record the walk picked, so its + length is his library's business — and a probe that read nothing + would otherwise pass every clause below it. */ + "playlist-row-sheet": (i) => { globalThis.__plN = i.plrows; return i.half.includes("up") && /Remove from playlist/.test(i.halfText); }, + "playlist-removed": (i) => i.plrows === globalThis.__plN - 1 && /Removed/.test(i.notice) && /Undo/.test(i.announce), + /* the undo stash round-tripped through the real backend for the first + time: the row is back, in the place it was taken from. `__pl0` is + the order `playlist-drag-arm` stashed before the two drags, which + `playlist-dragged-back` has already put back. */ + "playlist-undone": (i) => i.plrows === globalThis.__plN && !i.notice && (globalThis.__plN < 2 || i.plOrder === globalThis.__pl0), + /* CONDITIONAL: `lift()` refuses a list of fewer than two rows, so a + one-track record — which makes a one-row playlist — has no press to + release. The two drag steps above tolerate exactly the same thing. */ + "playlist-drag-held": (i) => globalThis.__plN < 2 || (i.half.includes("up") && /Remove from playlist/.test(i.halfText)), + /* CONDITIONAL in its last clause: the drag-refusal note needs more + than one row to drag. The note itself hangs off the UNFILTERED + list, so a filter that matched nothing still shows it. */ + "playlist-filter": (i) => i.plrows === 0 && /Nothing here matches/.test(i.stubText) && (globalThis.__plN < 2 || /Clear the search/.test(i.plNote)), + "playlist-filter-cleared": (i) => i.plrows === globalThis.__plN && !i.plNote, + /* ── the review's Keep switch ──────────────────────────────────── + CONDITIONAL: the row renders only when something did not resolve, + and a paste that resolved completely leaves `keepOn` false anyway. + The second step is what guarantees `import-created` below still + sees the three rows it has always seen. */ + "import-keep-off": (i) => !i.keepOn && /Create playlist \(\d+ tracks?\)/.test(i.impBtn), + "import-keep-on": (i) => /Create playlist \(3 tracks\)/.test(i.impBtn) && i.imp === 3, + /* ── the library's own shape, on the device for the first time ──── */ + /* `a11yBad` is left out for the reason `track-sheet` leaves it out: a + card's name is [title, artist, badge] off his own tags, and `junk()` + calls a name of nothing but digits bad — "1999, 311" is a real + record. The mock asserts it against a fixture, where it belongs. + The structural three stay: nameless, ghost and mis-stated. */ + library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 1 && /hours|min/.test(i.countline) + && !i.a11yAnon && !i.a11yGhost && !i.a11yState, + /* the sort lives on the Library entry (`c.sort`), not in the config, + so it needs no putting back — the chip after it does that anyway. + What a real library's top format IS cannot be asserted; that the + grid re-sorted and still badges every card can be. */ + "library-by-format": (i) => !!i.firstBadge && i.fmtBadges >= 1 && !i.toast && !i.a11yState, + artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 1 && !i.toast, + /* CONDITIONAL: follows live in the library folder and the phone's own + folder starts without any, so a phone with no follows can only be + asked for the stub */ + gaps: (i) => i.title.startsWith("Your library") && !i.toast && (i.gaplines >= 1 || !!i.stubText), + /* a LOCAL track's sheet: the three things it offers, the file block + underneath it off a real path, and the layer named and holding + focus. `a11yBad` is left out deliberately — it reads the names of + real rows, and a record of his is allowed to be called anything. */ + "track-sheet": (i) => i.half.includes("up") && /Play next/.test(i.halfText) && /Add to queue/.test(i.halfText) + && /Add to playlist/.test(i.halfText) && /Format/.test(i.halfText) && /Folder/.test(i.halfText) + && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yGhost, + /* the recents are localStorage on the device, written by the walk's + own earlier search when that screen was left */ + "search-recent": (i) => i.title.startsWith("Search") && i.sq === "" && i.recents >= 1, + "search-recent-pick": (i) => i.title.startsWith("Search") && !!i.sq, + "queue-face": (i) => i.sheet.includes("up") && i.qFace, + /* CONDITIONAL: the walked track can be the last queueable row, and + then there is nothing to press. Asserted exactly when there is. */ + "queue-row-sheet": (i) => !i.half.includes("up") || (/Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText)), + /* Home's feed on a phone whose library folder holds no follows: the + store answered, and not one of its slots came back with an error */ + "home-feed": (i) => /^(Good |Still)/.test(i.title) && !i.toast && i.homeSlots >= 0 && i.homeErr === 0, + "home-more": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText), + "sleep-sheet": (i) => i.half.includes("up") && /Pause after/.test(i.halfText) && /1½ hours/.test(i.halfText), + /* every chip in that sheet goes through `act()`, which closes it — so + this says nothing at all about the timer. The step after it does. */ + "sleep-set": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), + /* the proof: the More sheet's Sleep-timer subtitle IS `sleepLabel()`, + and the only thing that can make it read "N min left" is a + `player.sleepAt` in the future */ + "sleep-armed": (i) => i.half.includes("up") && /min left/.test(i.halfText), + "sleep-off": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), + "sleep-cleared": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText) && !/min left/.test(i.halfText), + /* the retention chip: read, moved, put back. The harness's stash and + the page's own (`window.__keep0`) hold the same value for the same + reason the drag steps keep two — a step's script is fixed before + the walk starts and cannot carry this one. */ + "you-keep-arm": (i) => { globalThis.__keepWas = i.keep; return !!i.keep; }, + /* CONDITIONAL: the step moves nothing when "Forever" is the chip that + is on, because the way back writes the flag and not the number. The + label is what says which case this is. */ + "you-keep": (i) => !!i.keep && (/^Forever/.test(globalThis.__keepWas) ? i.keep === globalThis.__keepWas : i.keep !== globalThis.__keepWas), + "you-keep-back": (i) => i.keep === globalThis.__keepWas, + /* the Wi-Fi-only guard, which exists only because this is a phone */ + "wifi-arm": (i) => { globalThis.__wifiWas = i.wifiOnly; return !!i.wifiOnly; }, + "wifi-on": (i) => i.wifiOnly === "on", + "wifi-refused": (i) => /Wi-Fi/.test(i.toast), + /* the shadow removed and the sentence taken off the bar, with the + switch still where the step above put it */ + "wifi-cleared": (i) => !i.toast && i.wifiOnly === "on", + "wifi-restored": (i) => i.wifiOnly === globalThis.__wifiWas, + /* a port the core refuses: nothing written, and the number the walk + already left standing still standing */ + "you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50300 /.test(i.port), diff --git a/handoff/rounds/ins-r40-phone-keep.txt b/handoff/rounds/ins-r40-phone-keep.txt new file mode 100644 index 0000000..073c2ff --- /dev/null +++ b/handoff/rounds/ins-r40-phone-keep.txt @@ -0,0 +1,8 @@ + /* the review's one switch, which renders only when a line did not + resolve: off, and the button's count drops with it; on again, so the + draft the next step creates is the three rows it has always been. + Found and clicked by hand rather than through `__click`, which + THROWS when the row is not there — a paste that resolved completely + has nothing to switch, and that is not a failure. */ + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-off", wait: 700 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-on", wait: 700 }, diff --git a/handoff/rounds/ins-r40-phone-playlist.txt b/handoff/rounds/ins-r40-phone-playlist.txt new file mode 100644 index 0000000..8be3ac9 --- /dev/null +++ b/handoff/rounds/ins-r40-phone-playlist.txt @@ -0,0 +1,19 @@ + /* the walk's own playlist, on the real core: a row's sheet, the row + removed and put back by Undo — the first time that stash has made + the round trip through real SQLite — the same sheet off a real + press, and the filter. Everything here acts on "Walk test", which + the walk made four steps ago and deletes eight steps below, and the + order is the one the drags above restored. */ + { do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 900 }, + { do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 }, + { do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1600 }, + /* a press released without moving is still the row's sheet — on real + touch geometry this time, not a synthesised contextmenu */ + { do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + /* a filter that matches nothing: the rows go and the stub says so. The + drag-refusal note hangs off the UNFILTERED list, so it is up here + whatever the filter matched — which is what makes this readable + against a playlist made out of whichever record the walk picked. */ + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'zzqq'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 700 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter-cleared", wait: 700 }, diff --git a/handoff/rounds/ins-r41-art-tally.txt b/handoff/rounds/ins-r41-art-tally.txt new file mode 100644 index 0000000..c24334c --- /dev/null +++ b/handoff/rounds/ins-r41-art-tally.txt @@ -0,0 +1,11 @@ +/* ── what this module is holding ──────────────────────────────────────── + The map and its three side tables are module-private, so nothing outside + this file can see whether they grow. The walk reads this before and after + a long session and asserts the difference. A pure read — it fetches + nothing, evicts nothing and is safe to call on Kyle's own phone. */ +export function artTally() { + const keys = Object.keys(art); + let chars = 0; + for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; } + return { art: keys.length, chars, accents: accents.size, pending: pending.size, waiting: waiting.length, max: ART_MAX }; +} diff --git a/handoff/rounds/ins-r41-art-trim.txt b/handoff/rounds/ins-r41-art-trim.txt new file mode 100644 index 0000000..298c1f6 --- /dev/null +++ b/handoff/rounds/ins-r41-art-trim.txt @@ -0,0 +1,72 @@ +/* ── how much of this the phone may hold ──────────────────────────────── + The desktop bounds the identical map with these two numbers + (src/lib/state.svelte.js: ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS, + trimmed in insertion order by trimResolvedCache). Kept exactly as they + are rather than invented here: `album_art` answers with base64 JPEG of a + 360px thumbnail on both, so 512 covers is the same budget either side. + Until now nothing on the phone gave a byte of it back — one browse of a + 114-cover library retains every one of them for the WebView's lifetime, + and a catalog crawl adds to the same map without limit. */ +const ART_MAX = 512; +const ART_MAX_CHARS = 16 * 1024 * 1024; + +/* The desktop may drop any cold entry, because every caller there asks + through getArt(path). The phone's templates INDEX this map — art[path] in + Cover, Release, Sheet, Viewer, Artist, Discography, Home and the media + card — so a key something is painting would blank on the spot, and + Cover's `visible` action re-arms only when its path PROP changes, so an + evicted cover that is still mounted would never come back at all. Hence + two rules the desktop does not need: never the entry that has just + landed, and never one that a connected is showing. Oldest first + otherwise — Object.keys is insertion order here, because every key is a + file path, an https URL or a "stream:" string and never integer-like, + which would be hoisted to the front and go first. A remembered failure + ("" so it is not retried) is evictable like anything else: past the cap + a retry is the cheaper of the two. + + The map is allowed to sit OVER its cap for as long as one screen needs + all of it — a mounted grid of 600 covers loses none of them — and it + comes down the moment that screen goes. + + Called from pump() and nowhere else, which is never inside an $effect: + Object.keys on a $state proxy reads the version signal, so an effect + that called this would re-run on every cover that lands. The two + writers that do not go through pump() — loadArt's data: short-circuit + and a stream's tag art in player.svelte.js — are therefore bounded at + the next cover that does, which on any screen worth the name is the + next one. Under the cap + it is a key count and a char sum and returns; the pass over the screen's + images is built once, lazily, and only when there is something to drop, + because reading a base64 src is a copy of the whole data URL. */ +function trimArt(justAdded) { + const keys = Object.keys(art); + let chars = 0; + for (const k of keys) { const v = art[k]; if (typeof v === "string") chars += v.length; } + let live = keys.length; + if (live <= ART_MAX && chars <= ART_MAX_CHARS) return; + let painted = null; + for (const k of keys) { + if (live <= ART_MAX && chars <= ART_MAX_CHARS) break; + if (k === justAdded) continue; + const v = art[k]; + if (v) { + if (!painted) { + painted = new Set(); + /* the attribute, not the .src property: for a data URL they + are the same string and the property re-serializes it. + The fallback is for an whose src was set as a + property instead — none today, and a miss here is the one + way this could blank a cover. */ + for (const img of document.images) { + if (!img.isConnected) continue; + const src = img.getAttribute("src") || img.src; + if (src) painted.add(src); + } + } + if (painted.has(v)) continue; + } + delete art[k]; + if (typeof v === "string") chars -= v.length; + live--; + } +} diff --git a/handoff/rounds/ins-r41-doc.txt b/handoff/rounds/ins-r41-doc.txt new file mode 100644 index 0000000..2135555 --- /dev/null +++ b/handoff/rounds/ins-r41-doc.txt @@ -0,0 +1,111 @@ +The audio side had its own lag: `dumpsys media.audio_flinger` showed the +track on the fast mixer with runs of ~55 ms underruns. The 8192-frame buffer +above moved it to the normal mixer; the phone walk samples the underrun +counters before and after and fails if they rose. + +### The long session + +Every walk is minutes long; a phone runs the app for hours, and nothing in +the port had ever been looked at for a leak. It has now, by driving the real +bundle in headless Chrome for lap after lap of the whole app — five tabs, +the Library, a record, playback, the mini bar, all three faces of the sheet, +the artwork viewer, a row's half sheet, an artist page — and counting what +the app was still holding at the start and at the end. + +**Listeners and timers do not leak, and the obvious counter says they do.** +With a registry installed at document start — an investigation, not what +ships — add minus remove climbs 23 → 119 over eight laps, about twelve a +lap, almost all of them `scroll`. None of it is a leak: `{#key nav.seq}` +replaces the `.screen` element on every navigation, so each new screen gets +its own handler and the old element is dropped whole. Counting only what is +still ATTACHED — `window`, `document`, or an element that is still +connected — gives 29 at the first lap and 29 at the eighth, key for key. +Intervals are the same story: two at boot (the media card's 30 s, the +player's 10 s session writer), three from the first lap on, and three at the +last; `Lane` and `Diagnostics` both clear theirs in their effect teardown, +and every `bind*()` in `src/mobile/` is guarded by a module-level `bound` +flag, so no Tauri subscription is ever registered twice. + +**What the walk ships is narrower than that, and says so.** A script +registered with `Page.addScriptToEvaluateOnNewDocument` does not survive the +first-run block's reloads — the re-attach opens a new DevTools session — so +the registry the walk installs goes in as a STEP, after everything else, and +can only see what is registered from that moment on. Its absolute counts are +therefore small and depend on how much ran first: `tallyEars` reads 2 at the +end of the full mock walk and 8 from a settled boot, `tallyTicks` 1 in both +(the mock's own playback ticker). Those numbers are not a census of the app +and are not asserted as one. What is asserted is the DELTA across the laps, +which is where a per-navigation leak shows, and that was proved by planting +one: a listener and an interval stranded once a lap take `grewEars` 0 → 9 +and `grewTicks` 0 → 9, and the step fails. + +**The thumbnail map did leak, and is bounded now.** `src/mobile/art.svelte.js` +kept every cover it had ever fetched for the WebView's lifetime: one browse +of a 114-cover library took it from 5 keys to 114 and gave nothing back, and +a catalog crawl adds to the same map without limit. A 360 px cover comes +back from `album_art` as base64 JPEG — about 63 KB of string, measured +against this machine's own thumbnail cache — so that browse alone is roughly +7 MB retained. The bound is the desktop's, by its own numbers: +`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB from +`src/lib/state.svelte.js`, trimmed oldest first. Two rules the desktop does +not need are added, because the desktop's callers ask through `getArt(path)` +while the phone's templates INDEX the map (`art[path]` in Cover, Release, +Sheet, Viewer, Artist, Discography, Home and the media card): the entry that +has just arrived is never dropped, and neither is one a connected `` is +painting — `Cover`'s `visible` action re-arms only when its path PROP +changes, so an evicted cover that is still mounted would stay a disc icon +for good. The few places that paint a cover as a `background-image` rather +than an `` — the ground, the sheet's wash, the artist hero — are +covered twice over: each of them reads `art[path]` inside an effect that +calls `wantArt`, so an eviction under one re-fetches on its own. The cache +is therefore allowed to sit over its cap for as long as one screen needs all +of it, and comes down the moment that screen goes. Built with the cap forced +to 8 and scrolled through eighteen screens of a 140-card Library grid, the +map sat at 61 — every one of them spared for being painted — and not one +cover on screen lost its image at any point; the cards that had scrolled +away repaint cold and fill as they come back, which is what a cold first +visit already looks like. At the shipped cap of 512 a whole library walk +runs the trim zero times and the census is identical either way: on Kyle's +phone the change is inert, and it is the crawl past 512 covers that it +exists for. The accent table gets the same cap — an accent is a pure +function of its cover, so dropping one costs a recompute and nothing else. + +**Playlist covers.** `pls.covers` is keyed `id:revision`, and an edit bumps +the revision, so every edit stranded one 360 px data URL under a key nothing +could ever read again. The new cover now drops that playlist's other +revisions as it lands — no cap and no policy, just the keys that are +unreachable by construction. + +**Three things are unbounded on purpose.** `catalog.svelte.js`'s ten maps are +cached for the session by design, and evicting from them is not safe the way +the art map is: `Artist.svelte` calls `lookupArtist` inside `untrack`, so a +page whose entry went missing under it would never look it up again. +`lib.svelte.js`'s `tracksCache` is unbounded but finite — it is bounded by +the library. And `player.svelte.js`'s `scheduleRecents` leaves a 47-second +timeout per track start, which self-drains and is the desktop's own shape; +coalescing the two callers into one slot would cancel one of them, so it is +watched under a ceiling instead of changed. + +The walk measures all of it. Every step prints `tallyArt`, `tallyChars`, +`tallyAcc`, `tallyPend`, `tallyCat`, `tallyQueue`, `tallyXfer` and +`tallyNodes` from the app itself (`artTally()` on the `__ss` handle, because +the map and its side tables are module-private), and `tallyEars`, +`tallyTimers`, `tallyTicks` plus the `grew*` deltas from a registry the +session block installs in the page. The registry is installed by a STEP, not +by `Page.addScriptToEvaluateOnNewDocument`: the first-run block reloads three +times and the re-attach opens a new DevTools session, so a script registered +on the old one would be gone. The block's last step takes it out again — +`EventTarget` and the four timer functions restored, `__ssCount` and the +baseline deleted — so nothing appended after it inherits a patched platform, +and every step after the block reads −1 for those fields, as does the whole +phone walk, where it is never installed at all. The assertion is a threshold +with headroom, not a stopwatch — the listener and interval counts are held +flat, the art map is held under its cap, and `tallyTimers` is a ceiling +rather than a delta because the recents window keeps a handful pending +during a walk this fast. Across eleven laps of the block as it ships, driven +against a real build of this tree, the listeners and the intervals moved by +0, the timers stood at 9 against a ceiling of 32, the art map grew 17 +against a headroom of 80 and the catalog 5 against 24, and the DOM ended 10 +nodes smaller than it started. + +## Prerequisites (home machine, done 2026-09-12) diff --git a/handoff/rounds/ins-r41-expect.txt b/handoff/rounds/ins-r41-expect.txt new file mode 100644 index 0000000..ac0544a --- /dev/null +++ b/handoff/rounds/ins-r41-expect.txt @@ -0,0 +1,29 @@ + /* the instrument itself, before anything is asked of it: the registry + is counting, the store is answering, and the walk is not sitting on a + toast from the block above. tallyTicks is only asked to ANSWER (-1 is + 'no registry'), never to be non-zero: the app's own intervals were + all started at boot, before the registry went in, so the only live + one here belongs to the mock's playback ticker — requiring it would + tie this guard to the mock instead of to the registry. */ + "session-warm": (i) => i.tallyEars > 0 && i.tallyTicks >= 0 && i.tallyArt > 0 && !i.toast, + /* Nine laps later. The listener and interval counts are the sharp ones — + both measured dead flat over the nine, twice — so their headroom is + small on purpose; grewEars allows four for a screen that happens to + be mounted at the second reading and not the first, and grewTicks is + one-sided because a ticker that simply stopped between the readings + is not a leak. Both were driven with a listener and an interval + deliberately stranded once a lap: +9 and +9, and this line failed. + tallyTimers is a CEILING rather than a delta, and nine of the + thirty-two are real: every track start schedules a 47-second + recents refresh (player.svelte.js, the desktop's own shape), so a + walk this fast keeps a dozen of them pending at once. That window is + bounded by its own length and does not grow with the session, while a + genuinely leaking timer climbs past this ceiling as laps are added. + Making it a delta would need a fifty-second quiet period between the + two readings, which is not worth the minute — said here rather than + tightening the number blind. tallyArt is the bound itself; grewArt's + headroom is for a sibling round that puts more records in a lap, and + is to be raised deliberately rather than deleted. */ + "session-late": (i) => i.grewEars <= 4 && i.grewTicks <= 0 && i.tallyTimers <= 32 + && i.tallyArt <= 512 && i.grewArt <= 80 && i.grewCat <= 24 && i.grewNodes <= 200, + "session-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"), diff --git a/handoff/rounds/ins-r41-probe.txt b/handoff/rounds/ins-r41-probe.txt new file mode 100644 index 0000000..03cd71b --- /dev/null +++ b/handoff/rounds/ins-r41-probe.txt @@ -0,0 +1,64 @@ + /* ── the long session: what the app is still HOLDING ───────────── + Minutes is all a walk has ever been. These are the numbers that + decide what hours look like: the thumbnail map and its side + tables (from the store itself — they are module-private, so + artTally is the only way to see them), the catalog's ten + session maps, the queue, the transfer list, the DOM, and the + live listeners and timers. + + The listener and timer counts come from a registry that a step + installs IN THE PAGE, not from here: they are -1 until it is + installed, which means every step before the session block, every + step after it puts itself away again, and the whole phone walk, + where it is never installed at all. They count what was + registered AFTER the install and is still ATTACHED — window, + document, or an element that is connected — so the absolute + number is small and depends on how much of the walk ran first; + it is the DELTA across the laps that means anything. Attached + rather than add-minus-remove because that counter is a liar in + this app: {#key nav.seq} replaces the .screen element on every + navigation, so each new screen gets its own scroll handler and + the old element is simply dropped. + + grew* is the same number against whatever the block latched into + window.__ssBase, and -1 when nothing has. Every backslash here is + doubled and there is not one backtick, comment included — this + block lives inside a template literal. */ + tallyArt: (function () { + /* one census per probe, kept in __ssLast for the fields below: + a field of an object literal cannot see its siblings, and + this insert is the only part of the probe this round owns. + All of it is a read; nothing here fetches or evicts. */ + window.__ssCensus = function () { + var out = { art: 0, chars: 0, accents: 0, pending: 0, cat: 0, queue: 0, xfer: 0, nodes: 0, ears: -1, timers: -1, ticks: -1 }; + try { var a = __ss.artTally(); out.art = a.art; out.chars = a.chars; out.accents = a.accents; out.pending = a.pending; /* waiting is a SUBSET of pending, never a second queue */ } catch (e) {} + try { + var c = __ss.catalog; + var ks = ['artists', 'albums', 'extras', 'profiles', 'seeds', 'albumInfo', 'popular', 'heroSeeds', 'overviews', 'listeners']; + for (var i = 0; i < ks.length; i++) out.cat += Object.keys(c[ks[i]] || {}).length; + } catch (e) {} + try { out.queue = __ss.player.queue.length; } catch (e) {} + try { out.xfer = Object.keys(__ss.transfers.items).length; } catch (e) {} + try { out.nodes = document.getElementsByTagName('*').length; } catch (e) {} + try { var k = window.__ssCount(); out.ears = k.listeners; out.timers = k.timeouts; out.ticks = k.intervals; } catch (e) {} + return out; + }; + window.__ssLast = window.__ssCensus(); + return window.__ssLast.art; + })(), + tallyChars: (function () { try { return window.__ssLast.chars; } catch (e) { return 0; } })(), + tallyAcc: (function () { try { return window.__ssLast.accents; } catch (e) { return 0; } })(), + tallyPend: (function () { try { return window.__ssLast.pending; } catch (e) { return 0; } })(), + tallyCat: (function () { try { return window.__ssLast.cat; } catch (e) { return 0; } })(), + tallyQueue: (function () { try { return window.__ssLast.queue; } catch (e) { return 0; } })(), + tallyXfer: (function () { try { return window.__ssLast.xfer; } catch (e) { return 0; } })(), + tallyNodes: (function () { try { return window.__ssLast.nodes; } catch (e) { return 0; } })(), + tallyEars: (function () { try { return window.__ssLast.ears; } catch (e) { return -1; } })(), + tallyTimers: (function () { try { return window.__ssLast.timers; } catch (e) { return -1; } })(), + tallyTicks: (function () { try { return window.__ssLast.ticks; } catch (e) { return -1; } })(), + grewArt: (function () { try { return window.__ssLast.art - window.__ssBase.art; } catch (e) { return -1; } })(), + grewCat: (function () { try { return window.__ssLast.cat - window.__ssBase.cat; } catch (e) { return -1; } })(), + grewEars: (function () { try { return window.__ssLast.ears - window.__ssBase.ears; } catch (e) { return -1; } })(), + grewTimers: (function () { try { return window.__ssLast.timers - window.__ssBase.timers; } catch (e) { return -1; } })(), + grewTicks: (function () { try { return window.__ssLast.ticks - window.__ssBase.ticks; } catch (e) { return -1; } })(), + grewNodes: (function () { try { return window.__ssLast.nodes - window.__ssBase.nodes; } catch (e) { return -1; } })(), diff --git a/handoff/rounds/ins-r41-steps.txt b/handoff/rounds/ins-r41-steps.txt new file mode 100644 index 0000000..cb49663 --- /dev/null +++ b/handoff/rounds/ins-r41-steps.txt @@ -0,0 +1,160 @@ + /* ── the long session ───────────────────────────────────────────── + Every walk before this one was minutes long. A phone runs the app + for hours, and nothing in the port had ever been looked at for a + leak. This block is both the instrument and the assertion. + + The registry is installed by a STEP rather than through + Page.addScriptToEvaluateOnNewDocument, and that is load-bearing: the + first-run block above reloads three times and reattach() opens a NEW + DevTools session, so a script registered on the old one is gone for + the rest of the run. Installed from here it covers exactly the laps + the assertion is about, and the last step takes it out again. It + wraps addEventListener/removeEventListener (holding a WeakRef to + anything that is not window or document) and the four timer + functions, and it counts only what was registered AFTER it went in + and is still ATTACHED. Attached, because the naive counter — add + minus remove — climbs about twelve a lap in this app and none of it + is a leak: {#key nav.seq} replaces the .screen element on every + navigation and the old element's scroll handler goes with it. After + the install, because nothing can be registered before it: the + absolute count is therefore small (two after the whole walk, eight + from a settled boot) and only the DELTA across the laps is the + measurement. A listener or an interval stranded once a lap shows as + +9 in one and +9 in the other; it was driven to check. + + Then two laps to warm, a reading latched into __ssBase, nine more + laps, and a second reading. A lap is the five tabs, the Library, a + record, Play, the mini bar, all three faces of the sheet, the artwork + viewer, a row's half sheet, the artist page (a catalog lookup) and + back — about five seconds each, and fifty in all. The lap count is + what makes the assertion sharp: a one-per-lap leak shows as +9 + against a headroom of four, and trimming it to save time blunts the + only thing this block is for. + + Nothing here reaches the network or the disk beyond what any other + mock step does, and the last step puts the Library back on whichever + section the walk left it on. */ + { do: ` + window.__ssLibTab = (function () { try { return __ss.nav.stacks.library[0].c.tab || 'albums'; } catch (e) { return 'albums'; } })(); + window.__ssLapN = 0; + window.__ssLap = function (n) { + var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }; + var tap = function (sel, text) { + var all = document.querySelectorAll(sel); + for (var k = 0; k < all.length; k++) if (!text || (all[k].textContent || '').indexOf(text) >= 0) { all[k].click(); return true; } + return false; + }; + /* the app's own back, never history.back(): walking off the + history mirror leaves file:// and the page is simply gone */ + var peel = async function () { + for (var b = 0; b < 8; b++) { + var nv = __ss.nav; + if (!nv.timing && !nv.viewer && !nv.half && !nv.sheet && nv.stacks[nv.tab].length === 1) return; + __ss.back(); + await nap(190); + } + }; + return (async function () { + for (var i = 0; i < n; i++) { + /* the counter is global, not the loop's own: the laps + run in threes, and a lap that always opened the same + record would stop asking for anything new after the + first call and quietly measure nothing */ + var which = window.__ssLapN++; + __ss.go('search'); await nap(90); + __ss.go('community'); await nap(90); + __ss.go('you'); await nap(90); + __ss.go('library'); await nap(200); + tap('.segtabs button', 'Albums'); await nap(260); + var rel = document.querySelectorAll('.grid2 .release'); + if (rel.length) { rel[which % rel.length].click(); await nap(440); } + tap('.rec-acts .disc'); await nap(440); + tap('.mini'); await nap(380); + var fb = document.querySelectorAll('.fbtn'); + for (var f = 0; f < fb.length; f++) { fb[f].click(); await nap(110); } + tap('.np-art'); await nap(240); + /* peeled one layer at a time and only if it opened: a + record with no cover never opens the viewer, and a + blind pair of backs would pop the album page instead + and take the rows below with it */ + if (__ss.nav.viewer) { __ss.back(); await nap(220); } + if (__ss.nav.sheet) { __ss.back(); await nap(260); } + var trk = document.querySelector('.trk .r'); + if (trk) { trk.click(); await nap(260); tap('.scrim'); await nap(220); } + tap('.rec-artist'); await nap(620); + await peel(); + __ss.go('home'); await nap(160); + } + return n; + })(); + }; + (function () { + if (window.__ssCount) return 'already'; + var recs = [], liveT = new Set(), liveI = new Set(); + var ael = EventTarget.prototype.addEventListener, rel = EventTarget.prototype.removeEventListener; + EventTarget.prototype.addEventListener = function (t, f) { + try { recs.push({ t: t, f: f, w: (this === window || this === document) ? null : new WeakRef(this), dead: false }); } catch (e) {} + return ael.apply(this, arguments); + }; + EventTarget.prototype.removeEventListener = function (t, f) { + try { for (var i = recs.length - 1; i >= 0; i--) if (!recs[i].dead && recs[i].t === t && recs[i].f === f) { recs[i].dead = true; break; } } catch (e) {} + return rel.apply(this, arguments); + }; + var st = window.setTimeout, ct = window.clearTimeout, si = window.setInterval, ci = window.clearInterval; + window.setTimeout = function (fn, ms) { + var id, rest = [].slice.call(arguments, 2); + var w = typeof fn === 'function' ? function () { liveT.delete(id); return fn.apply(window, arguments); } : fn; + id = st.apply(window, [w, ms].concat(rest)); liveT.add(id); return id; + }; + window.clearTimeout = function (id) { liveT.delete(id); return ct.call(window, id); }; + window.setInterval = function () { var id = si.apply(window, arguments); liveI.add(id); return id; }; + window.clearInterval = function (id) { liveI.delete(id); return ci.call(window, id); }; + /* a listener is LIVE when its target is window, document, or an + element still in the tree; a timer is live until it fires or + is cleared */ + window.__ssCount = function () { + var n = 0; + for (var i = 0; i < recs.length; i++) { + var r = recs[i]; + if (r.dead) continue; + if (r.w === null) { n++; continue; } + var el = r.w.deref(); + if (el && el.isConnected) n++; + } + return { listeners: n, timeouts: liveT.size, intervals: liveI.size }; + }; + /* and hands the page back exactly as it was found. A sibling + round appending after this block must not inherit a patched + EventTarget or a patched clock; __ssCount and __ssBase go + with them, so every later probe reads the -1 that means + 'nothing has measured this' rather than a frozen number. */ + window.__ssUninstall = function () { + EventTarget.prototype.addEventListener = ael; + EventTarget.prototype.removeEventListener = rel; + window.setTimeout = st; window.clearTimeout = ct; + window.setInterval = si; window.clearInterval = ci; + delete window.__ssCount; delete window.__ssBase; delete window.__ssUninstall; + return 'removed'; + }; + return 'installed'; + })() + `, wait: 500 }, + { do: `__ssLap(2)`, name: "session-warm", wait: 1200 }, + /* the baseline, taken after the warm laps have settled and their own + probe has run — __ssCensus is defined by the probe itself */ + { do: `window.__ssBase = window.__ssCensus(); true`, wait: 300 }, + /* nine laps, in threes: cdp.send() gives a step forty-five seconds to + answer and a lap is about five, so one call of nine times out, the + walk records a failure it did not have, and the probe lands in the + middle of a lap instead of at Home */ + { do: `__ssLap(3)`, wait: 400 }, + { do: `__ssLap(3)`, wait: 400 }, + { do: `__ssLap(3)`, name: "session-late", wait: 1400 }, + /* back to Home with nothing open, the Library on the section the walk + was using before this block, and the registry out of the page, so + whatever is appended after this finds the app — and the platform — + where the block above left them. This step's own probe therefore + reports -1 for every grew* field, which is the sentinel for + 'nothing has latched a baseline': session-late is where the numbers + are. */ + { do: `__settle(); try { __ss.go('library'); __ss.patch({ tab: __ssLibTab }); __ss.go('home'); } catch (e) {} try { __ssUninstall(); } catch (e) {} true`, name: "session-clear", wait: 1000 }, diff --git a/handoff/rounds/ins-r42-doc-layout.txt b/handoff/rounds/ins-r42-doc-layout.txt new file mode 100644 index 0000000..15f2cf1 --- /dev/null +++ b/handoff/rounds/ins-r42-doc-layout.txt @@ -0,0 +1,8 @@ +layout is the only thing that has to answer. Two media queries do that. +Every claim in that sentence was read off the CSS and proved at 915×411 +behind a viewport override; the phone walk now turns a real device over and +reads each of them there — the angle, the insets around the cutout and the +gesture pill, the activity, the audio. See **Verifying**. The player sheet +is the one surface whose portrait shape cannot survive a wide viewport: its +artwork is a square sized by WIDTH, so at 915×411 the cover came out 879px +tall and the title, the lane, the transport and the face buttons sat diff --git a/handoff/rounds/ins-r42-doc-walk.txt b/handoff/rounds/ins-r42-doc-walk.txt new file mode 100644 index 0000000..5ebfff2 --- /dev/null +++ b/handoff/rounds/ins-r42-doc-walk.txt @@ -0,0 +1,57 @@ +the phone walk refuses one, because a real device's orientation is not the +harness's to set. + +The device turns itself over instead, in a block of the phone walk's own, +after `background` and before the network — because a step list cannot do +either of the two things this needs. It reads +`settings get system accelerometer_rotation` and `user_rotation` FIRST and +latches the pair; only then does it put auto-rotate off and `user_rotation` +to 1 (a retry at 3 for a device whose natural orientation is landscape), and +it polls for the turn rather than waiting a fixed time. Then it asserts what +only a device can answer: `screen.orientation.angle` is 90 or 270 — an +`Emulation.setDeviceMetricsOverride` without `screenOrientation` leaves the +angle at 0, so the mock physically cannot fake this, and it is the one clean +"a real phone did this" clause — that the WebView RESIZED as well (a separate +clause, because a WebView that never got the configuration change looks +identical to a display that never rotated), that `--nav-h` computed to 48px +so the phone-sized landscape query really fired, that the activity was NOT +recreated (the same window token and the same `performance.timeOrigin`, the +same pid, no `AndroidRuntime` fatal — the manifest declares `orientation` and +`screenSize` handled, so a recreation here would be the bug), that the tab +and every stack are where they were, that the track is still playing and past +where it was, that nothing is clipped, and that the glass bar, the mini bar +and the screen spend whatever `env()` says. Then the player sheet sideways: +two columns, every control on screen, its own side padding. Then back. + +The four safe-area insets are printed, not asserted. Nobody had ever measured +them on this phone: `MainActivity` calls androidx's `enableEdgeToEdge()`, +`themes.xml` sets no cutout attribute and nothing in the generated Kotlin +touches insets, so `env()` comes straight out of Chromium's own window-inset +mapping and whether the hole-punch shows up as a non-zero side inset depends +on the cutout mode that picks. The run records the numbers — `insets` as +top/right/bottom/left, `insetsReal`, the app's own `--inset-left/right` beside +them, and `chromePct` — and a later round can assert them once they are known. +Asserting a number nobody has measured is how a round fails on a correct +build. + +Putting the phone back is the part that is written carefully, because the +phone is Kyle's. The saved pair is a module-level latch, and the restore is +reached from four places: the rotation block's own `finally`, main's +`finally`, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK. It has to be all +four — a failed clause only pushes a problem and walks on, but `still()` and +the probe call are wrapped in nothing, so a WebView that stops answering +rejects after 45 s and throws clean out of the loop, and Ctrl-C runs no `exit` +handler at all. The latch clears itself before it writes, so only the first +caller acts and a restore that fails hands the latch back for the next one. +The rotation goes back before auto-rotate does, so a phone that had the sensor +in charge gets it back last. A device whose two settings will not read is +skipped with a line rather than forced — `rotation skipped: the phone's +rotation settings would not read` — as is one that is already sideways, and +`--no-rotate` skips the block outright for a run where he is holding the +phone. Nothing else on the device is touched: no download, no room, no +message, no sign-in, nothing written to the config or the library. The one +case no handler covers is a hard kill of the machine, after which auto-rotate +is a Quick Settings tile away. + +The stills are `phone-turned.png`, `phone-turned-sheet.png` and +`phone-turned-back.png`. diff --git a/handoff/rounds/ins-r42-helpers.txt b/handoff/rounds/ins-r42-helpers.txt new file mode 100644 index 0000000..6ab7106 --- /dev/null +++ b/handoff/rounds/ins-r42-helpers.txt @@ -0,0 +1,108 @@ +/* ── the phone, turned over: the two settings that do it ─────────────── + * `settings get system accelerometer_rotation` and `user_rotation` are the + * only two things on the device this walk ever writes, and both are READ + * before either is written. The saved pair is latched at module scope on + * purpose: the step loop's `still()` and its PROBE call are wrapped in + * nothing at all, so a WebView that stops answering rejects after cdp.mjs's + * timeout and throws clean out of the walk into main's `finally`. That is + * the exact path that would otherwise leave Kyle's phone locked sideways, + * which is why the restore is a latch reachable from four places — the + * rotation block's own finally, main's finally, process 'exit' and a + * signal — rather than a step in a list. */ +let rotationSaved = null; + +/** Both rotation settings as the device has them now, or null when either + * will not read as something we recognise. null is the SKIP signal: the + * block never writes what it could not read first. `user_rotation` comes + * back as the literal "null" on a phone where it has never been set. */ +function rotationSettings() { + try { + const accel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim(); + const user = adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); + if (!/^\d+$/.test(accel)) return null; + if (!/^\d+$/.test(user) && user !== "null") return null; + return { accel, user }; + } catch { return null; } +} + +/** Read once and latch. Returns the pair, or null when the device would not + * say — in which case nothing has been written and there is nothing to put + * back. */ +function rotationArm() { + const was = rotationSettings(); + if (was) rotationSaved = was; + return was; +} + +/** Drop the latch WITHOUT writing anything. For the skip paths: the pair has + * been read but nothing has been put, and restoring there would write two + * settings onto Kyle's phone that this walk never changed. */ +function rotationDisarm() { rotationSaved = null; } + +/** Hold the phone at one rotation. Auto-rotate goes off FIRST: with the + * sensor still in charge it takes `user_rotation` straight back and the + * turn never happens. Returns null, or the message it failed with. */ +function rotationPut(n) { + try { + adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); + adb("shell", "settings", "put", "system", "user_rotation", String(n)); + return null; + } catch (e) { return e.message; } +} + +/** Put both back, once. The latch is cleared BEFORE anything is written, so + * the first caller is the only one that acts and the three behind it are + * no-ops; a restore that fails puts the latch back so the next caller can + * try again. The rotation goes first and auto-rotate last — a phone whose + * auto-rotate was on must hand control back to the sensor only once the + * angle underneath it is the one it started at. Synchronous, because + * `adb()` is spawnSync and nothing asynchronous survives an 'exit' + * handler. Returns true, null when there was nothing to restore, or the + * message it failed with. */ +function rotationRestore() { + const was = rotationSaved; + rotationSaved = null; + if (!was) return null; + try { + if (was.user === "null") { + /* never set on this phone: delete it rather than inventing a 0 */ + try { adb("shell", "settings", "delete", "system", "user_rotation"); } + catch { adb("shell", "settings", "put", "system", "user_rotation", "0"); } + } else { + adb("shell", "settings", "put", "system", "user_rotation", was.user); + } + adb("shell", "settings", "put", "system", "accelerometer_rotation", was.accel); + return true; + } catch (e) { rotationSaved = was; return e.message; } +} + +/** The system's own view of the turn, for the report only. The two settings + * read back, plus the display's current size and rotation out of `dumpsys + * window displays` — whose spelling moves between Android versions, so a + * parse failure comes back empty and is printed, never asserted. */ +function rotationSystem() { + const out = { setAccel: "", setUser: "", dispCur: "", dispRot: "" }; + try { + out.setAccel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim(); + out.setUser = adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); + } catch { /* empty is the reading */ } + try { + const d = adb("shell", "dumpsys", "window", "displays").toString(); + out.dispCur = (d.match(/\bcur=(\d+x\d+)/) || [])[1] || ""; + out.dispRot = (d.match(/mCurrentRotation=(?:ROTATION_)?(\w+)/) || d.match(/\brotation=(\d+)/) || [])[1] || ""; + } catch { /* empty is the reading */ } + return out; +} + +/* 'exit' does not run on a signal and a signal does not run 'exit', so + both. Ctrl-C in the middle of the turn is precisely the case that would + otherwise leave the phone sideways until Kyle found the Quick Settings + tile. Registered only for the phone walk — the mock never writes a + setting, and a SIGINT handler on the mock run would change how Ctrl-C + behaves there for nothing. */ +if (PHONE) { + process.on("exit", () => { rotationRestore(); }); + for (const sig of ["SIGINT", "SIGTERM", "SIGBREAK"]) { + process.on(sig, () => { rotationRestore(); process.exit(130); }); + } +} diff --git a/handoff/rounds/ins-r42-phase.txt b/handoff/rounds/ins-r42-phase.txt new file mode 100644 index 0000000..5879810 --- /dev/null +++ b/handoff/rounds/ins-r42-phase.txt @@ -0,0 +1,316 @@ + /* ── turned over, for real ──────────────────────────────── + Round twenty-seven wrote the landscape layout and proved it + in headless Chrome behind a viewport override. No real device + had ever been turned. The phone walk still refuses a + `{ view: [...] }` step — a device's orientation is not the + step list's to set — so the turn lives here in the tail + instead, where it can hold a latch and put the phone back + from a `finally` whatever happens above it. It lands right + after `background`, which has just asserted the position + advanced by eight seconds with the app off screen: playback + is provably live, which is the best arming state there is. + + What only a device can answer, and the override cannot fake: + screen.orientation.angle (an override without + `screenOrientation` leaves it at 0, so 90 is a fact rather + than a viewport), env()'s real insets around the cutout and + the gesture pill, whether the activity survived the + configuration change instead of being recreated, and whether + the audio kept going through it. The turn and the RESIZE are + separate clauses on purpose: a WebView that never got the + configuration change otherwise looks exactly like a display + that never rotated. + + What is NOT asserted, because nobody has measured it yet: + the inset numbers themselves. MainActivity calls androidx's + enableEdgeToEdge() and nothing in the generated Kotlin + touches insets, so env() comes straight out of Chromium's own + window-inset mapping and whether the hole-punch shows up as a + non-zero side inset depends on the cutout mode that picks. + The four numbers are REPORTED; what is asserted is the + plumbing — that .screen, .mini and .np spend whatever env() + says, which is the rule the side-inset longhands at the foot + of shell.css exist to win. Ditto chromePct: the first run is + what records the truth, and a later round can tighten it. + + The phone is Kyle's. Both settings are read before either is + written and put back from four places; a device whose + settings will not read is skipped rather than forced; and + `--no-rotate` skips the block outright, for a run where he is + holding the phone. */ + if (ARGS.includes("--no-rotate")) { + console.log(" -- rotation skipped: --no-rotate"); + } else { + /* the phase's own reading, beside PLAYER / STREAM / HERO / + CAT / NET — PROBE is deliberately left alone, because + sibling rounds append their fields to it. env() cannot be + read out of a custom property everywhere, so the ground + truth is a throwaway fixed 0x0 div whose padding IS the + four env()s, read back computed; the app's own + --inset-left/right are read beside it and printed, so a + disagreement between the two shows up in the report + rather than hiding inside an assertion. */ + const ROT = `(function () { + var d = document.createElement('div'); + d.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none;' + + 'padding-top:env(safe-area-inset-top,0px);padding-right:env(safe-area-inset-right,0px);' + + 'padding-bottom:env(safe-area-inset-bottom,0px);padding-left:env(safe-area-inset-left,0px)'; + document.documentElement.appendChild(d); + var cs = getComputedStyle(d); + var ins = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(function (v) { return Math.round(parseFloat(v) || 0); }); + d.remove(); + var rs = getComputedStyle(document.documentElement); + function box(sel) { var x = document.querySelector(sel); return x ? x.getBoundingClientRect() : null; } + function inside(b) { return !!b && b.height > 0 && b.top >= -1 && b.bottom <= innerHeight + 1 && b.left >= -1 && b.right <= innerWidth + 1; } + function pad(sel, side) { var x = document.querySelector(sel); return x ? Math.round(parseFloat(getComputedStyle(x)[side]) || 0) : -1; } + /* the chrome that is actually on screen: a mini bar under a raised + sheet and a nav under one are translated away on purpose */ + var out = []; + ['.gbar', '.screen', '.mini:not(.hidden)', '.nav:not(.gone)'].forEach(function (sel) { + var b = box(sel); + if (b && b.height > 0 && !inside(b)) out.push(sel); + }); + /* the witness that an activity recreation would destroy: planted on the + first reading, gone with the window if a new one replaces it */ + if (!window.__ssTurn) window.__ssTurn = 'turn-' + Date.now() + '-' + Math.round(Math.random() * 1000000); + var p = window.__ss && __ss.player; + var n = window.__ss && __ss.nav; + return { + token: window.__ssTurn, + origin: Math.round(performance.timeOrigin), + w: innerWidth, h: innerHeight, + vp: innerWidth + 'x' + innerHeight, + /* the one thing a viewport override cannot move */ + angle: (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : -1, + ori: (screen.orientation && screen.orientation.type) || '', + mq: matchMedia('(orientation: landscape)').matches, + /* the phone-sized landscape query, read off the one variable only + it sets: 60px portrait, 48px sideways */ + navH: (rs.getPropertyValue('--nav-h') || '').trim(), + insets: ins, + varLeft: Math.round(parseFloat(rs.getPropertyValue('--inset-left')) || 0), + varRight: Math.round(parseFloat(rs.getPropertyValue('--inset-right')) || 0), + screenPadL: pad('.screen', 'paddingLeft'), + screenPadR: pad('.screen', 'paddingRight'), + npPadL: pad('.sheet.up .np', 'paddingLeft'), + npPadR: pad('.sheet.up .np', 'paddingRight'), + /* both edges, because a hole-punch is on ONE of them: sideways the + cutout is left or right depending on which way the phone turned, + and a check of the left alone would pass by the inset being 0 */ + miniLeft: Math.round((box('.mini') || { left: -1 }).left), + miniRight: Math.round(innerWidth - (box('.mini') || { right: innerWidth + 1 }).right), + chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(), + outside: out.join('|'), + sheet: !!document.querySelector('.sheet.up'), + npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length, + npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(), + tab: n ? n.tab : '', + depth: (function () { try { return Object.keys(n.stacks).map(function (k) { return k + ':' + n.stacks[k].length; }).join(','); } catch (e) { return ''; } })(), + status: p ? p.status : '', + title: p && p.track ? p.track.title : '', + pos: p ? Math.round(p.position) : -1, + }; +})()`; + /* 15 s, not cdp.evaluate's 45: if the activity really was + recreated the socket is dead, and this block asks three + times over. A broken device should cost the walk under a + minute, not three. */ + const rotAsk = async (expr) => { + const r = await cdp.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }, 15000); + if (r.result && r.result.exceptionDetails) { + throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text || "")); + } + return r.result?.result?.value; + }; + /* a known screen: Home, nothing open, no transition in + flight, so `.screen` is the viewport box and not a sheet + sliding across it */ + try { + await cdp.evaluate(HELPERS); + await cdp.evaluate(`__settle()`); + await sleep(900); + } catch { /* the reading below says so */ } + + const was = rotationArm(); + let start = null; + try { start = await rotAsk(ROT); } catch (e) { start = null; } + + if (!was) { + console.log(" -- rotation skipped: the phone's rotation settings would not read"); + } else if (!start) { + /* disarmed, not restored: nothing has been written yet, + and putting two settings back that were never changed + is still writing to his phone */ + rotationDisarm(); + console.log(" -- rotation skipped: the page did not answer before the turn"); + } else if (start.w > start.h) { + rotationDisarm(); + console.log(" -- rotation skipped: the phone is already sideways (" + start.vp + ")"); + } else { + /* "it kept playing" means nothing unless it was playing: + the restart phase's own idiom, and the only call this + block makes into the app */ + if (start.status !== "playing") { + try { await cdp.evaluate(`window.__ss && __ss.toggle()`); } catch { /* reported by the clause */ } + await sleep(2500); + try { start = (await rotAsk(ROT)) || start; } catch { /* keep the earlier reading */ } + } + let rotErr = ""; + /* polled, never fixed-waited: a real device turns on its + own clock, and reading a slow answer as a failure has + cost this project a round twice. A page that stops + answering breaks the loop rather than paying 15 s + sixteen times over — and that IS the recreated-WebView + reading, which the finally below still restores from. */ + const until = async (wide) => { + let last = null; + for (let i = 0; i < 16; i++) { + await sleep(500); + try { last = await rotAsk(ROT); } catch (e) { rotErr = e.message; return last; } + if (last && (wide ? last.w > last.h : last.w < last.h)) return last; + } + return last; + }; + /* `adb()` throws on a non-zero exit, and `pidof` exits 1 + when the process is GONE — which is the one finding + this block most exists to catch. Read tolerantly, so a + dead app comes back as `alive: false` in the report + rather than as a bare throw that takes every other + clause with it; `logcat -c`, which some devices refuse + to clear, is the same shape before the turn. */ + const adbSay = (...a) => { try { return adb(...a).toString(); } catch { return ""; } }; + try { + const pid0 = adbSay("shell", "pidof", "com.soulstream.app").trim(); + adbSay("logcat", "-c"); + const put = rotationPut(1); + if (put) problems.push("ROTATION could not turn the phone: " + put); + let turned = await until(true); + if (!rotErr && !(turned && turned.w > turned.h)) { + /* a device whose natural orientation is landscape + maps 1 the other way; one retry at 3 is cheap + insurance against a false finding */ + rotationPut(3); + turned = await until(true); + } + await sleep(700); + if (!rotErr) { try { turned = (await rotAsk(ROT)) || turned; } catch (e) { rotErr = e.message; } } + try { await still(path.join(OUT, `${prefix}turned.png`)); } catch (e) { problems.push("ROTATION still: " + e.message); } + + const sys = rotationSystem(); + const pid1 = adbSay("shell", "pidof", "com.soulstream.app").trim(); + const fatals = (adbSay("logcat", "-d", "-s", "AndroidRuntime:E").match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length; + const t = turned || {}; + const ins = t.insets || [0, 0, 0, 0]; + const checks = { + /* only a device moves this */ + turned: t.angle === 90 || t.angle === 270, + /* and the WebView got the configuration change */ + resized: t.w > t.h && t.mq === true, + /* …down to the phone-sized query, exactly */ + layout: t.navH === "48px", + /* and it is still ANSWERING: a reading that went + stale because the socket died is otherwise a + good-looking reading of the moment before */ + answering: !rotErr, + alive: !!pid1 && pid1 === pid0 && !fatals, + /* the manifest declares orientation and screenSize + handled, so the activity must NOT have been + recreated: a recreation makes a new window and + a new document, and both of these would move */ + kept: t.token === start.token && t.origin === start.origin, + stack: t.tab === start.tab && t.depth === start.depth, + /* a track that rolled over during the turn starts + the next one at zero: still playing, which is + the whole of what this clause claims */ + playing: t.status === "playing" && (t.pos >= start.pos || t.title !== start.title), + unclipped: t.outside === "", + /* whatever env() says sideways, the chrome spends + it — the plumbing, not a number nobody has + measured. The `>= 0` clauses are not belt and + braces: pad() answers -1 for an element that is + not there, and -1 clears `>= inset - 1` whenever + the inset is 0, so a missing .screen passed */ + plumbing: t.screenPadL >= 0 && t.screenPadR >= 0 + && t.screenPadL >= ins[3] - 1 && t.miniLeft >= 8 + ins[3] - 1 + && t.screenPadR >= ins[1] - 1 && t.miniRight >= 8 + ins[1] - 1, + }; + const bad = Object.keys(checks).filter((k) => !checks[k]); + const report = { + vp: t.vp, angle: t.angle, ori: t.ori, navH: t.navH, chromePct: t.chromePct, + insets: ins.join("/"), insetsReal: ins[1] > 0 || ins[3] > 0, + vars: t.varLeft + "/" + t.varRight, + screenPad: t.screenPadL + "/" + t.screenPadR, mini: t.miniLeft + "/" + t.miniRight, + pid: pid1 || null, fatals, sys, err: rotErr, failed: bad.join(","), + }; + if (bad.length) problems.push("EXPECT rotation " + JSON.stringify(report)); + console.log((bad.length ? " FAIL " : " ok ") + "rotation".padEnd(16) + " " + JSON.stringify(report)); + + /* the sheet is the one surface whose portrait shape + cannot survive a wide viewport: two columns, every + control on screen, and its own side padding */ + if (!rotErr && t.w > t.h) { + try { + await cdp.evaluate(`var m = document.querySelector('.mini'); m && m.click(); true`); + await sleep(1400); + await still(path.join(OUT, `${prefix}turned-sheet.png`)); + const sh = (await rotAsk(ROT)) || {}; + const sIns = sh.insets || [0, 0, 0, 0]; + const sChecks = { + up: sh.sheet === true, + beside: sh.npCols === "beside", + onScreen: sh.npOut === 0, + /* `>= 0` first: pad() answers -1 for an + element that is not there, and -1 + clears `>= inset - 1` whenever the + inset is 0 */ + plumbing: sh.npPadL >= 0 && sh.npPadR >= 0 + && sh.npPadL >= sIns[3] - 1 && sh.npPadR >= sIns[1] - 1, + }; + const sBad = Object.keys(sChecks).filter((k) => !sChecks[k]); + const sReport = { vp: sh.vp, npCols: sh.npCols, npOut: sh.npOut, npPad: sh.npPadL + "/" + sh.npPadR, insets: sIns.join("/"), failed: sBad.join(",") }; + if (sBad.length) problems.push("EXPECT rotation-sheet " + JSON.stringify(sReport)); + console.log((sBad.length ? " FAIL " : " ok ") + "rotation-sheet".padEnd(16) + " " + JSON.stringify(sReport)); + await cdp.evaluate(`window.__ss && __ss.closeSheet()`); + await sleep(800); + } catch (e) { problems.push("ROTATION rotation-sheet: " + e.message); } + } else { + console.log(" -- rotation-sheet skipped: the phone did not turn"); + } + } catch (e) { + /* an adb that falls over mid-block, a still that + cannot be taken: reported, never thrown. A throw + out of here would take the network block, the + lock-screen still and the walk's own summary with + it — and the phone is put back either way, because + catch runs before finally. */ + problems.push("ROTATION " + e.message); + } finally { + /* whatever happened above — a clause that failed, a + WebView that stopped answering, a throw out of any + of it — the phone goes back first and everything + else is reported afterwards. */ + const back = rotationRestore(); + if (back !== true) problems.push("ROTATION could not put the phone's rotation settings back: " + back); + rotErr = ""; + let home = await until(false); + await sleep(600); + if (!rotErr) { try { home = (await rotAsk(ROT)) || home; } catch (e) { rotErr = e.message; } } + try { await still(path.join(OUT, `${prefix}turned-back.png`)); } catch { /* the phone may be gone */ } + const now = rotationSettings(); + const h = home || {}; + const bChecks = { + answering: !rotErr, + portrait: h.w > 0 && h.w < h.h, + angle: h.angle === start.angle, + layout: h.navH === start.navH, + kept: h.token === start.token && h.origin === start.origin, + playing: h.status === "playing", + settings: !!now && now.accel === was.accel && now.user === was.user, + }; + const bBad = Object.keys(bChecks).filter((k) => !bChecks[k]); + const bReport = { vp: h.vp, angle: h.angle, navH: h.navH, status: h.status, settings: now, wanted: was, err: rotErr, failed: bBad.join(",") }; + if (bBad.length) problems.push("EXPECT rotation-back " + JSON.stringify(bReport)); + console.log((bBad.length ? " FAIL " : " ok ") + "rotation-back".padEnd(16) + " " + JSON.stringify(bReport)); + } + } + } diff --git a/handoff/rounds/ins-r43-build-flag.txt b/handoff/rounds/ins-r43-build-flag.txt new file mode 100644 index 0000000..042fb48 --- /dev/null +++ b/handoff/rounds/ins-r43-build-flag.txt @@ -0,0 +1,34 @@ + +# Which build. The default is exactly what this script has always done, and +# has to stay that way: deploy.sh calls it with no argument every twenty +# minutes, and ship.sh calls deploy.sh. `--release` (or SS_ANDROID_RELEASE=1) +# is the other path, and as of this round nobody has run it — see ANDROID.md +# → "The release build (nobody has made one yet)". +REL=0 +case "${1:-}" in + --release) REL=1 ;; + "") ;; + *) echo "usage: build.sh [--release]"; exit 2 ;; +esac +if [ "${SS_ANDROID_RELEASE:-0}" = "1" ]; then REL=1; fi +if [ "$REL" = "1" ]; then + PROFILE=release + VARIANT=Arm64Release + # --features is repeatable and cargo unions them, so custom-protocol on + # the cargo line below is kept. devtools is not a nicety: wry compiles the + # setWebContentsDebuggingEnabled call only under + # cfg(any(debug_assertions, feature = "devtools")), so a plain release APK + # opens no webview_devtools_remote socket and `smoke.mjs --phone` cannot + # attach to the one build that most needs walking. R8 never sees it. + CARGO_EXTRA="--release --features tauri/devtools" + test -d src-tauri/gen/android/app || { echo "no generated Android project — run the CLI's android init first"; exit 1; } + # The app-wide keep rules, into a project that cannot hold them itself: + # gen/ is regenerated and gitignored. The release build type globs + # **/*.pro out of this directory; the debug type reads none of them. + cp src-tauri/android/proguard-soulstream.pro src-tauri/gen/android/app/proguard-soulstream.pro + echo "== RELEASE: R8 on, rules from src-tauri/android/proguard-soulstream.pro + each plugin's consumer-rules.pro" +else + PROFILE=debug + VARIANT=Arm64Debug + CARGO_EXTRA="" +fi diff --git a/handoff/rounds/ins-r43-build-tail.txt b/handoff/rounds/ins-r43-build-tail.txt new file mode 100644 index 0000000..a94e520 --- /dev/null +++ b/handoff/rounds/ins-r43-build-tail.txt @@ -0,0 +1,28 @@ +cd /f/Coding/SoulStream +if [ "$REL" = "1" ]; then + UNSIGNED=src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release-unsigned.apk + if [ ! -f "$UNSIGNED" ]; then + echo "no release APK — gradle's tail is above. The two failures we expect:" + echo " R8 missing class — its own list is at src-tauri/gen/android/app/build/outputs/mapping/arm64Release/missing_rules.txt;" + echo " add what it names to src-tauri/android/proguard-soulstream.pro and run again" + echo " lintVital — re-run gradle by hand with -x lintVitalArm64Release" + echo " (AGP 8 splits it; -x lintVitalAnalyzeArm64Release may be the one)" + exit 1 + fi + KS="$HOME/.android/debug.keystore" + test -f "$KS" || { echo "no $KS — the release APK is unsigned and no phone will install it"; exit 1; } + BT="$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1 || true)" + test -n "$BT" || { echo "no build-tools under $SDK — cannot sign"; exit 1; } + APK=src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk + echo "== sign $(date +%T) ($(basename "$BT"))" + "$BT/zipalign.exe" -p -f 4 "$UNSIGNED" "$APK" + # The SAME key the debug builds are signed with, deliberately. A release + # APK signed with any other key cannot be installed over the app already + # on the phone, and the obvious next move — uninstall, then install — + # takes the app's data with it. This is a test build, not a distributable + # one; a real one needs a real key and this script is not where that goes. + MSYS_NO_PATHCONV=1 "$BT/apksigner.bat" sign --ks "$(cygpath -w "$KS")" --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "$APK" +else + APK=src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk +fi +ls -la "$APK" diff --git a/handoff/rounds/ins-r43-consumer-media.txt b/handoff/rounds/ins-r43-consumer-media.txt new file mode 100644 index 0000000..02f0d3c --- /dev/null +++ b/handoff/rounds/ins-r43-consumer-media.txt @@ -0,0 +1,22 @@ +# Consumer R8/ProGuard rules for the media-session plugin. Inert unless the +# consuming app minifies, and only the `release` build type does, so the +# debug APK the deploy pipeline builds is untouched by this file. +# +# MediaSessionPlugin is found by the string +# "com/soulstream/mediasession/MediaSessionPlugin" (register_android_plugin → +# FindClass), constructed through "(Landroid/app/Activity;)V", and its two +# @Command methods — update and clear — are dispatched by NAME through +# PluginHandle's commands map. NowPlayingArgs is filled by Jackson field by +# field. Rename any of it and the app still builds, installs and plays; the +# lock-screen card simply never appears. +# +# The other half of this plugin is in the app-wide file: the card's buttons +# come back as the plugin event "action", which the WebView subscribes to +# through Plugin.registerListener — declared on tauri's base class, which no +# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro. +# +# MediaService and androidx's MediaButtonReceiver are declared in this +# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold +# them by name. Not repeated here: one place decides each of them. +-keep class com.soulstream.mediasession.MediaSessionPlugin { *; } +-keep class com.soulstream.mediasession.NowPlayingArgs { *; } diff --git a/handoff/rounds/ins-r43-consumer-notice.txt b/handoff/rounds/ins-r43-consumer-notice.txt new file mode 100644 index 0000000..e9ba343 --- /dev/null +++ b/handoff/rounds/ins-r43-consumer-notice.txt @@ -0,0 +1,22 @@ +# Consumer R8/ProGuard rules for the transfer-notice plugin. Inert unless the +# consuming app minifies, and only the `release` build type does, so the +# debug APK the deploy pipeline builds is untouched by this file. +# +# TransferNoticePlugin is found by the string +# "com/soulstream/transfernotice/TransferNoticePlugin" +# (register_android_plugin → FindClass), constructed through +# "(Landroid/app/Activity;)V", and its two @Command methods — update and +# clear — are dispatched by NAME through PluginHandle's commands map. +# NoticeArgs is filled by Jackson field by field. Rename any of it and a Save +# runs to completion with no notice in the shade at all. +# +# The notice's own Cancel and Open come back as the plugin event "action", +# which the WebView subscribes to through Plugin.registerListener — declared +# on tauri's base class, which no rule of tauri's own names. See +# src-tauri/android/proguard-soulstream.pro. +# +# TransferService is declared in this module's AndroidManifest.xml, so AGP's +# own merged-manifest keep rules hold it by name. Not repeated here: one +# place decides it. +-keep class com.soulstream.transfernotice.TransferNoticePlugin { *; } +-keep class com.soulstream.transfernotice.NoticeArgs { *; } diff --git a/handoff/rounds/ins-r43-consumer-share.txt b/handoff/rounds/ins-r43-consumer-share.txt new file mode 100644 index 0000000..404a821 --- /dev/null +++ b/handoff/rounds/ins-r43-consumer-share.txt @@ -0,0 +1,17 @@ +# Consumer R8/ProGuard rules for the share plugin. Inert unless the consuming +# app minifies, and only the `release` build type does, so the debug APK the +# deploy pipeline builds is untouched by this file. +# +# SharePlugin is found by the string "com/soulstream/share/SharePlugin" +# (register_android_plugin → FindClass), constructed through +# "(Landroid/app/Activity;)V", and its one @Command method — pendingShare — +# is dispatched by NAME through PluginHandle's commands map. It takes no +# arguments, so there is no @InvokeArg class here. Rename it and text shared +# to SoulStream from another app arrives nowhere: the activity still runs and +# still brings the app to the front, and the WebView is never handed what it +# carried. +# +# ShareActivity is declared in this module's AndroidManifest.xml, so AGP's +# own merged-manifest keep rules hold it by name. Not repeated here: one +# place decides it. +-keep class com.soulstream.share.SharePlugin { *; } diff --git a/handoff/rounds/ins-r43-consumer-storage.txt b/handoff/rounds/ins-r43-consumer-storage.txt new file mode 100644 index 0000000..fc4fffb --- /dev/null +++ b/handoff/rounds/ins-r43-consumer-storage.txt @@ -0,0 +1,38 @@ +# Consumer R8/ProGuard rules for the storage plugin. Gradle hands a consumer +# file to whatever app links this module, and it is INERT unless that app +# minifies: src-tauri/gen/android/app/build.gradle.kts sets +# isMinifyEnabled = true for the `release` build type and false for `debug`, +# and the debug type names no proguard files at all. The deploy pipeline +# builds debug, so nothing in this file reaches it. +# +# Nothing below is called from Kotlin, which is the whole problem. Tauri +# reaches this plugin by STRING, at three layers: +# +# 1. register_android_plugin("com.soulstream.storage", "StoragePlugin") is +# turned into "com/soulstream/storage/StoragePlugin" and handed to +# FindClass; the instance is built through the JNI signature +# "(Landroid/app/Activity;)V" (tauri/src/plugin/mobile.rs). +# 2. PluginHandle.indexMethods() files every @Command under +# commands[method.name] and every @ActivityCallback under +# startActivityCallbackMethods[method.name]; invoke() looks up the name +# Rust asked for and throws "No command pickFolder found for plugin ..." +# when it is not there. +# 3. Jackson fills the @InvokeArg classes FIELD by field — PluginManager +# sets FIELD visibility to ANY — so field names weigh the same as +# method names. +# +# Six commands (pickFolder, allFilesAccess, requestAllFilesAccess, pickImage, +# pickFile, createDocument) and four activity callbacks (folderPicked, +# imagePicked, filePicked, documentCreated). createDocument is the easy one +# to miss by hand: it has no JS-facing command at all, it is called from Rust +# by playlist export. +# +# Tauri's own consumer rules already match all of this by annotation +# (-keep @TauriPlugin public class * { @Command public ; ... }). +# These name it outright anyway: theirs are generic, ours are specific, the +# failure is silent and user-facing, and nobody has built a release APK of +# this app yet to find out which of us was needed. Narrow them once a release +# build has actually been smoke-tested command by command. +-keep class com.soulstream.storage.StoragePlugin { *; } +-keep class com.soulstream.storage.CreateDocumentArgs { *; } +-keep class com.soulstream.storage.PickFileArgs { *; } diff --git a/handoff/rounds/ins-r43-doc-notdone.txt b/handoff/rounds/ins-r43-doc-notdone.txt new file mode 100644 index 0000000..34189f8 --- /dev/null +++ b/handoff/rounds/ins-r43-doc-notdone.txt @@ -0,0 +1,11 @@ +- **A release build nobody has made.** Every APK this port has produced is a + debug one, with minification off. The keep rules the plugins need are now + written — one `consumer-rules.pro` per plugin module, plus + `src-tauri/android/proguard-soulstream.pro` for what belongs to no single + plugin — and `build.sh --release` can make and sign a release APK, off by + default so the deploy pipeline cannot reach it. None of it has been run. + The rules are a precaution against a failure that would not show up until + someone taps something, not a fix for anything observed; the first person + to type `bash src-tauri/android/build.sh --release` is doing something new, + and should work down the smoke list in "The release build" before believing + any of it. diff --git a/handoff/rounds/ins-r43-doc-release.txt b/handoff/rounds/ins-r43-doc-release.txt new file mode 100644 index 0000000..603b6b5 --- /dev/null +++ b/handoff/rounds/ins-r43-doc-release.txt @@ -0,0 +1,137 @@ +## The release build (nobody has made one yet) + +Every APK in this port is `assembleArm64Debug` — every `deploy.sh`, every +`dev.sh`, every walk, every build on Kyle's phone. The generated project turns +**R8 on for `release` and only for `release`**: in +`src-tauri/gen/android/app/build.gradle.kts` the debug type is +`isMinifyEnabled = false` and names no proguard files, and the release type is +`isMinifyEnabled = true` plus `proguard-android-optimize.txt` and every +`**/*.pro` it finds in the app directory. R8 is the one thing that can break +this app in a way no build can see. + +Tauri's Android plugins are reached by NAME, at three layers, none of which +R8 can trace: + +- `register_android_plugin("com.soulstream.storage", "StoragePlugin")` becomes + the string `com/soulstream/storage/StoragePlugin` and goes to `FindClass`; + the instance is constructed through the JNI signature + `(Landroid/app/Activity;)V`. +- `PluginHandle.indexMethods()` walks the class and its superclasses with + `getDeclaredMethods()` and files every `@Command` under + `commands[method.name]`, every `@ActivityCallback` under + `startActivityCallbackMethods[method.name]`. `invoke()` looks up the name + Rust or the WebView asked for and throws `No command pickFolder found for + plugin …` when it is not there. +- Jackson fills every `@InvokeArg` class FIELD by field. + +So a minified build compiles, installs, launches and shows Home — and then a +tap on Library folder, or the card's next button, does nothing at all. Four +plugin modules expose eleven commands, four activity callbacks and four +argument classes that way. + +```bash +bash src-tauri/android/build.sh --release # or SS_ANDROID_RELEASE=1 bash src-tauri/android/build.sh +adb install -r src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk +``` + +With no argument `build.sh` is what it has always been — the same cargo line, +the same `assembleArm64Debug -x rustBuildArm64Debug`, the same APK path — so +`deploy.sh`, `dev.sh` and `ship.sh` cannot reach the release path by accident. +The flag changes four things and nothing else: `--release` and +`--features tauri/devtools` on the cargo line, the `release` profile's `.so`, +`assembleArm64Release -x rustBuildArm64Release`, and a sign step at the end. + +**Where the keep rules live, and why in two places.** Each plugin carries its +own `consumer-rules.pro` in `crates/tauri-plugin-*/android`, registered by one +`consumerProguardFiles("consumer-rules.pro")` line in its `build.gradle.kts` — +the same shape `tauri-android` and `tauri-plugin-dialog` already use in this +build. (`tauri-plugin-opener` ships a `consumer-rules.pro` and never registers +it; the file is inert. That is the mistake this line exists to avoid.) Consumer rules travel with the plugin, so a regenerated +`gen/android` keeps them, and they are inert in the debug APK because a +consumer file only does anything when the consuming app minifies. What belongs +to no single plugin is in `src-tauri/android/proguard-soulstream.pro`, which +`build.sh --release` copies into `src-tauri/gen/android/app/` for the +`fileTree(".")` glob to pick up — it cannot simply live there, because `gen/` +is generated and gitignored and `tauri android init` rewrites it. That file +holds three things: the runtime annotation attributes, a `-dontwarn` pair for +two `java.beans` classes jackson-databind references and Android does not +have (R8 treats a missing class as a build *error*), and a keep for four +`@Command` methods declared on `app.tauri.plugin.Plugin` itself — +`registerListener`, `removeListener`, `checkPermissions`, `requestPermissions`. +Tauri's own rule matches classes annotated `@TauriPlugin` and the members they +declare; the base class carries no such annotation, so nothing names those +four, and `registerListener` is what the lock-screen card's buttons and the +download notice's Cancel/Open ride on. That last rule is a precaution, not a +diagnosed bug — it is six lines, and it removes the question. + +**What already keeps the wry bridge, and must not be thrown away.** The +`**/*.pro` in that glob is recursive, and the file it reaches that nobody +thinks about is +`app/src/main/java/com/soulstream/app/generated/proguard-wry.pro`, written +beside the generated Kotlin by wry's own build script. It is what keeps +`Rust`'s native methods, `WryActivity.setWebView`/`getVersion`/`getId`, +`Ipc`'s `@JavascriptInterface` methods and `RustWebView.evalScript` — the +entire JNI bridge between the Rust core and the WebView, all of it reached by +string from Rust. `proguard-tauri.pro` beside it keeps +`TauriActivity.getPluginManager()`. Neither is ours and neither is in git; +both are regenerated. Narrow that glob to a named list and the app stops at a +black screen. The four plugins are the gap because they are the only +name-reached surface nobody else wrote a rule for. + +**What is different about a release build, beyond minification.** + +- `usesCleartextTraffic` flips to `"false"` (debug sets the placeholder to + `"true"`). The page's own origin is `http://tauri.localhost`, which Tauri + serves itself, so this should not bite — but it is the first thing to check + if the WebView comes up blank. +- Not debuggable, so `run-as com.soulstream.app` is gone: the documented way + of pushing test music into the app's private Library folder does not work + on a release build. Point it at a folder in shared storage instead. +- DevTools exist only because `build.sh --release` passes + `--features tauri/devtools`; wry compiles + `setWebContentsDebuggingEnabled` under + `cfg(any(debug_assertions, feature = "devtools"))`. Without it there is no + `webview_devtools_remote_` socket and `smoke.mjs --phone` has nothing + to attach to. +- The APK Gradle produces is `app-arm64-release-unsigned.apk` — there is no + `signingConfigs` block in the generated project. `build.sh --release` + zipaligns it and signs it with `~/.android/debug.keystore` + (alias `androiddebugkey`, password `android`), the **same key the debug + builds use**, so `adb install -r` replaces the app in place. Sign it with + any other key and that install fails; the obvious next move — uninstall, + then install — deletes the app's data, which on Kyle's phone means his + settings, his account and his queue. This is a test build, not a + distributable one. +- `cargo build --release` fills a second `target/aarch64-linux-android/release/` + tree; the debug artifacts are untouched, but that cache roughly doubles. +- Both build types package `app/src/main/jniLibs/arm64-v8a/libapp_lib.so`, and + a release run leaves the release library sitting there. `deploy.sh` copies + over it unconditionally, so the next debug deploy is correct — but a Gradle + run by hand in between would package the release library into a debug APK. +- If lint fails on something unrelated, run Gradle by hand with + `-x lintVitalArm64Release`; AGP 8 splits that into an analyze task and a + report task, so `-x lintVitalAnalyzeArm64Release` may be the one that + actually has to go. Untested — no release build has run here. + +**What to smoke-test on a release build, and only on a release build.** The +walks do not cover this: the pipeline reinstalls the debug APK constantly, and +what minification breaks is exactly the part that is hands rather than CDP — +pickers, notifications and a share sheet. Every plugin command, one at a time: + +- **storage** (six commands, four activity callbacks) — pick a library folder + (You → Library folder) and check the picked path comes back; the all-files + access row and the system screen its button opens; pick an image (a playlist + cover); pick a file (a playlist import); export a playlist, which is + `createDocument` and has no JS-facing command at all — it is called from + Rust, so it is the one a hand test forgets. +- **media-session** — play something and watch the card appear (`update`); its + prev, play, next and stop, which arrive through `registerListener`; stop + playing and watch it go (`clear`). +- **transfer-notice** — start a Save and watch the notice appear (`update`); + Cancel and Open on the notice, also `registerListener`; let it finish and + watch it go (`clear`). +- **share** — share plain text to SoulStream from another app (`pendingShare`). + +A command that has been renamed answers `No command found for plugin +`, which lands in logcat rather than on screen: +`adb logcat --pid=$(adb shell pidof com.soulstream.app)`. diff --git a/handoff/rounds/ins-r43-proguard-app.txt b/handoff/rounds/ins-r43-proguard-app.txt new file mode 100644 index 0000000..a0e8893 --- /dev/null +++ b/handoff/rounds/ins-r43-proguard-app.txt @@ -0,0 +1,96 @@ +# The app-wide R8 rules for a SoulStream release APK — the ones that belong +# to no single plugin. +# +# This file lives in the repo on purpose. The Gradle project that consumes it, +# src-tauri/gen/android/app, is GENERATED and gitignored, and +# `tauri android init` rewrites it, so a rule written into that project's own +# proguard-rules.pro is a rule with a short life. +# `bash src-tauri/android/build.sh --release` copies this file next to that +# project's build.gradle.kts, whose release build type reads +# +# proguardFiles(*fileTree(".") { include("**/*.pro") } +# .plus(getDefaultProguardFile("proguard-android-optimize.txt")) ...) +# +# — anything ending in .pro in that directory. Copying it is a no-op for the +# debug build: the debug build type sets isMinifyEnabled = false and names no +# proguard files at all, so the path the deploy pipeline runs every twenty +# minutes never reads a line of this. +# +# NOR are the rules for the wry/JNI bridge here. wry's build script writes +# proguard-wry.pro next to the Kotlin it generates, in +# app/src/main/java/com/soulstream/app/generated/, and the release build type's +# **/*.pro glob is recursive, so it is already picked up: Rust's native +# methods, WryActivity, Ipc's @JavascriptInterface, RustWebView.evalScript. +# proguard-tauri.pro, beside this file, keeps TauriActivity.getPluginManager(). +# Duplicating either here would be a second place to get them wrong. +# +# Per-plugin rules are NOT here. Each plugin carries its own +# consumer-rules.pro inside crates/tauri-plugin-*/android, so they travel with +# the plugin into a regenerated gen/android and into a plain +# `npx tauri android build` too. +# +# NOTHING BELOW HAS BEEN PROVEN BY A BUILD. Every APK this project has ever +# made is assembleArm64Debug with minification off. See ANDROID.md → +# "The release build". + + +# Tauri reads its own annotations off the class files at RUNTIME: +# PluginHandle.indexMethods() asks each Method whether @Command, +# @ActivityCallback and @PermissionCallback are present, and all three are +# @Retention(AnnotationRetention.RUNTIME). proguard-android-optimize.txt is +# expected to keep them with `-keepattributes *Annotation*`, but that file is +# extracted from the Android Gradle plugin when a release build runs and no +# release build has ever run here, so it has never existed in this tree to +# read. Say it outright rather than inherit it from a file this project does +# not own and cannot see. Signature, InnerClasses +# and EnclosingMethod are for Jackson, which resolves an @InvokeArg field's +# declared type before it fills it. +-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod + + +# THE GAP. Tauri's own consumer rule is +# +# -keep @app.tauri.annotation.TauriPlugin public class * { +# @app.tauri.annotation.Command public ; ... } +# +# and a class-member specification matches the members a matched class +# DECLARES. These four @Command methods are declared on app.tauri.plugin.Plugin +# — the abstract base every plugin extends — which carries no @TauriPlugin of +# its own; tauri's other rule for that package keeps only @JniMethod and +# native members. So no rule in the build names them, while +# PluginHandle.indexMethods() walks the superclass chain with +# getDeclaredMethods() and registers them by name regardless. +# +# registerListener is the one this app rides on. addPluginListener() in +# src/mobile/media.svelte.js and src/mobile/xfernote.svelte.js invokes +# plugin:media-session|registerListener and +# plugin:transfer-notice|registerListener, and those subscriptions are how the +# lock-screen card's prev/play/next/stop and the download notice's cancel and +# open get back into the app. Lose them and everything still looks right and +# nothing either of those buttons does works. +# +# Written as a precaution, not as a diagnosed bug: whether R8 reaches +# inherited members through tauri's rule could not be settled from source, and +# it cannot be settled on this machine at all. Six lines to stop asking. +-keep class app.tauri.plugin.Plugin { + public void registerListener(app.tauri.plugin.Invoke); + public void removeListener(app.tauri.plugin.Invoke); + public void checkPermissions(app.tauri.plugin.Invoke); + public void requestPermissions(app.tauri.plugin.Invoke); +} + + +# jackson-databind 2.15.3 — tauri's own dependency, the thing that fills every +# @InvokeArg — ships no META-INF/proguard rules of its own, and its +# databind/ext/Java7SupportImpl references java.beans.ConstructorProperties and +# java.beans.Transient, neither of which exists on Android. R8 in AGP 7+ turns +# a missing class into a BUILD ERROR, so without these two lines the release +# build may not get as far as producing an APK. +# +# If R8 names others, its own list is the answer: add what +# app/build/outputs/mapping/arm64Release/missing_rules.txt says to this file +# and run again. (jackson-core 2.15.3 scanned clean, and the +# java.lang.reflect.RecordComponent in jdk14/JDK14Util is a reflective string +# rather than a class reference, so it should not be reported.) +-dontwarn java.beans.ConstructorProperties +-dontwarn java.beans.Transient diff --git a/handoff/rounds/ins-r44-doc-byhand.txt b/handoff/rounds/ins-r44-doc-byhand.txt new file mode 100644 index 0000000..0667708 --- /dev/null +++ b/handoff/rounds/ins-r44-doc-byhand.txt @@ -0,0 +1,2 @@ +# 1b. the app's own resources, over the generated project's (the launcher icon) +cp -r src-tauri/android/res/. src-tauri/gen/android/app/src/main/res/ diff --git a/handoff/rounds/ins-r44-doc-row.txt b/handoff/rounds/ins-r44-doc-row.txt new file mode 100644 index 0000000..d7f9020 --- /dev/null +++ b/handoff/rounds/ins-r44-doc-row.txt @@ -0,0 +1 @@ +| `src-tauri/android/res/` | the app module's own Android resources, tracked: the adaptive launcher icon, its monochrome layer, the legacy mipmaps and the one colour the background is. `build.sh` and `dev.sh` copy the whole tree into `gen/android/app/src/main/res/` before Gradle; `verify-icon.sh` reads it back out of the APK | diff --git a/handoff/rounds/ins-r44-doc-section.txt b/handoff/rounds/ins-r44-doc-section.txt new file mode 100644 index 0000000..1a69126 --- /dev/null +++ b/handoff/rounds/ins-r44-doc-section.txt @@ -0,0 +1,91 @@ +## The launcher icon + +Until this round the phone wore **tauri's** logo. `tauri android init` writes +the generated project's `res/` from its own template and never reads +`src-tauri/icons/android/` — which has carried the falcon since `50afa6a` +— and the detour build runs cargo and Gradle directly, so nothing ever +copied it across. The two dominant colours in +`gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png` were `#24c8db` +and `#ffc131`: tauri's brand cyan and tauri's brand amber. + +`src-tauri/android/res/` is the app module's res, tracked, and `build.sh` and +`dev.sh` copy the whole tree into the generated project before Gradle runs. +It carries: + +- `mipmap-anydpi-v26/ic_launcher.xml` and `ic_launcher_round.xml` — the + adaptive icon: a foreground, a background, and a **monochrome** layer, which + is what Android 13's themed icons need and what tauri's generator has never + made; +- `mipmap-{m,h,xh,xxh,xxxh}dpi/` — `ic_launcher_foreground.png` and + `ic_launcher_monochrome.png` (108–432 px) from the kit's + `android/adaptive/res/`, and the legacy `ic_launcher.png` / + `ic_launcher_round.png` (48–192 px) from its `android/legacy/`; +- `values/ic_launcher_background.xml` — `#FF05080B`, the brand's night. + +Three things about it are not obvious. + +**The kit's own adaptive XML does not compile here.** Its +`android/adaptive/res/mipmap-anydpi-v26/*.xml` points every layer at +`@drawable/…`, and the kit ships those layers as PNGs under `mipmap-*` with +no `drawable-*` directory anywhere. That is worse than a missing resource: +the generated project still carries Android Studio's template vectors at +`drawable/ic_launcher_background.xml` and +`drawable-v24/ic_launcher_foreground.xml`, so two of the three references +resolve — to the green robot — and only `@drawable/ic_launcher_monochrome` +fails the build. The overlay's XML says `@mipmap/` for the two image layers +and `@color/` for the background instead. + +**The background is one colour resource, not five identical flat PNGs.** +A richer gradient is being mocked separately; when it is approved it replaces +one file, and the three XML references do not move. + +**The colour file is named `ic_launcher_background.xml` on purpose.** +`src-tauri/icons/android/values/ic_launcher_background.xml` declares the same +resource name (`#fff`), and two files in one `values/` directory declaring +one colour is a hard aapt2 error — `resource 'color/ic_launcher_background' +has a conflicting value`. Sharing the filename means the overlay overwrites +it rather than colliding with it, if a future `npx tauri android init` ever +copies it in. + +That leaves `src-tauri/icons/android/` a second, stale copy of these same +resource names: white background, no monochrome, hdpi at 49×49. Nothing on +Android reads it while the build is the detour — but `npx tauri icon` writes +it, and it should be brought to the kit before anyone builds through the CLI. + +### Checking it + +`verify-icon.sh` opens the APK `build.sh` has just made and reads the +resources back out. It checks the overlay's own two XMLs first, before the APK +is opened — a `` layer that a later edit drops is a failure too — +and then reads the APK, debug and release differently, because AGP hands them +to aapt2 differently: + +- **debug** — the packaging default for a debuggable build type is + `--no-crunch`, so every entry *is* the file on disk. Both + `res/mipmap-anydpi-v26/*.xml` must be in the APK, and all four PNG names at + all five densities must be byte for byte the overlay's. +- **release** (`--crunched`) — aapt2 re-encodes every PNG, *and* AGP's + resource optimization (`OptimizeResourcesTask` → `SHORTEN_RESOURCE_PATHS`) + renames the lot: an APK optimized here had no `res/mipmap-…` entry left at + all, only `res/Ag.png`. Neither the bytes nor the names survive, so the + check asks `aapt2 dump resources` what each name points at and compares the + pixel dimensions of whatever that is — 72×72 at hdpi still separates the + kit's legacy icon from tauri's 49×49. If the resource *names* have been + collapsed as well (that comes with resource shrinking, which this project + does not turn on), it says the icon is unchecked rather than claiming a + failure it cannot see. + +Both `build.sh` and `dev.sh` call it, so no APK this repo makes can quietly go +back to the old artwork. It costs about a second and a half on the 107 MB +debug APK. + +What it cannot check is what the icon looks like. `deploy.sh`'s `px-1.png` is +a screenshot of the app five seconds after launch; the launcher is not in it. +The one thing that may now differ there is the Android 12+ system splash, +which is drawn from the launcher icon — if a cold start ever runs past those +five seconds, that splash is the falcon on `#05080B` rather than tauri's mark. +**Adaptive masking and the monochrome layer can only be judged from the +phone's home screen**, which is a minute with the Pixel: the icon under the +launcher's mask, in the app drawer, in recents and in another app's share +sheet — and then Wallpaper & style → Themed icons on, to see the monochrome +layer take the wallpaper's colour. diff --git a/handoff/rounds/ins-r44-overlay-copy.txt b/handoff/rounds/ins-r44-overlay-copy.txt new file mode 100644 index 0000000..350d904 --- /dev/null +++ b/handoff/rounds/ins-r44-overlay-copy.txt @@ -0,0 +1,9 @@ +# The launcher identity, into a project that cannot keep it: gen/android is +# regenerated by the tauri CLI and gitignored, and the detour build never runs +# that CLI, so the res it holds is still the one `android init` wrote — tauri's +# own logo. Everything under src-tauri/android/res/ is copied over it on every +# build. The whole tree, not a file list: a later round adding a resource adds +# a file there and this line does not change. +echo "== res overlay $(date +%T)" +test -d src-tauri/android/res || { echo "src-tauri/android/res is gone — the APK would wear tauri's logo and say nothing"; exit 1; } +cp -r src-tauri/android/res/. src-tauri/gen/android/app/src/main/res/ diff --git a/handoff/rounds/ins-r44-verify-icon.txt b/handoff/rounds/ins-r44-verify-icon.txt new file mode 100644 index 0000000..914dded --- /dev/null +++ b/handoff/rounds/ins-r44-verify-icon.txt @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Is the falcon actually in the APK? The way this loses is silent: gen/android +# is regenerated and gitignored, so a project rebuilt without the overlay copy +# still compiles, still installs, and wears tauri's logo again with nothing in +# the log. So every APK this repo makes is opened and read before it is +# installed. Called by build.sh and dev.sh; run it by hand on any APK. +# +# bash src-tauri/android/verify-icon.sh [--crunched] +# +# Debug and release are read DIFFERENTLY, because AGP treats their resources +# differently and both differences were measured on 2026-09-14: +# +# debug packaged with --no-crunch (AGP's default for the debuggable build +# type), so every entry is byte for byte the file on disk — checked +# against the real 107 MB APK. Names are names: res/mipmap-hdpi-v4/… +# release --crunched. Two things change. aapt2 re-encodes every PNG, so the +# bytes are no longer the file; and AGP's resource optimization +# (OptimizeResourcesTask → SHORTEN_RESOURCE_PATHS) renames the lot +# to res/Ag.png, so the names are gone as well — an APK optimized +# here had zero entries matching "res/mipmap". Nothing but the +# resource table can say what is what, so that is what is read: +# `aapt2 dump resources`, then the pixel dimensions of whatever it +# points at. 72x72 at hdpi still tells the kit's legacy icon from +# tauri's 49x49. +# +# If anyone ever sets isCrunchPngs = true on the debug build type, the debug +# path fails on byte-equality with the file named; --crunched is the answer. +set -uo pipefail +APK="${1:-}" +test -n "$APK" || { echo "usage: verify-icon.sh [--crunched]"; exit 2; } +# resolved before the cd, so a relative path typed by hand still means what it +# said; the repo root is where $RES is +[ -f "$APK" ] && APK="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")" +# the repo this script is IN, not a drive letter. build.sh and dev.sh have +# already cd'd to /f/Coding/SoulStream and call this as +# src-tauri/android/verify-icon.sh, so ../.. lands on the same place they are +# — but a checkout on another letter, or an APK checked by hand from +# somewhere else, then compares against ITS OWN overlay rather than F:'s. +cd "$(dirname "$0")/../.." || { echo "cannot find the repo above $0"; exit 2; } +CRUNCH=0; [ "${2:-}" = "--crunched" ] && CRUNCH=1 +RES=src-tauri/android/res +DPI="mdpi hdpi xhdpi xxhdpi xxxhdpi" +NAMES="ic_launcher ic_launcher_round ic_launcher_foreground ic_launcher_monochrome" +bad() { echo "ICON CHECK FAILED: $*"; exit 1; } +test -f "$APK" || bad "no APK at $APK" +test -d "$RES" || bad "$RES is missing — there is nothing to check the APK against" + +# 1. the overlay itself, before the APK is opened. The monochrome layer is the +# one Android 13 tints for themed icons and the one tauri's generator has +# never made, so a later edit that drops it should not pass quietly. +for x in ic_launcher ic_launcher_round; do + test -f "$RES/mipmap-anydpi-v26/$x.xml" || bad "$RES/mipmap-anydpi-v26/$x.xml is missing from the overlay" + grep -q " layer — themed icons need one" +done +for d in $DPI; do for n in $NAMES; do + test -f "$RES/mipmap-$d/$n.png" || bad "$RES/mipmap-$d/$n.png is missing from the overlay" +done; done + +# the PNG header's width and height, eight bytes at offset 16 — the one thing +# about an image that survives being re-encoded. Printed as 72x72, so a failure +# reads as a size rather than as hex. +dims() { + local h + h="$(xxd -s 16 -l 8 -p)" + [ "${#h}" = 16 ] || { echo "unreadable"; return; } + printf '%dx%d\n' "$((16#${h:0:8}))" "$((16#${h:8:8}))" +} + +LIST="$(unzip -l "$APK")" || bad "cannot read $APK" + +if [ "$CRUNCH" = 0 ]; then + # 2. debug: the adaptive XMLs by name, then all twenty PNGs byte for byte. + # Nothing but the overlay makes a mipmap-anydpi-v26 file, so its absence + # is exactly the failure this script exists for. + for x in ic_launcher ic_launcher_round; do + echo "$LIST" | grep -q " res/mipmap-anydpi-v26/$x\.xml$" \ + || bad "res/mipmap-anydpi-v26/$x.xml is not in the APK — $RES never reached src-tauri/gen/android/app/src/main/res" + done + for d in $DPI; do for n in $NAMES; do + got="res/mipmap-$d-v4/$n.png" + echo "$LIST" | grep -q " $got$" || bad "$got is not in the APK" + unzip -p "$APK" "$got" | cmp -s - "$RES/mipmap-$d/$n.png" \ + || bad "$got in the APK is not $RES/mipmap-$d/$n.png — a stale res in gen/, the overlay lost a file, or this build crunched its PNGs (then: --crunched)" + done; done + echo "== icon: adaptive + monochrome, five densities, byte for byte from $RES" + exit 0 +fi + +# 3. release. Ask the resource table what the names point at, since the paths +# have very likely been shortened away. aapt2 is beside the zipalign and +# apksigner build.sh has just used. +SDKU="" +[ -n "${ANDROID_HOME:-}" ] && SDKU="$(cygpath -u "$ANDROID_HOME" 2>/dev/null || echo "")" +[ -z "$SDKU" ] && [ -n "${LOCALAPPDATA:-}" ] && SDKU="$(cygpath -u "$LOCALAPPDATA" 2>/dev/null || echo "")/Android/Sdk" +A2="$(ls -1 "$SDKU"/build-tools/*/aapt2.exe 2>/dev/null | sort -V | tail -1 || true)" +if [ -z "$A2" ]; then + echo "== icon: NOT CHECKED — no aapt2 under ${SDKU:-(no SDK path)}/build-tools." + echo " A release APK's resource paths are shortened (res/Ag.png), so names" + echo " alone cannot find the icon. Unverified here, not missing." + exit 0 +fi +# one line per file entry: " " +MAP="$("$A2" dump resources "$APK" | awk ' + /^[ \t]*resource 0x[0-9a-f]+ mipmap\// { split($3, a, "/"); cur = a[2]; next } + /^[ \t]*resource 0x/ { cur = ""; next } + cur != "" { for (i = 1; i <= NF; i++) if ($i ~ /^res\//) { cfg = $1; gsub(/[()]/, "", cfg); print cur, cfg, $i } } +')" || bad "aapt2 could not read the resource table of $APK" +# the other half of the same optimization pass: with resource shrinking on, +# aapt2 also collapses the NAMES, and then even the table cannot say which +# entry is the launcher icon +if echo "$MAP" | grep -q "0_resource_name_obfuscated"; then + echo "== icon: NOT CHECKED — this APK's resource names are collapsed" + echo " (aapt2 optimize --collapse-resource-names, which comes with resource" + echo " shrinking). Nothing left to match on. Unverified here, not missing." + exit 0 +fi +# the config is printed as "mdpi" or as "mdpi-v4" depending on the link's +# min-sdk — both seen on this machine from the same resource tree, so both are +# taken, and "anydpi" matches the "anydpi-v26" the adaptive icon lives in +look() { echo "$MAP" | awk -v n="$1" -v c="$2" '$1 == n && ($2 == c || $2 ~ "^" c "-v[0-9]+$") { print $3; exit }'; } +for x in ic_launcher ic_launcher_round; do + got="$(look "$x" anydpi)" + test -n "$got" || bad "mipmap/$x has no anydpi entry in this APK — the adaptive icon is not in it, so $RES never reached src-tauri/gen/android/app/src/main/res" +done +for d in $DPI; do for n in $NAMES; do + got="$(look "$n" "$d")" + test -n "$got" || bad "mipmap/$n has no $d entry in this APK" + a="$(unzip -p "$APK" "$got" | dims)" + b="$(dims < "$RES/mipmap-$d/$n.png")" + [ "$a" = "$b" ] || bad "mipmap/$n at $d ($got) is $a, not the overlay's $b" +done; done +echo "== icon: adaptive + monochrome, five densities, by resource table ($(basename "$(dirname "$A2")")); release re-encodes the bytes, so dimensions only" diff --git a/handoff/rounds/ins-r45-appicon.txt b/handoff/rounds/ins-r45-appicon.txt new file mode 100644 index 0000000..e1d715f --- /dev/null +++ b/handoff/rounds/ins-r45-appicon.txt @@ -0,0 +1,39 @@ + /** The app's own icon, decoded ONCE and kept, as the card's large icon + * when there is no cover yet. `art` is cleared the moment `artKey` + * changes and only refilled when the frontend has finished decoding a + * cover into bytes — and a track with no cover never refills it at all — + * so every track change has a real window with a null large icon and the + * lock screen shows a grey hole. This is what stands in it. + * + * Through the PackageManager rather than a resource id on purpose: this + * is a library module and its own R has no `ic_launcher`, so from in here + * the app's icon is only reachable this way. + * + * Drawn at the tile's own bounds, which is the whole trick. On API 26+ + * what comes back is an AdaptiveIconDrawable, and an adaptive icon's + * BOUNDS are the 72 units the launcher shows — not the 108 its layers + * are drawn in. The drawable scales its own layers up by 3/2 and masks + * them inside whatever box it is handed, which is why its intrinsic + * width is two thirds of the layer PNG. So setBounds(0, 0, 512, 512) IS + * the launcher's view; a box set outside the tile to "reveal the 18% + * bleed" does the opposite and crops into the safe zone. Every other + * kind of drawable simply fills the tile. + * + * Decoded on the first ask and never again — `refresh()` runs on every + * position tick, and this must not. A failure caches nothing and answers + * null, which is the card exactly as it was before this existed. */ + fun appIcon(ctx: Context): Bitmap? { + val cached = icon + if (cached != null) return cached + return try { + val drawable = ctx.packageManager.getApplicationIcon(ctx.packageName) + val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888) + drawable.setBounds(0, 0, 512, 512) + drawable.draw(Canvas(bitmap)) + icon = bitmap + bitmap + } catch (e: Exception) { + android.util.Log.w("SoulStream", "app icon could not be decoded: $e") + null + } + } diff --git a/handoff/rounds/ins-r45-doc-down.txt b/handoff/rounds/ins-r45-doc-down.txt new file mode 100644 index 0000000..b83f25f --- /dev/null +++ b/handoff/rounds/ins-r45-doc-down.txt @@ -0,0 +1,4 @@ +The notice's glyph is the same falcon as the playback card's, diving into the +same tray bar it always had, so the two notices read as one app — a second +copy of the path on purpose, because `R.drawable.ic_soulstream_note` belongs +to the media-session module's R class and is not visible from here. diff --git a/handoff/rounds/ins-r45-doc-icon.txt b/handoff/rounds/ins-r45-doc-icon.txt new file mode 100644 index 0000000..fcd653b --- /dev/null +++ b/handoff/rounds/ins-r45-doc-icon.txt @@ -0,0 +1,61 @@ +The glyph in the status bar is the falcon, not a note. +`ic_soulstream_note.xml` is the logo kit's monochrome path verbatim +(`branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg`), +wrapped in a `` that re-frames it for a 24dp box — an Android path +element has no `transform` attribute, so the SVG's own +`translate/scale/translate` chain is folded into the group's numbers and the +falcon lands x 4.91→19.09, y 2→22. The fill stays `#FFFFFFFF` on a +transparent viewport because Android composites a notification's small icon +from its ALPHA CHANNEL and tints it itself: a coloured export renders as a +white blob. Nothing in the walk can tell those two apart — `dumpsys +notification` prints the icon's RESOURCE ID and never a pixel — so the glyph +is a screenshot, and the only thing the walk can prove is the thing that +actually breaks. `media-card` proves it: after `resumed`, the card is still +posted, on the `playback` channel, carrying the playing track's own title, +and `media-keys` a line below still gets the transport back. + +The card's large icon falls back to the app's own icon. `MediaHub.art` is +cleared the moment `artKey` changes and only refilled once the frontend has +decoded a cover into bytes, and a track with no cover never refills it at +all — so without a fallback every track change has a window where the lock +screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and +keeps it (`refresh()` runs on every position tick, so a per-update decode +would be a real cost), through the PackageManager rather than a resource id +because a library module's own R has no `ic_launcher`. It is drawn at +`setBounds(0, 0, 512, 512)`, and that is less obvious than it looks: an +`AdaptiveIconDrawable`'s bounds are the **72** units the launcher shows, not +the 108 its layers are drawn in — it scales its own layers up by 3/2 and +masks them inside whatever box it is handed, which is why its intrinsic width +is two thirds of the layer PNG. So the plain call already IS the launcher's +view, and a box set outside the tile to "reveal the 18% bleed" does the +opposite: it crops into the safe zone. The kit's foreground inks y 20.5→86.5 +of 108, so a box 128 units oversize on each side would have shown 30→78 — +the head and the tail cut off. The same bitmap serves +`METADATA_KEY_ALBUM_ART`. + +`android:appCategory="audio"` is contributed by the media-session plugin's +own library manifest. The app manifest lives in gitignored `gen/` and cannot +be edited from the repo, and it declares no category — so the merger takes +the library's, which is the one route there is. + +Android 12+ draws a splash whether we ask or not, and the platform's own is a +white flash; `Theme.App.Starting` puts the brand ground (`#05080B`, the kit's +`night`) and `@mipmap/ic_launcher` there instead, and takes the window +background with it so the frame before the WebView's first paint is not white +either. It is delivered as a tracked res overlay, `src-tauri/android/res/`, +laid over the generated project by `build.sh` and `dev.sh` — the same move +`build.sh` already makes for `proguard-soulstream.pro`, and for the same +reason: `gen/` is regenerated and gitignored, so nothing of ours survives in +it. The overlay carries BOTH `values-v31/themes.xml` and +`values-night-v31/themes.xml`, and that pair is not belt and braces: the +resource qualifier order puts NIGHT MODE ahead of PLATFORM VERSION, so on a +dark-mode Android 14 phone the generated `values-night/themes.xml` beats a +lone `values-v31/` and the splash is silently ignored — on this phone, most +likely always. The style IS `Theme.app` (its parent), not a theme nothing +references, because `androidx.core:core-splashscreen` is not a dependency of +the app module: the platform attributes are the whole mechanism, there is no +`postSplashScreenTheme`, and `Theme.app` already is the post-splash theme. +Both overlay files replace `Theme.app` outright on API 31+, so anything a +later round adds to the generated `values/themes.xml` has to be added to them +too. The splash is a screenshot as well — it is gone before the walk's first +frame and `dumpsys` has no word for it. diff --git a/handoff/rounds/ins-r45-falcon-path.txt b/handoff/rounds/ins-r45-falcon-path.txt new file mode 100644 index 0000000..97ea747 --- /dev/null +++ b/handoff/rounds/ins-r45-falcon-path.txt @@ -0,0 +1 @@ +M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.833 C 535.533 774.313 524.758 779.223 520.900 781.698 C 515.461 785.189 506.696 794.586 503.710 800.128 C 502.401 802.557 501.665 804.545 502.074 804.545 C 502.483 804.545 505.038 804.134 507.752 803.633 Z \ No newline at end of file diff --git a/handoff/rounds/ins-r45-overlay-copy.txt b/handoff/rounds/ins-r45-overlay-copy.txt new file mode 100644 index 0000000..f7ccb44 --- /dev/null +++ b/handoff/rounds/ins-r45-overlay-copy.txt @@ -0,0 +1,9 @@ +# Our own resources, into a project that cannot hold them itself: gen/ is +# regenerated and gitignored, so anything of ours that must reach the APK's +# res/ lives under src-tauri/android/res/ and is laid over the generated tree +# here — the same move build.sh already makes for proguard-soulstream.pro. +# Files present in both are OURS; files only in gen/ are left alone. +if [ -d src-tauri/android/res ]; then + mkdir -p src-tauri/gen/android/app/src/main/res + cp -r src-tauri/android/res/. src-tauri/gen/android/app/src/main/res/ +fi diff --git a/handoff/rounds/ins-r45-smoke-card.txt b/handoff/rounds/ins-r45-smoke-card.txt new file mode 100644 index 0000000..9a0e573 --- /dev/null +++ b/handoff/rounds/ins-r45-smoke-card.txt @@ -0,0 +1,42 @@ + /* ── the card is still in the shade ─────────────────────── + What a small icon the shade cannot inflate, or a large icon + that throws while the notification is built, actually does: + the card is never posted at all. So read it back out of the + same dump the downloads notice uses — our package, on the + `playback` channel, with the playing track's own title on it. + The title is taken from the player on BOTH sides of the dump + because a track may cross while it runs, and either answer is + a correct one; both sides come from the live app at the same + moment, so a real core answering faster than the mock cannot + make this disagree with itself. + + It runs after `resumed` has asserted status === "playing", so + the ongoing card is settled state rather than a race, and it + sits above `media-keys`, which proves the transport still + answers through the session. + + What this canNOT prove: the GLYPH. `dumpsys notification` + prints the icon's resource id and never a pixel, so a correct + falcon and a white rectangle read identically here. That one + is a screenshot. */ + let cardBefore = await cdp.evaluate(PLAYER); + let cardNote = shadeNotice(); + let cardAfter = await cdp.evaluate(PLAYER); + const cardAgrees = () => /playback/.test(cardNote.shadeChans) && !!cardNote.shadeCard + && (cardNote.shadeCard === cardBefore?.title || cardNote.shadeCard === cardAfter?.title); + if (!cardAgrees()) { + /* the one race the two reads do not cover: `push()` in + media.svelte.js is debounced, so the card trails the + player by a moment, and a track crossing exactly here + shows the player two NEW titles and the shade the old + one. Let it settle and look once more. A card that is + not posted at all fails this the second time too. */ + await sleep(2000); + cardBefore = await cdp.evaluate(PLAYER); + cardNote = shadeNotice(); + cardAfter = await cdp.evaluate(PLAYER); + } + const cardOk = cardAgrees(); + const cardSeen = { chans: cardNote.shadeChans, card: cardNote.shadeCard, title: cardAfter?.title ?? null }; + if (!cardOk) problems.push("EXPECT media-card " + JSON.stringify(cardSeen)); + console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen)); diff --git a/handoff/rounds/ins-r45-themes-night-v31.txt b/handoff/rounds/ins-r45-themes-night-v31.txt new file mode 100644 index 0000000..71e29c2 --- /dev/null +++ b/handoff/rounds/ins-r45-themes-night-v31.txt @@ -0,0 +1,15 @@ + + + + + +