android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -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`.
|
||||||
@@ -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 `<meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/>` 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 `<filesDir>/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<Arc<Player>>` 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<PathBuf>` 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 `<color name="ic_launcher_background">#FF05080B</color>` 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<Arc<Player>>` 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 `<filesDir>/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.
|
||||||
@@ -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 ? '</radialGradient>' : '</linearGradient>';
|
||||||
|
const end = svg.indexOf(close, i) + close.length;
|
||||||
|
return { block: svg.slice(start, end), radial: isRadial };
|
||||||
|
}
|
||||||
|
function stopsOf(block) {
|
||||||
|
return [...block.matchAll(/<stop\b[^>]*\/?>/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 = /<path\b[^>]*>/.exec(fgSvg)[0];
|
||||||
|
const monoTag = /<path\b[^>]*>/.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(/<rect\b[^>]*>/.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');
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>SoulStream Android icon — flat vs concept 04 gradient</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--ui-bg:#0e1013; --ui-panel:#161a1f; --ui-line:#252b33;
|
||||||
|
--ui-fg:#d2d9e1; --ui-dim:#828e9b; --ui-hi:#6fd3e0;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;background:var(--ui-bg);color:var(--ui-fg);
|
||||||
|
font:13px/1.45 ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||||
|
a{color:var(--ui-hi)}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:18px 20px 60px}
|
||||||
|
|
||||||
|
header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;margin-bottom:14px}
|
||||||
|
header.top h1{font-size:15px;font-weight:650;margin:0;letter-spacing:.2px}
|
||||||
|
header.top .sub{font-size:12px;color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;gap:8px 18px;
|
||||||
|
align-items:center;padding:10px 12px;margin:0 0 18px;border:1px solid var(--ui-line);
|
||||||
|
border-radius:10px;background:rgba(16,19,23,.94);backdrop-filter:blur(6px)}
|
||||||
|
.grp{display:flex;align-items:center;gap:7px}
|
||||||
|
.grp>label{font-size:10.5px;text-transform:uppercase;letter-spacing:.9px;color:var(--ui-dim)}
|
||||||
|
.seg{display:flex;border:1px solid var(--ui-line);border-radius:7px;overflow:hidden}
|
||||||
|
.seg button{appearance:none;border:0;background:transparent;color:var(--ui-dim);
|
||||||
|
font:inherit;font-size:12px;padding:5px 9px;cursor:pointer;white-space:nowrap}
|
||||||
|
.seg button+button{border-left:1px solid var(--ui-line)}
|
||||||
|
.seg button:hover{color:var(--ui-fg)}
|
||||||
|
.seg button[aria-pressed="true"]{background:#232c35;color:#eaf3fa}
|
||||||
|
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-dim);cursor:pointer}
|
||||||
|
|
||||||
|
h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:1.2px;color:var(--ui-dim);
|
||||||
|
margin:26px 0 9px;font-weight:600}
|
||||||
|
|
||||||
|
.band{border-radius:12px;padding:22px 18px 16px;margin-bottom:12px;position:relative;overflow:hidden}
|
||||||
|
.band .bandname{position:absolute;top:8px;left:12px;font-size:10px;letter-spacing:1.1px;
|
||||||
|
text-transform:uppercase;opacity:.55;font-weight:600}
|
||||||
|
.band.dark{background:radial-gradient(125% 105% at 18% -5%,#2e3339 0%,#191c21 52%,#0a0b0d 100%)}
|
||||||
|
.band.dark .bandname{color:#fff}
|
||||||
|
.band.light{background:radial-gradient(125% 105% at 18% -5%,#fdfaf5 0%,#ece5db 52%,#d5cdc1 100%)}
|
||||||
|
.band.light .bandname{color:#2b2722}
|
||||||
|
.band.neutral{background:#12151a;border:1px solid var(--ui-line)}
|
||||||
|
.band.neutral .bandname{color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.cols{display:flex;flex-wrap:wrap;gap:26px;align-items:flex-start;justify-content:center}
|
||||||
|
.col{display:flex;flex-direction:column;align-items:center;gap:9px;min-width:104px}
|
||||||
|
.col.today{opacity:.92}
|
||||||
|
.tilebox{display:flex;align-items:center;justify-content:center;min-height:0}
|
||||||
|
.tag{font-size:12px;font-weight:700;letter-spacing:.4px;text-align:center}
|
||||||
|
.tagsub{font-size:10.5px;line-height:1.35;text-align:center;max-width:150px}
|
||||||
|
.band.dark .tag{color:#eef4fa}.band.dark .tagsub{color:#9aa6b3}
|
||||||
|
.band.light .tag{color:#221f1b}.band.light .tagsub{color:#6a635a}
|
||||||
|
.band.neutral .tag{color:#eef4fa}.band.neutral .tagsub{color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.divider{width:1px;align-self:stretch;background:currentColor;opacity:.16;margin:0 2px}
|
||||||
|
|
||||||
|
.ladder{display:flex;flex-direction:column;gap:14px}
|
||||||
|
.lrow{display:flex;align-items:flex-end;gap:26px;justify-content:center}
|
||||||
|
.lcell{display:flex;flex-direction:column;align-items:center;gap:6px}
|
||||||
|
.lcell .sz{font-size:10px;color:var(--ui-dim)}
|
||||||
|
.lhead{display:flex;gap:26px;justify-content:center;margin-bottom:2px}
|
||||||
|
.lhead span{width:118px;text-align:center;font-size:11px;font-weight:700;letter-spacing:.4px}
|
||||||
|
|
||||||
|
.note{border:1px solid var(--ui-line);border-radius:10px;padding:14px 16px;background:var(--ui-panel);
|
||||||
|
font-size:12.5px;color:#b6c0cb;margin-top:14px}
|
||||||
|
.note b{color:#e6eef6;font-weight:650}
|
||||||
|
.note p{margin:0 0 9px}.note p:last-child{margin:0}
|
||||||
|
table.m{border-collapse:collapse;font:11.5px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:4px}
|
||||||
|
table.m th,table.m td{border:1px solid var(--ui-line);padding:4px 8px;text-align:left}
|
||||||
|
table.m th{color:var(--ui-dim);font-weight:600}
|
||||||
|
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:6px;
|
||||||
|
border:1px solid rgba(255,255,255,.18)}
|
||||||
|
.hidden{display:none}
|
||||||
|
.banner{border-left:2px solid var(--ui-hi);padding:8px 0 8px 12px;margin:0 0 14px;
|
||||||
|
font-size:12.5px;color:#b6c0cb}
|
||||||
|
.banner b{color:#e6eef6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<h1>SoulStream Android launcher icon — background layer decision</h1>
|
||||||
|
<span class="sub">real kit layers · true 108/72 adaptive geometry · nothing here is shipped</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<div class="grp"><label>Mask</label><div class="seg" id="segMask"></div></div>
|
||||||
|
<div class="grp"><label>Size</label><div class="seg" id="segSize"></div></div>
|
||||||
|
<div class="grp"><label>Mode</label><div class="seg" id="segMode"></div></div>
|
||||||
|
<label class="chk"><input type="checkbox" id="cbShadow" checked> launcher shadow</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="cbSafe"> safe-zone guides</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stdBanner" class="banner">
|
||||||
|
<b>A</b> is the kit as delivered. <b>B</b> and <b>C</b> are the same concept-04 gradient, differing only in
|
||||||
|
how the 1024 artwork is mapped onto the 108-unit layer. Reply with a letter.
|
||||||
|
</div>
|
||||||
|
<div id="thmBanner" class="banner hidden">
|
||||||
|
<b>Themed icons discard the background layer entirely.</b> Android 13+ draws only the
|
||||||
|
<code><monochrome></code> drawable, tinted, on a wallpaper-derived system ground — so A, B and C are
|
||||||
|
pixel-identical here. The decision below is not a background decision; it is only whether the monochrome
|
||||||
|
layer exists at all.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stage"></div>
|
||||||
|
|
||||||
|
<h2 class="sec">Size ladder — same mask, same wallpaper</h2>
|
||||||
|
<div class="band neutral"><span class="bandname">Neutral ground</span><div id="ladder" class="ladder"></div></div>
|
||||||
|
|
||||||
|
<div class="note" id="notes"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const D = {"flat":"#05080B","fgPath":{"d":"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.Line truncated
|
||||||
|
|
||||||
|
/* ---------- Android adaptive geometry ----------------------------------
|
||||||
|
Layers are 108x108 dp. The launcher mask shows only the central 72x72 dp,
|
||||||
|
i.e. the layer is drawn at 1.5x the on-screen icon size and centre-cropped.
|
||||||
|
Every tile below is an <svg viewBox="18 18 72 72"> over 108-unit artwork,
|
||||||
|
which reproduces exactly that. The 66 dp circle is the content safe zone.
|
||||||
|
------------------------------------------------------------------------*/
|
||||||
|
const VIEWBOX = '18 18 72 72';
|
||||||
|
|
||||||
|
/* Mask outlines in a 0..100 box, mapped into the visible 72 window. */
|
||||||
|
const MASKS = {
|
||||||
|
circle: {label:'Circle', d:'M50,0 A50,50 0 1,1 50,100 A50,50 0 1,1 50,0 Z'},
|
||||||
|
squircle: {label:'Squircle', d:'M50,0 C10.05,0 0,10.05 0,50 C0,89.95 10.05,100 50,100 C89.95,100 100,89.95 100,50 C100,10.05 89.95,0 50,0 Z'},
|
||||||
|
rsquare: {label:'Rounded square',d:'M20,0 L80,0 A20,20 0 0,1 100,20 L100,80 A20,20 0 0,1 80,100 L20,100 A20,20 0 0,1 0,80 L0,20 A20,20 0 0,1 20,0 Z'},
|
||||||
|
teardrop: {label:'Teardrop (aggressive OEM)',d:'M0,20 A20,20 0 0,1 20,0 L50,0 A50,50 0 1,1 0,50 Z'}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES = [
|
||||||
|
{px:24, cap:'24 dp · notification'},
|
||||||
|
{px:32, cap:'32 dp · recents chip'},
|
||||||
|
{px:48, cap:'48 dp · launcher grid'},
|
||||||
|
{px:64, cap:'64 dp · folder / tablet'},
|
||||||
|
{px:112, cap:'112 dp · inspect'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const VARIANTS = [
|
||||||
|
{id:'today', tag:'TODAY', sub:'shipping raster — Tauri placeholder on a white plate', today:true},
|
||||||
|
{id:'flat', tag:'A — FLAT', sub:'kit as delivered · solid ' + D.flat},
|
||||||
|
{id:'gradL', tag:'B — GRADIENT', sub:'concept 04 mapped 1024 → the whole 108 layer'},
|
||||||
|
{id:'gradM', tag:'C — GRADIENT', sub:'concept 04 mapped 1024 → the visible 72 window'}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- defs ------------------------------------------------------ */
|
||||||
|
function stopsSVG(stops){
|
||||||
|
return stops.map(s =>
|
||||||
|
'<stop offset="'+s.o+'" stop-color="'+s.c+'"'+(s.a!=null?' stop-opacity="'+s.a+'"':'')+'/>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
function linSVG(id,g){
|
||||||
|
return '<linearGradient id="'+id+'" gradientUnits="userSpaceOnUse" color-interpolation="sRGB" '+
|
||||||
|
'x1="'+g.x1+'" y1="'+g.y1+'" x2="'+g.x2+'" y2="'+g.y2+'">'+stopsSVG(g.stops)+'</linearGradient>';
|
||||||
|
}
|
||||||
|
function radSVG(id,g){
|
||||||
|
return '<radialGradient id="'+id+'" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" '+
|
||||||
|
'gradientTransform="translate('+g.cx+' '+g.cy+') rotate('+g.rot+') scale('+g.r+')">'+
|
||||||
|
stopsSVG(g.stops)+'</radialGradient>';
|
||||||
|
}
|
||||||
|
function buildDefs(){
|
||||||
|
let s = '<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>';
|
||||||
|
s += linSVG('fgGrad', D.fgGrad);
|
||||||
|
s += linSVG('surfL', D.gradL.surf) + radSVG('glowL', D.gradL.glow);
|
||||||
|
s += linSVG('surfM', D.gradM.surf) + radSVG('glowM', D.gradM.glow);
|
||||||
|
for (const k in MASKS){
|
||||||
|
s += '<clipPath id="clip-'+k+'" clipPathUnits="userSpaceOnUse">'+
|
||||||
|
'<path transform="translate(18,18) scale(0.72)" d="'+MASKS[k].d+'"/></clipPath>';
|
||||||
|
}
|
||||||
|
s += '</defs></svg>';
|
||||||
|
const el = document.createElement('div'); el.innerHTML = s; document.body.appendChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- layers ---------------------------------------------------- */
|
||||||
|
const FG = '<path d="'+D.fgPath.d+'" fill="url(#fgGrad)" fill-rule="evenodd" transform="'+D.fgPath.t+'"/>';
|
||||||
|
function MONO(fillVar){
|
||||||
|
return '<path d="'+D.monoPath.d+'" fill="'+fillVar+'" fill-rule="evenodd" transform="'+D.monoPath.t+'"/>';
|
||||||
|
}
|
||||||
|
const RECT = '<rect x="0" y="0" width="108" height="108" ';
|
||||||
|
|
||||||
|
function layers(id){
|
||||||
|
switch(id){
|
||||||
|
case 'flat': return RECT+'fill="'+D.flat+'"/>' + FG;
|
||||||
|
case 'gradL': return RECT+'fill="url(#surfL)"/>' + RECT+'fill="url(#glowL)"/>' + FG;
|
||||||
|
case 'gradM': return RECT+'fill="url(#surfM)"/>' + RECT+'fill="url(#glowM)"/>' + FG;
|
||||||
|
case 'today': /* Launcher3 wraps a legacy raster: corner-extracted plate + icon at ~0.7 */
|
||||||
|
return RECT+'fill="#ffffff"/>' +
|
||||||
|
'<image href="'+D.todayPng+'" x="28.8" y="28.8" width="50.4" height="50.4" '+
|
||||||
|
'preserveAspectRatio="xMidYMid meet"/>';
|
||||||
|
case 'monoLight': return RECT+'fill="'+D.themed.light.ground+'"/>' + MONO(D.themed.light.ink);
|
||||||
|
case 'monoDark': return RECT+'fill="'+D.themed.dark.ground +'"/>' + MONO(D.themed.dark.ink);
|
||||||
|
case 'todayThemed': return layers('today');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE = '<g fill="none" stroke="#ff4d6d" stroke-opacity=".85">'+
|
||||||
|
'<circle cx="54" cy="54" r="33" stroke-width=".6" stroke-dasharray="2 2"/>'+
|
||||||
|
'<rect x="18" y="18" width="72" height="72" stroke-width=".6" stroke-opacity=".45"/></g>';
|
||||||
|
|
||||||
|
function tile(variantId, px, mask){
|
||||||
|
const sh = state.shadow
|
||||||
|
? 'filter:drop-shadow(0 '+(px/48).toFixed(2)+'px '+(px/26).toFixed(2)+'px rgba(0,0,0,.30)) '+
|
||||||
|
'drop-shadow(0 0 '+(px/40).toFixed(2)+'px rgba(0,0,0,.14));' : '';
|
||||||
|
return '<svg class="tile" width="'+px+'" height="'+px+'" viewBox="'+VIEWBOX+'" style="'+sh+'display:block">'+
|
||||||
|
'<g clip-path="url(#clip-'+mask+')">'+layers(variantId)+(state.safe?SAFE:'')+'</g></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- state + render -------------------------------------------- */
|
||||||
|
const state = {mask:'circle', size:48, mode:'standard', shadow:true, safe:false};
|
||||||
|
|
||||||
|
/* the hash makes any view reproducible: #mask=teardrop&size=112&mode=themed */
|
||||||
|
(function readHash(){
|
||||||
|
for (const kv of location.hash.replace(/^#/,'').split('&')){
|
||||||
|
const [k,v] = kv.split('=');
|
||||||
|
if (k === 'mask' && MASKS[v]) state.mask = v;
|
||||||
|
if (k === 'size' && SIZES.some(s => s.px === +v)) state.size = +v;
|
||||||
|
if (k === 'mode' && (v === 'themed' || v === 'standard')) state.mode = v;
|
||||||
|
if (k === 'shadow') state.shadow = v !== '0';
|
||||||
|
if (k === 'safe') state.safe = v === '1';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
function writeHash(){
|
||||||
|
history.replaceState(null,'','#mask='+state.mask+'&size='+state.size+'&mode='+state.mode+
|
||||||
|
'&shadow='+(state.shadow?1:0)+'&safe='+(state.safe?1:0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seg(host, items, key, onpick){
|
||||||
|
host.innerHTML = items.map(i =>
|
||||||
|
'<button data-v="'+i.v+'" aria-pressed="'+(state[key]===i.v)+'">'+i.t+'</button>').join('');
|
||||||
|
host.onclick = e => {
|
||||||
|
const b = e.target.closest('button'); if(!b) return;
|
||||||
|
state[key] = items.find(i => String(i.v) === b.dataset.v).v;
|
||||||
|
[...host.children].forEach(c => c.setAttribute('aria-pressed', c === b));
|
||||||
|
onpick && onpick(); render(); writeHash();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colHTML(v, px){
|
||||||
|
return '<div class="col'+(v.today?' today':'')+'">'+
|
||||||
|
'<div class="tilebox">'+tile(v.id, px, state.mask)+'</div>'+
|
||||||
|
'<div class="tag">'+v.tag+'</div><div class="tagsub">'+v.sub+'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandStandard(cls, name){
|
||||||
|
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
|
||||||
|
h += colHTML(VARIANTS[0], state.size);
|
||||||
|
h += '<div class="divider"></div>';
|
||||||
|
for (let i=1;i<VARIANTS.length;i++) h += colHTML(VARIANTS[i], state.size);
|
||||||
|
return h + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandThemed(cls, name, which){
|
||||||
|
const t = which === 'light' ? D.themed.light : D.themed.dark;
|
||||||
|
const cols = [
|
||||||
|
{id:'todayThemed', tag:'TODAY', sub:'no monochrome layer — the launcher leaves it full-colour among themed neighbours', today:true},
|
||||||
|
{id: which==='light'?'monoLight':'monoDark', tag:'A / B / C', sub:'kit monochrome, tinted '+t.ink+' on system ground '+t.ground}
|
||||||
|
];
|
||||||
|
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
|
||||||
|
h += colHTML(cols[0], state.size) + '<div class="divider"></div>' + colHTML(cols[1], state.size);
|
||||||
|
return h + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const themed = state.mode === 'themed';
|
||||||
|
document.getElementById('stdBanner').classList.toggle('hidden', themed);
|
||||||
|
document.getElementById('thmBanner').classList.toggle('hidden', !themed);
|
||||||
|
|
||||||
|
if (!themed){
|
||||||
|
stage.innerHTML = bandStandard('dark','Dark wallpaper') + bandStandard('light','Light wallpaper');
|
||||||
|
} else {
|
||||||
|
stage.innerHTML = bandThemed('light','Light theme · wallpaper-derived ground','light') +
|
||||||
|
bandThemed('dark','Dark theme · wallpaper-derived ground','dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = themed
|
||||||
|
? [{id:'todayThemed',tag:'TODAY'},{id:'monoLight',tag:'THEMED · LIGHT'},{id:'monoDark',tag:'THEMED · DARK'}]
|
||||||
|
: VARIANTS;
|
||||||
|
let lh = '<div class="lhead">' + list.map(v=>'<span>'+v.tag+'</span>').join('') + '</div>';
|
||||||
|
for (const s of SIZES){
|
||||||
|
lh += '<div class="lrow">' + list.map(v =>
|
||||||
|
'<div class="lcell" style="width:118px"><div class="tilebox">'+tile(v.id, s.px, state.mask)+
|
||||||
|
'</div><div class="sz">'+s.cap+'</div></div>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
document.getElementById('ladder').innerHTML = lh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- measurement: how much background does the falcon leave? ---- */
|
||||||
|
function coverage(maskKey){
|
||||||
|
const N = 512, c = document.createElement('canvas');
|
||||||
|
c.width = c.height = N; const x = c.getContext('2d');
|
||||||
|
const k = N/72; // 72 visible units -> N px
|
||||||
|
// mask
|
||||||
|
x.save(); x.scale(k,k); x.translate(-18,-18);
|
||||||
|
x.translate(18,18); x.scale(0.72,0.72);
|
||||||
|
x.fill(new Path2D(MASKS[maskKey].d)); x.restore();
|
||||||
|
const maskData = x.getImageData(0,0,N,N).data;
|
||||||
|
let maskPx = 0; for (let i=3;i<maskData.length;i+=4) if (maskData[i]>127) maskPx++;
|
||||||
|
// falcon inside the mask
|
||||||
|
x.globalCompositeOperation = 'source-in';
|
||||||
|
x.save(); x.scale(k,k); x.translate(-18,-18);
|
||||||
|
const t = D.fgPath.tParsed; // [tx,ty,s,tx2,ty2]
|
||||||
|
x.translate(t[0],t[1]); x.scale(t[2],t[2]); x.translate(t[3],t[4]);
|
||||||
|
x.fill(new Path2D(D.fgPath.d),'evenodd'); x.restore();
|
||||||
|
const fgData = x.getImageData(0,0,N,N).data;
|
||||||
|
let fgPx = 0; for (let i=3;i<fgData.length;i+=4) if (fgData[i]>127) fgPx++;
|
||||||
|
// the falcon on its own, to see how much the mask shaves off
|
||||||
|
const c2 = document.createElement('canvas'); c2.width = c2.height = N;
|
||||||
|
const y = c2.getContext('2d');
|
||||||
|
y.save(); y.scale(k,k); y.translate(-18,-18);
|
||||||
|
y.translate(t[0],t[1]); y.scale(t[2],t[2]); y.translate(t[3],t[4]);
|
||||||
|
y.fill(new Path2D(D.fgPath.d),'evenodd'); y.restore();
|
||||||
|
const allData = y.getImageData(0,0,N,N).data;
|
||||||
|
let allPx = 0; for (let i=3;i<allData.length;i+=4) if (allData[i]>127) allPx++;
|
||||||
|
return {mask:maskPx, fg:fgPx, pct: 100*fgPx/maskPx, kept: 100*fgPx/allPx,
|
||||||
|
maskOfSquare: 100*maskPx/(N*N)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- notes ----------------------------------------------------- */
|
||||||
|
function notes(){
|
||||||
|
const cov = coverage(state.mask);
|
||||||
|
const rows = D.samples.map(r =>
|
||||||
|
'<tr><td>'+r.where+'</td>'+
|
||||||
|
r.v.map(v=>'<td><span class="sw" style="background:'+v+'"></span>'+v+'</td>').join('')+'</tr>').join('');
|
||||||
|
document.getElementById('notes').innerHTML =
|
||||||
|
'<p><b>Geometry.</b> Layers are 108 dp; the mask shows the central 72 dp, so every layer is drawn at '+
|
||||||
|
'1.5× the icon size and centre-cropped — that is what the <code>viewBox="18 18 72 72"</code> on each '+
|
||||||
|
'tile reproduces. The foreground and monochrome paths, including the kit\'s v1.1 optical offset '+
|
||||||
|
'<code>translate(-40.740741 20.370370)</code>, are copied verbatim from the kit SVGs and are '+
|
||||||
|
'<b>identical in A, B and C</b>. Only the background layer changes.</p>'+
|
||||||
|
'<p><b>Composited background colour</b> (surface + glow, under the falcon, sampled in layer units):</p>'+
|
||||||
|
'<table class="m"><tr><th>point in the visible 72 window</th><th>A flat</th><th>B layer-fit</th>'+
|
||||||
|
'<th>C mask-fit</th></tr>'+rows+'</table>'+
|
||||||
|
'<p style="margin-top:10px"><b>How much background is even visible?</b> Measured live on this page: '+
|
||||||
|
'under the '+MASKS[state.mask].label.toLowerCase()+' mask the falcon covers only <b>'+
|
||||||
|
cov.pct.toFixed(1)+'%</b> of the masked area, so the background owns the other '+
|
||||||
|
(100-cov.pct).toFixed(1)+'%. That mask keeps '+cov.maskOfSquare.toFixed(1)+'% of the 72 dp square and '+
|
||||||
|
cov.kept.toFixed(1)+'% of the falcon (the falcon\'s painted box is 46.6 × 65.8 of the 72 window, '+
|
||||||
|
'so even a circle shaves only the extreme tips).</p>'+
|
||||||
|
'<p><b>B vs C.</b> B maps the 1024 artwork onto the whole 108 layer, so the visible 72 window sees only '+
|
||||||
|
'the middle two-thirds of the gradient. C maps it onto the visible 72 window, so the masked result matches '+
|
||||||
|
'the desktop tile corner for corner — but C\'s outer 18 dp of bleed is flat padding, which is what a '+
|
||||||
|
'launcher reveals when it parallaxes the layers on a swipe. Under a circle the two are almost the same '+
|
||||||
|
'(#0F3643 vs #0E3442 at the NW rim); they separate only where a mask keeps the corners.</p>'+
|
||||||
|
'<p><b>Honesty notes.</b> 1 dp = 1 CSS px here; on a Pixel-class screen a 48 dp icon is ~132 physical '+
|
||||||
|
'pixels but only ~7.6 mm across. Mask outlines are the standard circle / squircle / rounded-square / '+
|
||||||
|
'teardrop families — OEM paths vary slightly. The launcher shadow is a Launcher3-style ambient+key pair, '+
|
||||||
|
'approximated in CSS, applied identically to every tile. Themed-icon ground and ink are representative '+
|
||||||
|
'Material You tones; the real values are derived from the user\'s wallpaper. The TODAY column is the '+
|
||||||
|
'actual shipping <code>mipmap-xxxhdpi/ic_launcher.png</code> with Launcher3\'s legacy plating '+
|
||||||
|
'(corner-extracted plate, icon at 0.7) applied.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- boot ------------------------------------------------------ */
|
||||||
|
buildDefs();
|
||||||
|
seg(document.getElementById('segMask'),
|
||||||
|
Object.keys(MASKS).map(k=>({v:k,t:MASKS[k].label})), 'mask', notes);
|
||||||
|
seg(document.getElementById('segSize'),
|
||||||
|
SIZES.map(s=>({v:s.px,t:s.px+' dp'})), 'size');
|
||||||
|
seg(document.getElementById('segMode'),
|
||||||
|
[{v:'standard',t:'Standard'},{v:'themed',t:'Themed (13+)'}], 'mode');
|
||||||
|
document.getElementById('cbShadow').checked = state.shadow;
|
||||||
|
document.getElementById('cbSafe').checked = state.safe;
|
||||||
|
document.getElementById('cbShadow').onchange = e => { state.shadow = e.target.checked; render(); writeHash(); };
|
||||||
|
document.getElementById('cbSafe').onchange = e => { state.safe = e.target.checked; render(); writeHash(); };
|
||||||
|
render(); notes(); writeHash();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 `<img>` 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
`<layer-list>` 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 `<background>` 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script>
|
||||||
|
/* A stranger, in full — the desktop's person drawer as a screen: the
|
||||||
|
card their own client serves (what they wrote about themselves, their
|
||||||
|
picture, what their uploads are doing), the likes and hates they
|
||||||
|
declared to the server, and the comparison this phone makes of their
|
||||||
|
share against the library. The comparison is the reason to open a
|
||||||
|
stranger at all: every record they have by an artist in your library
|
||||||
|
that you don't. Nothing is uploaded to work it out — their
|
||||||
|
folder names against your albums, here (`overlap.rs`). */
|
||||||
|
import Icon from "../../components/Icon.svelte";
|
||||||
|
import Identicon from "../../components/Identicon.svelte";
|
||||||
|
import * as api from "../../lib/api.js";
|
||||||
|
import { slsk, openThread } from "../slsk.svelte.js";
|
||||||
|
import { push, openHalf, openViewer } from "../nav.svelte.js";
|
||||||
|
import { player } from "../player.svelte.js";
|
||||||
|
import { note } from "../notice.svelte.js";
|
||||||
|
import { ago } from "../util.js";
|
||||||
|
|
||||||
|
let { username, screenEl = null } = $props();
|
||||||
|
|
||||||
|
/* the comparison keeps 120 named pairs; a screenful shows, the rest on
|
||||||
|
demand — the cap Browse gives a big share */
|
||||||
|
const GAPS_CAP = 40;
|
||||||
|
let info = $state(null); // slsk_user_info, once their client answers
|
||||||
|
let infoErr = $state("");
|
||||||
|
let interests = $state(null); // slsk_user_interests, from the server
|
||||||
|
let intErr = $state(false);
|
||||||
|
let overlap = $state(null); // the comparison, if this device made one
|
||||||
|
let busy = $state(false); // a comparison running: it browses them first
|
||||||
|
let cap = $state(GAPS_CAP);
|
||||||
|
|
||||||
|
const online = $derived(slsk.connection === "online");
|
||||||
|
const picture = $derived(info?.picture_png_base64 ? "data:image/png;base64," + info.picture_png_base64 : null);
|
||||||
|
const likes = $derived(interests?.likes ?? []);
|
||||||
|
const hates = $derived(interests?.hates ?? []);
|
||||||
|
|
||||||
|
/* both asked at once. The card comes from THEIR client, direct and peer
|
||||||
|
to peer, so it can simply go unanswered; the interests come from the
|
||||||
|
server, which answers or doesn't. */
|
||||||
|
$effect(() => {
|
||||||
|
const u = username;
|
||||||
|
if (!u || !online) return;
|
||||||
|
let live = true;
|
||||||
|
info = null;
|
||||||
|
infoErr = "";
|
||||||
|
interests = null;
|
||||||
|
intErr = false;
|
||||||
|
api.slskUserInfo(u).then((v) => { if (live) info = v; }, (e) => { if (live) infoErr = String(e).replace(/^Error:\s*/, ""); });
|
||||||
|
api.slskUserInterests(u).then((v) => { if (live) interests = v; }, () => { if (live) intErr = true; });
|
||||||
|
return () => { live = false; };
|
||||||
|
});
|
||||||
|
|
||||||
|
/* the ledger is per username and kept on the device: opening a stranger
|
||||||
|
reads what was compared before, never compares — comparing browses
|
||||||
|
their whole share, which is a minute of someone else's bandwidth */
|
||||||
|
async function readOverlap() {
|
||||||
|
try {
|
||||||
|
const all = (await api.overlapList()) || [];
|
||||||
|
const k = String(username || "").trim().toLowerCase();
|
||||||
|
overlap = all.find((o) => String(o.username).toLowerCase() === k) ?? null;
|
||||||
|
} catch {
|
||||||
|
overlap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => { void username; cap = GAPS_CAP; void readOverlap(); });
|
||||||
|
|
||||||
|
async function compare() {
|
||||||
|
if (busy || !online) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.overlapCompute(username);
|
||||||
|
await readOverlap();
|
||||||
|
cap = GAPS_CAP;
|
||||||
|
} catch (e) { player.error = String(e); }
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
async function forget() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.overlapForget(username);
|
||||||
|
overlap = null;
|
||||||
|
note("Comparison forgotten");
|
||||||
|
} catch (e) { player.error = String(e); }
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
/* a record they have and the library doesn't: the catalog search that
|
||||||
|
finds it, where Save can fetch it from whoever has it best */
|
||||||
|
function findRecord(g) {
|
||||||
|
push("search", { q: [g.artist, g.album].filter(Boolean).join(" ") }, screenEl);
|
||||||
|
}
|
||||||
|
const uploadLine = $derived.by(() => {
|
||||||
|
if (!online) return "not signed in to Soulseek";
|
||||||
|
if (infoErr) return "their client did not answer";
|
||||||
|
if (!info) return "asking their client…";
|
||||||
|
return (info.has_free_slot ? "a slot is free" : "no free slot")
|
||||||
|
+ " · queue " + (info.queue_size ?? 0)
|
||||||
|
+ " · " + (info.total_uploads ?? 0).toLocaleString() + " served";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="brw-head pr-head">
|
||||||
|
{#if picture}
|
||||||
|
<button class="pr-pic" onclick={() => openViewer(picture)} aria-label="Their picture"><img src={picture} alt="" /></button>
|
||||||
|
{:else}
|
||||||
|
<Identicon seed={username} size={44} />
|
||||||
|
{/if}
|
||||||
|
<div class="b">
|
||||||
|
<div class="t">{username}</div>
|
||||||
|
<div class="m">{uploadLine}</div>
|
||||||
|
</div>
|
||||||
|
<button class="iconbtn muted" onclick={() => openHalf("peer", { username })} aria-label="More"><Icon name="more" size={22} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="brw-acts">
|
||||||
|
<button class="btn btn-fill" onclick={() => { openThread(username); push("thread", { username }, screenEl); }}><Icon name="message" size={14} />Message</button>
|
||||||
|
<button class="btn btn-ghost" onclick={() => push("browse", { username }, screenEl)}><Icon name="folder-open" size={14} />Browse their files</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !online}
|
||||||
|
<div class="sec-note">Sign in to Soulseek to ask their client for their card.</div>
|
||||||
|
{:else if infoErr}
|
||||||
|
<div class="sec-note">Their client did not answer ({infoErr}) — offline, firewalled, or not sharing a card.</div>
|
||||||
|
{:else if !info}
|
||||||
|
<div class="sec-note">Asking their client for the card — direct, peer to peer.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pr-about">{info.description?.trim() || "They wrote nothing about themselves."}</div>
|
||||||
|
{#if info.picture_note}<div class="sec-note">{info.picture_note}</div>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sec"><h2>Interests <em>· what they declared on Soulseek</em></h2></div>
|
||||||
|
{#if likes.length || hates.length}
|
||||||
|
{#if likes.length}
|
||||||
|
<div class="sec-note">Likes</div>
|
||||||
|
<div class="chips pr-tags">{#each likes as l, i (i)}<span class="chip quiet">{l}</span>{/each}</div>
|
||||||
|
{/if}
|
||||||
|
{#if hates.length}
|
||||||
|
<div class="sec-note" style="padding-top: 10px">Hates</div>
|
||||||
|
<div class="chips pr-tags">{#each hates as h, i (i)}<span class="chip quiet">{h}</span>{/each}</div>
|
||||||
|
{/if}
|
||||||
|
{:else if intErr}
|
||||||
|
<div class="sec-note">The server did not answer.</div>
|
||||||
|
{:else if interests}
|
||||||
|
<div class="sec-note">None declared.</div>
|
||||||
|
{:else if online}
|
||||||
|
<div class="sec-note">Asking the server…</div>
|
||||||
|
{:else}
|
||||||
|
<div class="sec-note">Sign in to Soulseek to see what they declared.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sec"><h2>Library overlap <em>· compared on this phone</em></h2></div>
|
||||||
|
{#if overlap}
|
||||||
|
{@const o = overlap}
|
||||||
|
<div class="sec-note pr-sum">
|
||||||
|
{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)}`}
|
||||||
|
</div>
|
||||||
|
{#if o.gaps.length}
|
||||||
|
<div class="sec-note" style="padding-top: 6px">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.` : ""}</div>
|
||||||
|
{#each o.gaps.slice(0, cap) as g, i (i)}
|
||||||
|
<button class="row pr-gap" onclick={() => findRecord(g)}>
|
||||||
|
<span class="lead"><Icon name="disc" size={20} /></span>
|
||||||
|
<span class="b"><span class="t">{g.album}</span><span class="m">{g.artist}</span></span>
|
||||||
|
<span class="r"><Icon name="search" size={17} /></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if o.gaps.length > cap}
|
||||||
|
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => (cap += 200)}>+ {o.gaps.length - cap} more records</button></div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="sec-note" style="padding-top: 6px">Nothing they have that you lack.</div>
|
||||||
|
{/if}
|
||||||
|
<div class="brw-acts" style="padding-top: 8px">
|
||||||
|
<button class="btn btn-ghost" onclick={compare} disabled={!online || busy}><Icon name="refresh" size={14} />{busy ? "Browsing…" : "Compare again"}</button>
|
||||||
|
<button class="btn btn-ghost" onclick={forget} disabled={busy}><Icon name="trash" size={14} />Forget this comparison</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="stub"><b>Not compared yet.</b>Their share folders against your albums, on this phone — nothing is uploaded to do it.</div>
|
||||||
|
<div style="padding: 0 var(--pad) 8px">
|
||||||
|
<button class="btn btn-ghost wide" onclick={compare} disabled={!online || busy}><Icon name="library" size={14} />{busy ? "Browsing their shares…" : "Compare with my library"}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="endpad"></div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<button class="row bud" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
||||||
|
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
|
||||||
|
<span class="b">
|
||||||
|
<span class="t">{display(b.u)}{#if b.m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if b.m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span>
|
||||||
|
<span class="m">{[realName(b.u), status(b.st), b.m.group, b.st.file_count ? `${b.st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}</span>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* the two local marks beside a name: a star for a favourite, a shield for
|
||||||
|
someone trusted. Both are this phone's own; neither is published. */
|
||||||
|
.nmark { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--text-muted); }
|
||||||
|
.nmark.fav { color: #e8a33d; }
|
||||||
|
.nmark.tr { color: #3ddc84; }
|
||||||
|
/* a room line whose author wears a petname keeps their own name beside it */
|
||||||
|
.msg .who .real { margin-left: 5px; font-weight: 600; opacity: 0.65; }
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Every name in Community can wear one you gave it. The peer's sheet ends in
|
||||||
|
**Name and notes** (`src/mobile/contacts.svelte.js`, `contacts_meta_list` /
|
||||||
|
`contact_meta_set`): a petname, a group, a private note and the two marks,
|
||||||
|
Favorite and Trusted. All of it sits in the app's own config on this phone
|
||||||
|
and is published nowhere — Soulseek never learns it, and neither does the
|
||||||
|
person it describes. 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,
|
||||||
|
a star and a shield mark the two, and clearing a person's details drops the
|
||||||
|
entry rather than storing an empty one — the backend's own rule.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
|
||||||
|
/* the local contact metadata: the editor, the petname reaching the
|
||||||
|
backend, the mark sticking, the buddy list rearranged — and the
|
||||||
|
cleared entry dropped rather than stored empty */
|
||||||
|
"contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere",
|
||||||
|
"contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "),
|
||||||
|
"contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText),
|
||||||
|
"contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite",
|
||||||
|
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
|
||||||
|
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
|
||||||
|
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||||
|
budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||||
|
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||||||
|
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||||||
|
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* the contact editor's three fields go through the prompt sheet. The
|
||||||
|
username travels as a VALUE, the way the playlist prompts do: the
|
||||||
|
sheet switches kind under the prompt, so the contact block's
|
||||||
|
{@const} is a destroyed derived by the time Save lands. */
|
||||||
|
function metaPrompt(u, field, title, placeholder, value, multiline = false) {
|
||||||
|
openHalf("prompt", {
|
||||||
|
title, value, placeholder, ok: "Save", multiline, allowEmpty: true,
|
||||||
|
onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{:else if kind === "contact" && shown?.arg?.username}
|
||||||
|
{@const u = shown.arg.username}
|
||||||
|
{@const m = metaOf(u)}
|
||||||
|
<div class="sheet-head">
|
||||||
|
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||||
|
<span class="b"><span class="t">{display(u)}</span><span class="m">{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-rows">
|
||||||
|
{@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))}
|
||||||
|
{@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))}
|
||||||
|
{@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))}
|
||||||
|
<div class="sheet-hair"></div>
|
||||||
|
<button class="row" onclick={() => setMeta(u, { favorite: !m.favorite })}>
|
||||||
|
<span class="lead"><Icon name="star" size={20} /></span>
|
||||||
|
<span class="b"><span class="t">Favorite</span><span class="m">kept at the top of the buddy list</span></span>
|
||||||
|
<span class="sw" class:on={m.favorite}><span></span></span>
|
||||||
|
</button>
|
||||||
|
<button class="row" onclick={() => setMeta(u, { trusted: !m.trusted })}>
|
||||||
|
<span class="lead"><Icon name="shield" size={20} /></span>
|
||||||
|
<span class="b"><span class="t">Trusted</span><span class="m">someone you take at their word</span></span>
|
||||||
|
<span class="sw" class:on={m.trusted}><span></span></span>
|
||||||
|
</button>
|
||||||
|
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
|
||||||
|
<div class="sheet-hair"></div>
|
||||||
|
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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 },
|
||||||
@@ -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 },
|
||||||
@@ -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 },
|
||||||
@@ -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 });
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{:else if tab === "taste"}
|
||||||
|
<div class="sec" style="padding-top:8px"><h2>What you like <em>· {taste.likes.length}</em></h2></div>
|
||||||
|
<div class="sec-note">Declared on Soulseek: this is what the server matches other people against.</div>
|
||||||
|
<div class="chips tastechips likes" style="padding-top:8px">
|
||||||
|
{#each taste.likes as thing (thing)}
|
||||||
|
<button class="chip on tchip" disabled={taste.busy} onclick={() => setInterest(thing, true, false)}>{thing}<Icon name="x" size={13} /></button>
|
||||||
|
{/each}
|
||||||
|
<button class="chip quiet tadd" disabled={taste.busy} onclick={() => declare(true)}><Icon name="plus" size={14} />Add a like</button>
|
||||||
|
</div>
|
||||||
|
<div class="sec" style="padding-top:18px"><h2>What you hate <em>· {taste.hates.length}</em></h2></div>
|
||||||
|
<div class="chips tastechips hates" style="padding-top:8px">
|
||||||
|
{#each taste.hates as thing (thing)}
|
||||||
|
<button class="chip tchip" disabled={taste.busy} onclick={() => setInterest(thing, false, false)}>{thing}<Icon name="x" size={13} /></button>
|
||||||
|
{/each}
|
||||||
|
<button class="chip quiet tadd" disabled={taste.busy} onclick={() => declare(false)}><Icon name="ban" size={14} />Add a hate</button>
|
||||||
|
</div>
|
||||||
|
<div class="sec" style="padding-top:18px"><h2>People like you{#if taste.similar?.users?.length} <em>· {taste.similar.users.length}</em>{/if}</h2></div>
|
||||||
|
{#if taste.asking && !taste.similar}
|
||||||
|
<div class="sec-note">Asking the server…</div>
|
||||||
|
{:else if taste.similar?.users?.length}
|
||||||
|
{#each taste.similar.users.slice(0, 40) as su (su.username)}
|
||||||
|
<button class="row similar" onclick={() => openHalf("peer", { username: su.username })}>
|
||||||
|
<span class="art round idc"><Identicon seed={su.username} size={46} /></span>
|
||||||
|
<span class="b"><span class="t">{su.username}</span><span class="m">shares {su.rating} of your {su.rating === 1 ? "like" : "likes"}</span></span>
|
||||||
|
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else if taste.error}
|
||||||
|
<div class="stub"><b>The server did not answer.</b>{taste.error}</div>
|
||||||
|
{:else if !taste.likes.length}
|
||||||
|
<div class="stub"><b>Nothing to match you on yet.</b>The server puts people together on the likes you declare — declare one above and it has something to go on.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="stub"><b>Nobody yet.</b>The server found no one close to what you declared.</div>
|
||||||
|
{/if}
|
||||||
|
<div style="padding: 6px var(--pad) 0"><button class="btn btn-ghost wide" disabled={taste.asking} onclick={() => askSimilar(true)}>Ask the server again</button></div>
|
||||||
@@ -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.
|
||||||
@@ -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 },
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
{#snippet swrow(icon, t, m, on, fn)}
|
||||||
|
<button class="row" onclick={fn}>
|
||||||
|
<span class="lead"><Icon name={icon} size={20} /></span>
|
||||||
|
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
|
||||||
|
<span class="sw" class:on><span></span></span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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; }
|
||||||
@@ -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,
|
||||||
@@ -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 },
|
||||||
@@ -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,
|
||||||
@@ -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 },
|
||||||
@@ -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 ''; } })(),
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script>
|
||||||
|
/* The tap-to-sync room — the desktop's correction editor
|
||||||
|
(LyricsSyncEditor.svelte) in the phone's idiom. The song plays and you
|
||||||
|
tap once as each line begins; the taps become the timing. One line
|
||||||
|
large, the line behind and the line ahead faint around it, and the
|
||||||
|
whole middle of the screen is the tap. Saving writes the track's own
|
||||||
|
document; lines never tapped are left out of it, and the button says
|
||||||
|
how many are kept. */
|
||||||
|
import Icon from "../components/Icon.svelte";
|
||||||
|
import { nav } from "./nav.svelte.js";
|
||||||
|
import { player, toggle, seekSecs } from "./player.svelte.js";
|
||||||
|
import { tapSync, saveTapSync, closeTapSync } from "./lyrics.svelte.js";
|
||||||
|
import { mmss } from "./util.js";
|
||||||
|
|
||||||
|
/* the ✕ asks twice once there is work to lose; the back gesture keeps
|
||||||
|
the taps instead (the lyrics sheet offers them back). The room is
|
||||||
|
mounted for the life of the shell, so the question is put down when
|
||||||
|
it closes — otherwise the next visit opens already asking. */
|
||||||
|
let confirmDiscard = $state(false);
|
||||||
|
$effect(() => { if (!nav.timing) confirmDiscard = false; });
|
||||||
|
|
||||||
|
/* position anchors land a couple of times a second, so a tap between
|
||||||
|
them reads the last anchor plus the wall clock — the stamp carries
|
||||||
|
the tap's own precision rather than the tick's. Plain lets, never
|
||||||
|
read back inside the effect that writes them. */
|
||||||
|
let lastPos = 0;
|
||||||
|
let lastAt = 0;
|
||||||
|
$effect(() => {
|
||||||
|
void player.status;
|
||||||
|
lastPos = player.position ?? 0;
|
||||||
|
lastAt = performance.now();
|
||||||
|
});
|
||||||
|
function nowMs() {
|
||||||
|
const base = player.status === "playing"
|
||||||
|
? lastPos + (performance.now() - lastAt) / 1000
|
||||||
|
: (player.position ?? 0);
|
||||||
|
return Math.max(0, Math.round(base * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = $derived(tapSync.rows);
|
||||||
|
const done = $derived(rows.filter((r) => r.time_ms != null).length);
|
||||||
|
const at = $derived(Math.min(tapSync.cursor, rows.length));
|
||||||
|
const playing = $derived(player.status === "playing" || player.status === "buffering");
|
||||||
|
/* playback moved on: a stamp would land on another song's clock */
|
||||||
|
const wrong = $derived(!!tapSync.identity && !!player.track && player.track.title !== tapSync.identity.title);
|
||||||
|
|
||||||
|
function stamp() {
|
||||||
|
if (wrong || tapSync.saving || at >= rows.length) return;
|
||||||
|
tapSync.rows[at].time_ms = nowMs();
|
||||||
|
tapSync.cursor = at + 1;
|
||||||
|
confirmDiscard = false;
|
||||||
|
}
|
||||||
|
function undo() {
|
||||||
|
const last = tapSync.cursor - 1;
|
||||||
|
if (last < 0) return;
|
||||||
|
tapSync.rows[last].time_ms = null;
|
||||||
|
tapSync.cursor = last;
|
||||||
|
}
|
||||||
|
function back5() { seekSecs(Math.max(0, (player.position ?? 0) - 5)); }
|
||||||
|
function close() {
|
||||||
|
if (done > 0 && !confirmDiscard) { confirmDiscard = true; return; }
|
||||||
|
closeTapSync();
|
||||||
|
}
|
||||||
|
/* a stamp reads to a tenth — the precision a thumb actually has */
|
||||||
|
function mark(ms) {
|
||||||
|
if (ms == null) return "";
|
||||||
|
return `${mmss(Math.floor(ms / 1000))}.${Math.floor((ms % 1000) / 100)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if nav.timing}
|
||||||
|
<div class="syncroom">
|
||||||
|
<div class="sy-head">
|
||||||
|
<button class="iconbtn sy-close" onclick={close} aria-label={confirmDiscard ? "Discard the timing" : "Close"}>
|
||||||
|
{#if confirmDiscard}<span class="sy-discard">Discard?</span>{:else}<Icon name="x" size={22} />{/if}
|
||||||
|
</button>
|
||||||
|
<span class="sy-who"><b>Tap in time</b><i>{tapSync.identity?.title ?? ""}</i></span>
|
||||||
|
<span class="sy-count">{done} / {rows.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if wrong}
|
||||||
|
<div class="sy-warn">Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.</div>
|
||||||
|
{:else if tapSync.error}
|
||||||
|
<div class="sy-warn">{tapSync.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="sy-pad" onclick={stamp} disabled={wrong}>
|
||||||
|
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
|
||||||
|
{#if at < rows.length}
|
||||||
|
<span class="sy-now">{rows[at].text}</span>
|
||||||
|
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
|
||||||
|
<span class="sy-hint">Tap anywhere as the line begins</span>
|
||||||
|
{:else}
|
||||||
|
<span class="sy-now">That’s every line — save the timing.</span>
|
||||||
|
<span class="sy-next"></span>
|
||||||
|
<span class="sy-hint">Undo puts the last one back</span>
|
||||||
|
{/if}
|
||||||
|
<span class="sy-mark">{at > 0 ? mark(rows[at - 1].time_ms) : mmss(player.position)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sy-foot">
|
||||||
|
<button class="iconbtn sy-undo" onclick={undo} disabled={tapSync.cursor === 0} aria-label="Undo the last tap"><Icon name="history" size={22} /></button>
|
||||||
|
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
|
||||||
|
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
|
||||||
|
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
|
||||||
|
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : "Save timing")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -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 },
|
||||||
@@ -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,
|
||||||
@@ -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 },
|
||||||
@@ -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. */
|
||||||
@@ -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 },
|
||||||
@@ -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 },
|
||||||
@@ -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 },
|
||||||
@@ -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) {
|
||||||
@@ -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"],
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script>
|
||||||
|
/* Diagnostics — the desktop's debug overlay (Ctrl+Shift+D) as a screen,
|
||||||
|
on the dev flavour only: what this process costs the phone, what the
|
||||||
|
network is moving, the track playing and its loudness correction, the
|
||||||
|
tracks the pre-buffer is fetching behind it, and how big the play
|
||||||
|
history has grown. Polled once a second while the screen is open and
|
||||||
|
never otherwise, the way the overlay does it. The GPU counters are
|
||||||
|
Windows PDH and the private commit is a Win32 call, so neither row is
|
||||||
|
here — on the phone they would read nothing at all. */
|
||||||
|
import Icon from "../../components/Icon.svelte";
|
||||||
|
import * as api from "../../lib/api.js";
|
||||||
|
import { fmtBytes, basename } from "../util.js";
|
||||||
|
|
||||||
|
let stats = $state(null);
|
||||||
|
let plays = $state(null);
|
||||||
|
let failed = $state("");
|
||||||
|
|
||||||
|
/* nothing reactive is READ in here: the effect runs once when the screen
|
||||||
|
mounts and its cleanup when the screen leaves. An effect that reads
|
||||||
|
what it writes takes its whole subtree down with it. */
|
||||||
|
$effect(() => {
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = await api.debugStats();
|
||||||
|
if (live) { stats = s; failed = ""; }
|
||||||
|
} catch (e) {
|
||||||
|
if (live) failed = String(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
api.playRowCount().then((n) => { if (live) plays = n; }, () => {});
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
return () => { live = false; clearInterval(id); };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pct = (v) => (v == null ? "—" : v.toFixed(1) + "%");
|
||||||
|
const size = (n) => (n ? fmtBytes(n) : "—");
|
||||||
|
const rate = (n) => (n ? fmtBytes(n) + "/s" : "—");
|
||||||
|
const db = (v) => (v == null ? "—" : (v > 0 ? "+" : "") + v.toFixed(1) + " dB");
|
||||||
|
const upfor = (s) => {
|
||||||
|
if (s == null) return "—";
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
return h ? h + " hr " + m + " min" : m ? m + " min" : s + " s";
|
||||||
|
};
|
||||||
|
const share = (p) => (p.total ? Math.max(0.02, Math.min(1, p.received / p.total)) : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="phead"><div class="row"><h1>Diagnostics</h1></div></div>
|
||||||
|
<div class="sec-note">The desktop's debug overlay, on the dev flavour. Polled once a second while this screen is open.</div>
|
||||||
|
|
||||||
|
{#if failed}
|
||||||
|
<div class="stub">
|
||||||
|
<b>No diagnostics.</b>
|
||||||
|
{failed}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sheet-sec">This process</div>
|
||||||
|
<div class="group diag">
|
||||||
|
<div class="kv"><span class="k">App CPU</span><span class="v">{pct(stats?.cpuProcess)}</span></div>
|
||||||
|
<div class="kv"><span class="k">System CPU</span><span class="v">{pct(stats?.cpuSystem)}</span></div>
|
||||||
|
<div class="kv"><span class="k">Memory</span><span class="v">{size(stats?.memWorkingSet)}</span></div>
|
||||||
|
<div class="kv"><span class="k">System memory</span><span class="v dim">{size(stats?.memSystemUsed)} of {size(stats?.memSystemTotal)}</span></div>
|
||||||
|
<div class="kv"><span class="k">Processes</span><span class="v">{stats?.processCount ?? "—"}</span></div>
|
||||||
|
<div class="kv"><span class="k">Up</span><span class="v">{upfor(stats?.uptimeSecs)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-note">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.</div>
|
||||||
|
|
||||||
|
<div class="sheet-sec">Network</div>
|
||||||
|
<div class="group diag">
|
||||||
|
<div class="kv"><span class="k">Down</span><span class="v">{rate(stats?.netRxBps)}</span></div>
|
||||||
|
<div class="kv"><span class="k">Up</span><span class="v">{rate(stats?.netTxBps)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-note">Every interface on the phone, not this app alone.</div>
|
||||||
|
|
||||||
|
<div class="sheet-sec">The track playing</div>
|
||||||
|
<div class="group diag">
|
||||||
|
<div class="kv"><span class="k">Stream</span><span class="v">{!stats ? "—" : stats.dlTotal ? size(stats.dlReceived) + " of " + size(stats.dlTotal) + (stats.dlDone ? " · done" : "") : "nothing streaming"}</span></div>
|
||||||
|
<div class="kv"><span class="k">Normalization</span><span class="v">{!stats ? "—" : stats.normalizationEnabled ? "on · " + (stats.targetLufs ?? -14) + " LUFS" : "off"}</span></div>
|
||||||
|
<div class="kv"><span class="k">Measured</span><span class="v dim">{stats?.loudness?.lufs != null ? stats.loudness.lufs.toFixed(1) + " LUFS" + (stats.loudness.analyzing ? " · measuring" : "") : "—"}</span></div>
|
||||||
|
<div class="kv"><span class="k">Gain</span><span class="v">{db(stats?.normNowDb)}{stats && stats.normNowDb != null && stats.normNowDb !== stats.normTargetDb ? " → " + db(stats.normTargetDb) : ""}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-sec">On deck</div>
|
||||||
|
{#if stats?.preloadDl?.length}
|
||||||
|
<div class="group diag-deck">
|
||||||
|
{#each stats.preloadDl as p (p.username + p.path)}
|
||||||
|
<div class="row">
|
||||||
|
<span class="lead"><Icon name="download" size={20} /></span>
|
||||||
|
<span class="b">
|
||||||
|
<span class="t">{basename(p.path)}</span>
|
||||||
|
<span class="m">{p.username} · {p.state}{p.place != null ? " · queue " + p.place : ""}{p.speedBps ? " · " + fmtBytes(p.speedBps) + "/s" : ""}</span>
|
||||||
|
{#if p.total}<span class="lbar"><i style="transform: scaleX({share(p).toFixed(3)})"></i><em>{fmtBytes(p.received)}</em></span>{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="sec-note">Nothing pre-buffering. The player readies the next three tracks while one plays.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sheet-sec">Library</div>
|
||||||
|
<div class="group diag">
|
||||||
|
<div class="kv"><span class="k">Play rows</span><span class="v">{plays != null ? plays.toLocaleString() : "—"}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpad"></div>
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
@@ -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 },
|
||||||
@@ -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 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
@@ -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,
|
||||||
@@ -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 },
|
||||||
@@ -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,
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
@@ -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; })(),
|
||||||
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user