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.
This commit is contained in:
1 parent
a44b4422fd
commit
3a8ccd4640
11 files changed
+1139
-283
No files matched your search
+237
-60
@@ -21,7 +21,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README).
|
||||
| `src/mobile/xfernote.svelte.js` | the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open |
|
||||
| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events |
|
||||
| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere |
|
||||
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
|
||||
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin `MediaSessionService` (`MediaService`) and a `SimpleBasePlayer` over the Rust engine (`SoulStreamPlayer`), on androidx.media3. media3 posts and owns the card; audio focus and the becoming-noisy pause are still ours. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
|
||||
| `crates/tauri-plugin-transfer-notice/` | a second Tauri plugin with a second Kotlin foreground service (`TransferService`, `dataSync`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing |
|
||||
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) |
|
||||
| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
|
||||
@@ -761,30 +761,148 @@ screen — off Windows they report None and 0.
|
||||
|
||||
## Background playback and the lock-screen card
|
||||
|
||||
The service is started with `startForegroundService`, so it promotes itself
|
||||
FIRST in `onStartCommand` — with the card there is, or a plain one — and
|
||||
only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when
|
||||
the track is paused or stopping when the bar is empty. Android 14 kills the
|
||||
whole app about ten seconds after a foreground start that never called
|
||||
`startForeground()`, and every restart restores a PAUSED track, which is
|
||||
exactly that case: on 2026-09-13 the first real Save died with the process
|
||||
that way, four tracks in. The check: force-stop, relaunch with a paused
|
||||
track, wait 25 s, the pid survives.
|
||||
|
||||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||||
hands its `android/` Gradle module to tauri-build (which writes it into
|
||||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin
|
||||
class and exposes `update` / `clear`, and the Kotlin side is two files —
|
||||
the plugin (arguments in, the `action` event out) and `MediaService`, a
|
||||
`mediaPlayback` foreground service holding a `MediaSessionCompat` and a
|
||||
MediaStyle notification. Playing → `startForeground`; paused → the service
|
||||
steps back and the card stays, dismissable. Android 13+ asks for
|
||||
`POST_NOTIFICATIONS` the first time something plays. Audio focus is
|
||||
requested while playing (loss pauses, a transient loss pauses and resumes),
|
||||
and headphones coming out pause. The app's capability grants
|
||||
`media-session:default`; the plugin is registered on every platform so the
|
||||
permission resolves everywhere, and on the desktop its commands answer
|
||||
"not here" (the desktop has `smtc.rs`).
|
||||
Nothing in this plugin starts the service. It is **bound**, and that is half
|
||||
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
|
||||
action, a custom action, or media3's own self-start intent — which it
|
||||
recognises by a uid extra of its own making — and returns `START_STICKY`
|
||||
without calling `startForeground()` for anything else; a plain
|
||||
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
||||
`startForegroundService` would now arm Android's ten-second timer and satisfy
|
||||
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
|
||||
whole app about ten seconds after a foreground start that never called
|
||||
`startForeground()`, and on that day the first real Save died with the
|
||||
process, four tracks in. media3 promotes ITSELF, once there is something to
|
||||
promote for — `MediaNotificationManager.startForeground()` does the
|
||||
`startForegroundService` and the `setForegroundServiceNotification` in one
|
||||
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
||||
with the action `androidx.media3.session.MediaSessionService`, which creates
|
||||
the service, is not subject to the Android 8+/12+ background-start
|
||||
restriction, and arms no timer at all. The check is unchanged and one step
|
||||
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
|
||||
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
||||
|
||||
The other half is `MediaService.onStartCommand`, because the **system** starts
|
||||
this service in the foreground whether media3 then wants it there or not.
|
||||
media3 hands the framework a media-button `PendingIntent` built with
|
||||
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
||||
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
||||
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
||||
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
||||
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
||||
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
||||
`setStartAsForegroundService(!player.playWhenReady)`, but
|
||||
`PlaybackPendingIntentBuilder.build()` only honours it for
|
||||
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
|
||||
anyway; every other card button is a plain `getService`. The KEYS are the
|
||||
problem. A key press arms the ten-second timer, and media3 promotes only for a
|
||||
session it calls *user-engaged*: `playWhenReady` and
|
||||
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
||||
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
||||
cannot resolve — a null session from `onGetSession` (ours never is), a custom
|
||||
action it cannot name (we declare none), a stale self-start intent from a
|
||||
destroyed instance. Three presses qualify for none of it and would each take
|
||||
the app down about ten seconds later: a key into a process the system built
|
||||
*for* that key; a key on a card paused longer than ten minutes, once the tail
|
||||
has run out and the press does not start playback; and any key at all with the
|
||||
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
|
||||
alive for. So
|
||||
`onStartCommand` promotes first, before `super` — with media3's own card
|
||||
through media3's own `onUpdateNotification(session, true)` when there is
|
||||
something on the bar, so its `startedInForeground` bookkeeping stays true and
|
||||
the shade never sees a stand-in, and otherwise with a plain notification on an
|
||||
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
|
||||
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
|
||||
`startForeground` and `stopForeground` are **final**, so none of this can be
|
||||
done by watching them; the split above is what keeps it from fighting the
|
||||
library, because media3 cannot be in the foreground with nothing on the bar —
|
||||
an empty timeline fails `shouldShowNotification`, which removes the
|
||||
notification and demotes.
|
||||
|
||||
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
||||
hands its `android/` Gradle module to tauri-build (which writes it into
|
||||
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
||||
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
||||
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
||||
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
||||
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
|
||||
needs a media3 session to browse, and the rounds after this one build on the
|
||||
`Player` this one creates. `MediaService` is a `MediaSessionService` holding
|
||||
one `MediaSession`, and `SoulStreamPlayer` is a `SimpleBasePlayer` — media3's
|
||||
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
||||
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
|
||||
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
||||
last `NowPlayingArgs` off the bridge, queue window included) and every
|
||||
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
||||
`src/mobile/media.svelte.js`, which did not change.
|
||||
|
||||
Four things in that file are load-bearing and silent when they break. The
|
||||
playlist **must never be empty while a track exists**:
|
||||
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
||||
and the card then never appears, with nothing in any log — so `getState()`
|
||||
falls back to a one-item playlist rather than trusting the window. Today's
|
||||
frontend cannot actually send an empty window with a track on it
|
||||
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
||||
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
|
||||
there because a frontend out of step with this plugin, or a `queue` that did
|
||||
not survive the two JSON hops, would take the card down silently.
|
||||
`COMMAND_GET_TIMELINE` and
|
||||
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
||||
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
||||
down to what its commands allow — one window without the first,
|
||||
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
|
||||
same refusal from the other end. A paused player must report `STATE_READY`,
|
||||
not `STATE_IDLE`, or the card
|
||||
goes when the music does. And both spellings of next and previous must be
|
||||
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
||||
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
|
||||
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
|
||||
alone and the button appears and does nothing.
|
||||
|
||||
Two things media3 does NOT do for a custom player, whatever its own API
|
||||
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
||||
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
||||
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
||||
wording is "true if **the player** should handle audio focus".
|
||||
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
||||
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
||||
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
||||
"another app takes the output → we pause" and "headphones out → we pause",
|
||||
and no walk on this phone can catch either.
|
||||
|
||||
The transport handlers do not answer media3 immediately. A play, a pause or a
|
||||
seek is a round trip out to the WebView and back, and an instant answer has
|
||||
media3 call `getState()` on the spot — which still reports the OLD state, so
|
||||
the lock screen flips back before it flips forward. Each returns an
|
||||
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
||||
while one is outstanding media3 shows its own optimistic placeholder, which
|
||||
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
|
||||
regardless, because `invalidateState()` deliberately does nothing while
|
||||
anything is pending: a future that is never completed does not degrade the
|
||||
card, it freezes it.
|
||||
|
||||
Two behaviours changed with the migration, both defensible, both of them
|
||||
looking like bugs if nobody wrote them down. After a pause the service stays
|
||||
in the foreground for up to ten minutes (media3's
|
||||
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
|
||||
at once. And `clear` leaves the service alive with an idle session registered
|
||||
rather than stopping it, so a headset button still reaches us with nothing on
|
||||
the bar; the audio focus the old service gave back by dying is now abandoned
|
||||
by hand in `MediaHub.clear`.
|
||||
|
||||
What did NOT change, though it is the first thing a reading of the API
|
||||
suggests: previous and next still work everywhere the old card did, including
|
||||
at the very top of a queue. The card's buttons are delivered as key events —
|
||||
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
||||
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
||||
`BasePlayer` falls back to seeking the current item when there is no previous
|
||||
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
||||
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
||||
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
||||
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
|
||||
else. So the app's own `prev()` — five-second restart rule and all — runs on
|
||||
every press. The one button that really can go missing is next on the last row
|
||||
with repeat off, because `hasNext` is false there and the command is not
|
||||
advertised at all, which is correct.
|
||||
|
||||
The glyph in the status bar is the falcon, not a note.
|
||||
`ic_soulstream_note.xml` is the logo kit's monochrome path verbatim
|
||||
@@ -802,14 +920,14 @@ actually breaks. `media-card` proves it: after `resumed`, the card is still
|
||||
posted, on the `playback` channel, carrying the playing track's own title,
|
||||
and `media-keys` a line below still gets the transport back.
|
||||
|
||||
The card's large icon falls back to the app's own icon. `MediaHub.art` is
|
||||
cleared the moment `artKey` changes and only refilled once the frontend has
|
||||
decoded a cover into bytes, and a track with no cover never refills it at
|
||||
all — so without a fallback every track change has a window where the lock
|
||||
screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and
|
||||
keeps it (`refresh()` runs on every position tick, so a per-update decode
|
||||
would be a real cost), through the PackageManager rather than a resource id
|
||||
because a library module's own R has no `ic_launcher`. It is drawn at
|
||||
The card's artwork falls back to the app's own icon. `MediaHub.artBytes`
|
||||
is cleared the moment `artKey` changes and only refilled once the frontend
|
||||
has decoded a cover into bytes, and a track with no cover never refills it
|
||||
at all — so without a fallback every track change has a window where the
|
||||
lock screen shows a grey hole. `MediaHub.appIcon()` encodes the icon ONCE
|
||||
and keeps the PNG bytes (`getState()` runs on every invalidation, so a
|
||||
per-update encode would be a real cost), through the PackageManager rather
|
||||
than a resource id because a library module's own R has no `ic_launcher`. It is drawn at
|
||||
`setBounds(0, 0, 512, 512)`, and that is less obvious than it looks: an
|
||||
`AdaptiveIconDrawable`'s bounds are the **72** units the launcher shows, not
|
||||
the 108 its layers are drawn in — it scales its own layers up by 3/2 and
|
||||
@@ -818,8 +936,9 @@ is two thirds of the layer PNG. So the plain call already IS the launcher's
|
||||
view, and a box set outside the tile to "reveal the 18% bleed" does the
|
||||
opposite: it crops into the safe zone. The kit's foreground inks y 20.5→86.5
|
||||
of 108, so a box 128 units oversize on each side would have shown 30→78 —
|
||||
the head and the tail cut off. The same bitmap serves
|
||||
`METADATA_KEY_ALBUM_ART`.
|
||||
the head and the tail cut off. The same bytes go across as
|
||||
`MediaMetadata.artworkData`, which media3 decodes itself — one
|
||||
`BitmapFactory` call this plugin no longer makes.
|
||||
|
||||
`android:appCategory="audio"` is contributed by the media-session plugin's
|
||||
own library manifest. The app manifest lives in gitignored `gen/` and cannot
|
||||
@@ -857,16 +976,25 @@ with a `ClassNotFoundException`. `src-tauri/android/build.sh` exports it;
|
||||
after adding a plugin, touch `src-tauri/build.rs` too, so the app's build
|
||||
script re-runs and rewrites the wiring.
|
||||
|
||||
Every update also carries a **queue window** that nothing reads. `NowPlaying`
|
||||
(Rust) and `NowPlayingArgs` (Kotlin) hold a `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 both from `player.queue` and `player.qi`;
|
||||
`MediaService` ignores them completely, so the card is byte for byte the card
|
||||
it was. The reason is media3: `SimpleBasePlayer.getState()` wants a playlist
|
||||
and an index into it, and the data that migration needs should have been
|
||||
running on the phone for a while before anything depends on it. Ten either
|
||||
Every update also carries a **queue window**, and since round forty-eight the
|
||||
card is built out of it. `NowPlaying` (Rust) and `NowPlayingArgs` (Kotlin)
|
||||
hold a `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, `queueBase`, where the window starts in the whole
|
||||
queue, and `repeat`. `src/mobile/media.svelte.js` fills all of them from
|
||||
`player.queue`, `player.qi` and `player.repeat`, and
|
||||
`SoulStreamPlayer.getState()` turns them into the media3 playlist, the current
|
||||
index and the repeat mode. `queueBase` is what gives an entry a stable
|
||||
`MediaItemData` uid as the window slides past it, so a track change is one
|
||||
media-item transition rather than twenty-one re-labelled rows — and it is half
|
||||
of the mapping Android Auto will need to turn a tapped queue row back into a
|
||||
`playQueueAt()`. `repeat` crosses so the player's state is not a lie to
|
||||
whatever reads it — the system's own media controls now, Auto later — and so
|
||||
media3's view of "is there a next item" agrees with the `hasNext` the next
|
||||
button is advertised on. Its numberings are SWAPPED: the app 0 off · 1 ALL ·
|
||||
2 ONE, media3 0 OFF · 1 ONE · 2 ALL, so `SoulStreamPlayer` maps across rather
|
||||
than casting, which would have turned repeat-all into repeat-one. 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
|
||||
in both directions; and it is a cap, not a length, so a two-thousand-track
|
||||
@@ -878,14 +1006,18 @@ and an update is a track change, a transport change or the thirty-second
|
||||
top-up, never a position tick.
|
||||
|
||||
Because it is on no screen, the walk reads it out of `logcat`:
|
||||
`MediaSessionPlugin.update` logs one `media window n=… i=… art=… at=…` line
|
||||
per update under the tag `SoulStream`, and the phone walk's `queue-window`
|
||||
check holds that against `__ss.card` — the mirror `media.svelte.js` keeps of
|
||||
what it last sent, beside xfernote's — field for field, through a next and a
|
||||
previous. Field for field is deliberate: a key spelled snake_case where the
|
||||
bridge wants camelCase arrives as nothing at all on both hops, with no error
|
||||
anywhere, which is why the art-key count is compared and not just the size and
|
||||
the index.
|
||||
`MediaSessionPlugin.update` logs one
|
||||
`media window n=… i=… art=… base=… rep=… at=…` line per update under the tag
|
||||
`SoulStream`, and the phone walk's `queue-window` check holds that against
|
||||
`__ss.card` — the mirror `media.svelte.js` keeps of what it last sent, beside
|
||||
xfernote's — field for field, through a next and a previous. Field for field
|
||||
is deliberate: a key spelled snake_case where the bridge wants camelCase
|
||||
arrives as nothing at all on both hops, with no error anywhere, which is why
|
||||
the art-key count is compared and not just the size and the index. `base` and
|
||||
`rep` are on that line for a sharper version of the same reason: they steer
|
||||
the media3 playlist and the card's next button, they are on no screen and no
|
||||
card, and this is the only place on the device either of them can be read
|
||||
back at all.
|
||||
|
||||
Three things in that check are load-bearing and easy to undo by accident. It
|
||||
arms at queue index **one** rather than wherever the walk left off, which buys
|
||||
@@ -901,6 +1033,33 @@ FAILS rather than skips when it cannot be armed — a queue under three rows, or
|
||||
repeat-one, under which `next()` seeks instead of stepping — with the reason
|
||||
in the message.
|
||||
|
||||
### What the walk proves about the card, and what it does not
|
||||
|
||||
**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly
|
||||
one card, on the `playback` channel, carrying the playing track's own title
|
||||
(`media-card`, `one-card`); the system holding one media session for the app,
|
||||
PLAYING (`media-session`, `one-session`); media keys reaching the player
|
||||
through that session with the app in front (`media-keys`); ten seconds in the
|
||||
background with the position still advancing (`background`); the paused
|
||||
restore waited out past 25 s with the pid intact and no
|
||||
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
||||
queue window arriving across the bridge, field for field (`queue-window`).
|
||||
Printed but never failed, because their `dumpsys` formats have never been read
|
||||
back from this phone: whether the service is foreground, whether our audio
|
||||
focus is in the system's stack, and whether the becoming-noisy receiver is
|
||||
registered (`media-plumbing`).
|
||||
|
||||
**Kyle's hands only. Nothing here can be automated, and the walk going green
|
||||
says nothing about any of it.** Unplugging headphones pauses the music —
|
||||
`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell
|
||||
uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking
|
||||
audio focus pauses this one, and giving it back resumes it. The card's LOOK:
|
||||
the cover, and the falcon in the status bar — `dumpsys notification` prints a
|
||||
resource id and never a pixel, so a correct glyph and a white rectangle read
|
||||
identically. And a Bluetooth headset's own buttons, which take a path
|
||||
`input keyevent` does not. These four are the first things to try after the
|
||||
first deploy of anything that touches this plugin.
|
||||
|
||||
## What a screen reader says
|
||||
|
||||
Every control on the phone is named by what it does, in the app's own
|
||||
@@ -1978,13 +2137,19 @@ read over real IPC; a cold artist page, its ⋯, the whole-discography page and
|
||||
its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend
|
||||
strings through `__ss.say`; and a refused transfer's sentence.
|
||||
|
||||
Then, after the step loop, eight things the mock cannot do at all: a
|
||||
force-stop and the paused restore that follows it (waited out past the
|
||||
thirteen-second FGS-kill window), the position restored, playback resumed, the
|
||||
system media session and its ownership, media keys, ten seconds in the
|
||||
background with the position still advancing, SoulStream's own mixer
|
||||
underruns, and the queue window that rides along with every card update and
|
||||
that nothing on the card reads.
|
||||
Then, after the step loop, ten things the mock cannot do at all: a force-stop
|
||||
and the paused restore that follows it (waited out past twenty-five seconds,
|
||||
which is well past the ten-second FGS-kill window), the position restored,
|
||||
playback resumed, the system media session and its ownership, media keys,
|
||||
exactly one card in the shade and exactly one session in the system's list —
|
||||
the two checks that prove the media3 migration landed rather than doubled —
|
||||
ten seconds in the background with the position still advancing, SoulStream's
|
||||
own mixer underruns, and the queue window that rides along with every card
|
||||
update and that the lock-screen card is now built out of. One more line
|
||||
prints and never fails: whether the service is foreground, whether our audio
|
||||
focus is in the system's stack, and whether the becoming-noisy receiver is
|
||||
registered. What none of it touches is in "What the walk proves about the
|
||||
card, and what it does not", above.
|
||||
|
||||
One block in the phone walk pulls bytes, and only when the app is signed in:
|
||||
the trailing network block types `--query` into Search, streams a peer's copy
|
||||
@@ -2112,3 +2277,15 @@ symptom.
|
||||
and the hands-only list below, the pickers and the notification buttons and
|
||||
the share sheet, which no walk reaches. Work down the smoke list in "The
|
||||
release build" before trusting a release APK on a phone.
|
||||
|
||||
- **Android Auto, half-built.** Round forty-eight put the card on
|
||||
`androidx.media3` — a `MediaSessionService` over a `SimpleBasePlayer` —
|
||||
which is the base a browsable service needs and the reason that round
|
||||
happened at all. What is missing is the browse tree: `MediaService` becomes
|
||||
a `MediaLibraryService`, `onGetLibraryRoot` and `onGetChildren` answer with
|
||||
the library's own shape, an `automotive_app_desc.xml` and its
|
||||
`<meta-data>` declare the app to Auto, and a tapped queue row maps back
|
||||
through `queueBase` to `playQueueAt()` (which is why
|
||||
`COMMAND_SET_MEDIA_ITEM` and its neighbours are deliberately NOT advertised
|
||||
yet). None of it can be walked from here: Auto needs the Desktop Head Unit
|
||||
or a car.
|
||||
Reference in new issue
Block a user