Commit Graph
44 Commits
Author SHA1 Message Date
KeelrLeek bb7f1f1b2d android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing
Reaching a row whose bytes are not on the phone read as the player going
to the next track and freezing — the title changed, the position sat at
0:00, nothing said why — and after a skip the old song kept sounding under
the new title until the fetch delivered, because start_at notified the
card and spawned the resolver without stopping the engine (only the
natural boundary was silent).

The engine stops before it looks now: silence, the title, a spinner. The
card is built buffering from its first frame (headlessTrack carries
`resolving`), and the engine narrates on the artist line — "Signing in to
Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…",
"Streaming from <peer>…", "<peer> isn't sending · trying another copy…",
"No copy found · trying the next track…" — through headlessState, kept in
NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the
engine's own Playing event clears it. The first tap's reply builds a fresh
state after the JNI call the engine narrated inside, so it carries the
line over rather than showing the artist for a beat.

Verified on the head unit with a three-row test playlist: a cold tap reads
"Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip
into a row not fetched yet reads "Searching Soulseek…", then "Streaming
from nouvellevag0…", then the artist again at 0:07, with the prefetch
fetching the row after it.
2026-09-18 15:44:25 -05:00
KeelrLeek 9f7661afab android auto: the car's queue works like the page's, and every row gets its cover
"Needs the app" is gone. It named what the cold service could not do — the
resolver and the Soulseek session live in the page, and a car that starts
the app cold has no page — and Kyle's answer to the label was the right
one: a tap should find a copy and play it, in the background, and a
playlist should keep going through rows that are not here exactly as the
page keeps going.

headless_slsk.rs is a Soulseek session for a process with no page: the
stored account, one search (artist + simplified title, the server's
excluded phrases stripped), copies ranked by rules close to the page's
songCandidates — the title in the filename, streamable formats, a duration
within eight per cent, lossless over 320 over the rest, the artist in the
path, a free upload slot before anything else — and a download into the
same stream cache the page uses. Its own tokio runtime: a car-started
process has no Tauri one.

headless.rs is the page's queue in Rust. resolve_playable queues EVERY row
of a playlist in order, a row not here with its remembered peer file and
no path. The engine starts a row from its file when it is whole, from the
fetch already filling its cache file when one is, and otherwise finds it
on the network right then (the remembered peer first, then the search),
playing the bytes as they land; a peer that sends nothing for fifteen
seconds is dropped for the next copy, twice, and when a row has no copy
anywhere the queue moves on — only the last row's failure reaches the
card. The next three rows are fetched ahead of time, four seconds after a
start so nothing competes with the track that is starting, and when the
immediate next is whole before the current one ends it is appended to the
sink and the boundary is gapless — the page's preloadLocal and
preloadCachedStream in one. Fetches are keyed by cache file, not index, so
a shuffle cannot start the same file from the same peer twice. The card
shows the title at once with a spinner (STATE_BUFFERING through
headlessState), then the track; every source is remembered in the mirror,
so the row is "here" next time.

One session per process: when the app opens over it, login_inner ADOPTS
the client and reads its events through subscribe() instead of logging in
again — two logins from one address in quick succession is what the server
throttles — and the fetches in flight become the page's own preloads, so
the row the car was fetching ahead lands gaplessly under the page too.

Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have
no catalog cover, and the car drew the app icon over them. In the cold
process the cover embedded in the file is extracted — the desktop's own
tagger::extract_art, keyed by the audio path under
<library>/.soulstream/stream-art/by-file/, a miss remembered as an empty
file — for browse rows, the card and the Continue row, and a row fetched
cold gets the same once 200 kB of it have landed. On the phone,
coverart.svelte.js is the desktop's playlist backfill as a background walk
over every playlist: the file before the catalog (the row's own cached
copy, persisted as art_path), then the ONE strict ladder the import review
uses (findCatalogTrack, persisted as art_url + track_id) — never a loose
name lookup. After boot, on every playlist change, every six hours, the
open page's rows first, a miss remembered for a day and only when the
catalog answered. The player does the same for the track that is playing
(ensureStreamArt: embedded, then catalog), makes the URL the entry's art
key and hands it to the walk to persist. The page's own hole is closed
too: a streamed row with no cover got no art key at all, and where it had
one the extractor's FILE PATH was stored where a data URL belongs; such
rows get a "stream:" key and the file is read as a thumbnail, and a stream
row's art_path now draws on the phone.

A car that starts the app cold has no page to walk for it, so the cold
process asks the catalog itself. cover_hunt.rs is catalogMatch.js in
Rust — the same Deezer field queries through the metadata crate, a match
only when the artist agrees, the base title agrees, the version
qualifiers are compatible and a known duration does not contradict; the
two are meant to stay in step — fed by every browse and every queue built
from a playlist. A blank stream row is answered from what was asked
before (stream-art/catalog.json, a miss for a day and only when the
catalog answered), else queued for one thread that looks rows up one at
a time behind the client's own Deezer pacing, writes the answer on the
row (art_url and the track id, only ever filling a NULL; the library is
WAL and the page's writer is another connection), and re-lists the
playlist on the car. The track that is playing gets the same, through an
entry that moves only the card's art and never its position. The cold
Soulseek search is the page's two-rung ladder as well: artist plus the
simplified title, then the title alone with the artist required in the
peer's path; the hunt's last rung is the page's last rung too, the
artist's whole shelf flattened to tracks, for the records the search
index buries — spent only on the track that is playing, bounded to forty
seconds, and the cover memory carries the matcher's version so a "no"
from an older matcher is no answer once a rung is added.

Two things the head unit found in the seam. media3's default onSubscribe
answers "not supported", so nobody was ever subscribed and the re-list
notice reached nobody; LibraryTree accepts subscriptions now, and covers
land on the car's list within seconds of being found. And the Soulseek
client spawns a download's transfer task with tokio::spawn, which needs a
runtime on the calling thread — the engine's resolver thread is not a
Tauri command, the spawn panicked, the thread died without a word, and
the card sat on BUFFERING; every client call from an engine thread runs
under the cold runtime's enter() now.

The phone's artist page never ran the MusicBrainz pass. It built its
discography from Deezer's list alone, so the releases Deezer's artist
listing omits — EPs, and whole albums — never appeared, nor the
authoritative release types or the guest-appearance and co-tenant
arbitration. The desktop's identity merge is a plain module now
(lib/artistIdentity.js, moved out of state.svelte.js verbatim, the
desktop importing it back), and the phone runs it after the page is on
screen — one implementation for both surfaces.

What the restarted head unit found, all in the seam between a car session
and the page — and one bug older than this branch.

The pump's calls into Kotlin never worked. "auto advanced to" has not
appeared in a single log while cold queues advanced through whole albums
under a card stuck on track one, and the new end-of-queue notice did not
fire either. FindClass on the self-attached pump thread resolves through
the system class loader, which cannot see com.soulstream.mediasession
.AutoNative; the lookup failed and the exception was cleared, silently,
every time. The class is taken from the object Kotlin hands every JNI
entry — on a Java thread, through the app's own loader — and kept as a
global reference; every pump call goes through call_auto_native.

Autoplay on connection worked unprompted and resumed Starboy at 3:50 of
3:50: the page writes the position one last time when a track runs out, so
a resume point can sit at the end. resume_queue treats a position within
two seconds of the duration as "the next item from the top", keeps every
entry (one not here is found on reach, like any other), and the headless
engine reports end-of-queue to Kotlin, which turns the card to paused. A
seek on the page reached the resume point only at the pump's next
five-second flush; seekSecs writes the session at once.

Opening the app over a cold car session handed the page the right queue
and the page called it a paused session that needed re-sourcing: the
transport snapshot it reads is built only from events after the page
subscribes, and an engine already playing sends no Playing. The bar said
paused, the card said PAUSED, the audio kept going. playback::init seeds
the snapshot from the engine's own progress when it adopts a running
engine, so the page follows what is sounding.

preview::init opened two more output streams at every launch for the
desktop's hover previews, which the phone never calls — three live AAudio
tracks on our uid with one sounding. It is gated off on Android.
2026-09-18 15:00:11 -05:00
KeelrLeek 17942fc04b android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.

A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.

The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.

The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.

Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.

Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.

The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.

The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.

Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
2026-09-18 12:35:54 -05:00
KeelrLeek e1bf456743 android auto: the car can see the app, and the root it opens is empty
Android Auto reaches a media app through `MediaBrowserService`, so `MediaService`
becomes a `MediaLibraryService`, one intent filter carries three actions — media3's
`MediaLibraryService` and `MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds — and
`res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application`
meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a
browsable root. There is nothing under it: `onGetChildren` is left un-overridden so
the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree.

`onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O,
nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the
binder thread serving the host on that future with NO timeout and runs the callback
on the main handler. A root that waits on anything is a head unit that gives up.

The round carries a mitigation it cannot be split from. androidx/media #3158 — open
since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a
host that binds a `MediaLibraryService` with nothing playing drive the service round
`onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty
cycles a second, deterministically after a force-stop with no existing binding. That
is exactly how Auto connects and exactly what the prober does. media3 will not act
before an Auto-side fix lands, so the loop cannot be stopped from here; it can only
be made to cost nothing. The session, the player, the notification provider, the
audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life
of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's
version would have built a platform session and a legacy browser stub thirty times a
second, registered and unregistered a receiver sixty times a second, and abandoned
audio focus thirty times a second while music was playing.

Three consequences, each a trap for the next round. `onDestroy` calls `removeSession`
and never `release()`: `removeSession` drops the session from that instance's map and
clears its listener, which is what stops a destroyed service being called back, while
`markReleased()` would latch `released` and leave a process-lifetime player that never
updates the card again. The session is built with
`MediaLibrarySession.Builder(applicationContext, …)` rather than the
`(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()`
does `attachToBaseContext(sessionImpl.getContext())` — a session built against the
Service pins a destroyed Service as the base context of a live
`MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its
platform `MediaSessionCompat` live until the process dies; the walk's `one-session`
check is what keeps that honest. `onCreate` logs `auto service created n=…` and
`onDestroy` logs `auto service destroyed`, which are the only way to count the loop on
a real phone.

And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way
Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one
JSON object per event under `SoulAutoProbe`. It is framework-only
(`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the
path Auto itself takes into a media3 app, and a prober built out of the library it is
probing would not prove that path works. It lives outside `src-tauri/gen/android/`
because `tauri android init` would delete it, and borrows the generated project's
Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream
force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of
this — the prober is the only eye the project has on Auto without a head unit. The
number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with
the process cold.

Two corrections to the plan, argued in the files. `adb shell pm dump` is
`dumpsys package` and prints NO application meta-data, so the Auto declaration cannot
be a hard walk assertion — the two that ARE hard are the intent filters
(`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap
completely because the filters and the meta-data live in one library manifest and are
lost together; the declaration is read instead by the prober, through
`PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the
walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like
every other module here, not 17 — JDK 17 is what RUNS Gradle.

No Rust, no frontend, no cargo. The round was authored where none of it could be
compiled and it has now been built and run: the Kotlin compiles, the APK installs, and
the prober binds the app cold. `desc` resolves the meta-data to a real
`xml/automotive_app_desc`; `filter` shows BOTH the legacy
`android.media.browse.MediaBrowserService` and media3's own resolving to
`MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of
which the callback itself is about nine milliseconds and the rest is the cold process
starting; `children` answers `n=-1` with nothing in it, which is the un-overridden
default doing its job; and `auto service created n=1 reused=false` — #3158 does not
loop here.

One fix the first run earned. The prober read the descriptor's `name` out of the
android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no
namespace, so it came back `[""]` — a real declaration and a dangling resource id
reported identically, which is the single thing the event exists to tell apart. It
reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says
`uses:["media"]`.

None of this proves the car screen: the Desktop Head Unit or a real head unit is the
only thing that shows what Auto draws, how it groups a tree, or whether a tap plays.
This proves the app answers.
2026-09-15 09:16:53 -05:00
KeelrLeek 3a8ccd4640 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.
2026-09-15 09:09:36 -05:00
KeelrLeek da4abeb8c9 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.
2026-09-15 07:22:02 -05:00
KeelrLeek afcd3cc7f3 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.
2026-09-15 07:22:02 -05:00
KeelrLeek d42645d2a0 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.
2026-09-15 07:22:01 -05:00
KeelrLeek 4ebc38d5f3 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.
2026-09-15 07:22:00 -05:00
KeelrLeek 07dad65ca5 android: the keep rules, and the release build that proves them
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. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
2026-09-14 11:09:33 -05:00
KeelrLeek 867fd42972 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.
2026-09-14 07:27:40 -05:00
KeelrLeek 33f32cb4c9 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.
2026-09-14 07:05:55 -05:00
KeelrLeek 0ce8c63cb6 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.
2026-09-14 05:20:02 -05:00
KeelrLeek 537bfdd5af 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.
2026-09-14 03:59:09 -05:00
KeelrLeek f2cde4ec7c 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.
2026-09-14 03:41:16 -05:00
KeelrLeek 1258b2b78d 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.
2026-09-14 03:24:26 -05:00
KeelrLeek defd8c5e6d 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.
2026-09-14 02:55:56 -05:00
KeelrLeek 04c87e0db1 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.
2026-09-14 02:38:44 -05:00
KeelrLeek 95c2bfeeba 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.
2026-09-14 02:21:37 -05:00
KeelrLeek 46cf6a98fe 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.
2026-09-14 01:55:00 -05:00
KeelrLeek 01bab007f7 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.
2026-09-14 01:38:15 -05:00
KeelrLeek 052e15302b 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.
2026-09-14 01:23:26 -05:00
KeelrLeek 44eda8dff5 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.
2026-09-14 00:58:14 -05:00
KeelrLeek 5738c2d7bf 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.
2026-09-14 00:44:11 -05:00
KeelrLeek 4fc6e9c630 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.
2026-09-14 00:28:28 -05:00
KeelrLeek 9df162360e 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.
2026-09-14 00:14:23 -05:00
KeelrLeek aff29aba5c 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.
2026-09-13 23:59:47 -05:00
KeelrLeek 1737c5e1b6 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.
2026-09-13 23:46:07 -05:00
KeelrLeek c737809325 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.
2026-09-13 23:32:00 -05:00
KeelrLeek 24c6857847 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.
2026-09-13 23:04:49 -05:00
KeelrLeek e693dfb868 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.
2026-09-13 22:52:03 -05:00
KeelrLeek 7eae0ee945 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.
2026-09-13 22:40:17 -05:00
KeelrLeek f792d7955d 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.
2026-09-13 22:28:38 -05:00
KeelrLeek 694a29eb0d 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.
2026-09-13 22:15:07 -05:00
KeelrLeek 3c8989dfa6 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.
2026-09-13 22:03:57 -05:00
KeelrLeek 59be9f6c3e 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.
2026-09-13 21:52:53 -05:00
KeelrLeek f8c3b90665 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.
2026-09-13 21:31:16 -05:00
KeelrLeek b50577fc81 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.
2026-09-13 21:20:11 -05:00
KeelrLeek d95c3f48d1 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.
2026-09-13 21:07:11 -05:00
KeelrLeek 60303c8a95 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.
2026-09-13 20:55:50 -05:00
KeelrLeek 991d52d0cc 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.
2026-09-13 20:37:51 -05:00
KeelrLeek 26928b706d 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.
2026-09-13 19:56:20 -05:00
KeelrLeek 6e39dc43f3 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.
2026-09-13 17:56:52 -05:00
KeelrLeek bb828ff2dc android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
2026-09-13 09:30:06 -05:00