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:
KeelrLeek committed 2026-09-15 09:09:36 -05:00
1 parent a44b4422fd
commit 3a8ccd4640
11 files changed
+1139 -283

No files matched your search

+237 -60
View File
@@ -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/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/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 | | `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 | | `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/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) | | `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 ## Background playback and the lock-screen card
The service is started with `startForegroundService`, so it promotes itself Nothing in this plugin starts the service. It is **bound**, and that is half
FIRST in `onStartCommand` — with the card there is, or a plain one — and the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when action, a custom action, or media3's own self-start intent — which it
the track is paused or stopping when the bar is empty. Android 14 kills the recognises by a uid extra of its own making — and returns `START_STICKY`
whole app about ten seconds after a foreground start that never called without calling `startForeground()` for anything else; a plain
`startForeground()`, and every restart restores a PAUSED track, which is `Intent(ctx, MediaService::class.java)` is anything else. So the old
exactly that case: on 2026-09-13 the first real Save died with the process `startForegroundService` would now arm Android's ten-second timer and satisfy
that way, four tracks in. The check: force-stop, relaunch with a paused nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
track, wait 25 s, the pid survives. whole app about ten seconds after a foreground start that never called
`startForeground()`, and on that day the first real Save died with the
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs` process, four tracks in. media3 promotes ITSELF, once there is something to
hands its `android/` Gradle module to tauri-build (which writes it into promote for — `MediaNotificationManager.startForeground()` does the
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin `startForegroundService` and the `setForegroundServiceNotification` in one
class and exposes `update` / `clear`, and the Kotlin side is two files — call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
the plugin (arguments in, the `action` event out) and `MediaService`, a with the action `androidx.media3.session.MediaSessionService`, which creates
`mediaPlayback` foreground service holding a `MediaSessionCompat` and a the service, is not subject to the Android 8+/12+ background-start
MediaStyle notification. Playing → `startForeground`; paused → the service restriction, and arms no timer at all. The check is unchanged and one step
steps back and the card stays, dismissable. Android 13+ asks for longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
`POST_NOTIFICATIONS` the first time something plays. Audio focus is and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
requested while playing (loss pauses, a transient loss pauses and resumes),
and headphones coming out pause. The app's capability grants The other half is `MediaService.onStartCommand`, because the **system** starts
`media-session:default`; the plugin is registered on every platform so the this service in the foreground whether media3 then wants it there or not.
permission resolves everywhere, and on the desktop its commands answer media3 hands the framework a media-button `PendingIntent` built with
"not here" (the desktop has `smtc.rs`). `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. The glyph in the status bar is the falcon, not a note.
`ic_soulstream_note.xml` is the logo kit's monochrome path verbatim `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, posted, on the `playback` channel, carrying the playing track's own title,
and `media-keys` a line below still gets the transport back. 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 The card's artwork falls back to the app's own icon. `MediaHub.artBytes`
cleared the moment `artKey` changes and only refilled once the frontend has is cleared the moment `artKey` changes and only refilled once the frontend
decoded a cover into bytes, and a track with no cover never refills it at has decoded a cover into bytes, and a track with no cover never refills it
all — so without a fallback every track change has a window where the lock at all — so without a fallback every track change has a window where the
screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and lock screen shows a grey hole. `MediaHub.appIcon()` encodes the icon ONCE
keeps it (`refresh()` runs on every position tick, so a per-update decode and keeps the PNG bytes (`getState()` runs on every invalidation, so a
would be a real cost), through the PackageManager rather than a resource id per-update encode would be a real cost), through the PackageManager rather
because a library module's own R has no `ic_launcher`. It is drawn at 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 `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 `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 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 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 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 — 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 the head and the tail cut off. The same bytes go across as
`METADATA_KEY_ALBUM_ART`. `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 `android:appCategory="audio"` is contributed by the media-session plugin's
own library manifest. The app manifest lives in gitignored `gen/` and cannot 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 after adding a plugin, touch `src-tauri/build.rs` too, so the app's build
script re-runs and rewrites the wiring. script re-runs and rewrites the wiring.
Every update also carries a **queue window** that nothing reads. `NowPlaying` Every update also carries a **queue window**, and since round forty-eight the
(Rust) and `NowPlayingArgs` (Kotlin) hold a `queue` of at most twenty-one card is built out of it. `NowPlaying` (Rust) and `NowPlayingArgs` (Kotlin)
entries — the current item and ten either side, each with its id, title, hold a `queue` of at most twenty-one entries — the current item and ten either
artist, album, duration and art key — plus `queueIndex`, where the current one side, each with its id, title, artist, album, duration and art key — plus
sits *within that window* rather than within the whole queue. `queueIndex`, where the current one sits *within that window* rather than
`src/mobile/media.svelte.js` fills both from `player.queue` and `player.qi`; within the whole queue, `queueBase`, where the window starts in the whole
`MediaService` ignores them completely, so the card is byte for byte the card queue, and `repeat`. `src/mobile/media.svelte.js` fills all of them from
it was. The reason is media3: `SimpleBasePlayer.getState()` wants a playlist `player.queue`, `player.qi` and `player.repeat`, and
and an index into it, and the data that migration needs should have been `SoulStreamPlayer.getState()` turns them into the media3 playlist, the current
running on the phone for a while before anything depends on it. Ten either 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 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 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 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. top-up, never a position tick.
Because it is on no screen, the walk reads it out of `logcat`: Because it is on no screen, the walk reads it out of `logcat`:
`MediaSessionPlugin.update` logs one `media window n=… i=… art=… at=…` line `MediaSessionPlugin.update` logs one
per update under the tag `SoulStream`, and the phone walk's `queue-window` `media window n=… i=… art=… base=… rep=… at=…` line per update under the tag
check holds that against `__ss.card` — the mirror `media.svelte.js` keeps of `SoulStream`, and the phone walk's `queue-window` check holds that against
what it last sent, beside xfernote's — field for field, through a next and a `__ss.card` — the mirror `media.svelte.js` keeps of what it last sent, beside
previous. Field for field is deliberate: a key spelled snake_case where the xfernote's — field for field, through a next and a previous. Field for field
bridge wants camelCase arrives as nothing at all on both hops, with no error is deliberate: a key spelled snake_case where the bridge wants camelCase
anywhere, which is why the art-key count is compared and not just the size and arrives as nothing at all on both hops, with no error anywhere, which is why
the index. 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 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 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 repeat-one, under which `next()` seeks instead of stepping — with the reason
in the message. 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 ## What a screen reader says
Every control on the phone is named by what it does, in the app's own 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 its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend
strings through `__ss.say`; and a refused transfer's sentence. strings through `__ss.say`; and a refused transfer's sentence.
Then, after the step loop, eight things the mock cannot do at all: a Then, after the step loop, ten things the mock cannot do at all: a force-stop
force-stop and the paused restore that follows it (waited out past the and the paused restore that follows it (waited out past twenty-five seconds,
thirteen-second FGS-kill window), the position restored, playback resumed, the which is well past the ten-second FGS-kill window), the position restored,
system media session and its ownership, media keys, ten seconds in the playback resumed, the system media session and its ownership, media keys,
background with the position still advancing, SoulStream's own mixer exactly one card in the shade and exactly one session in the system's list —
underruns, and the queue window that rides along with every card update and the two checks that prove the media3 migration landed rather than doubled —
that nothing on the card reads. 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: 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 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 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 the share sheet, which no walk reaches. Work down the smoke list in "The
release build" before trusting a release APK on a phone. 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.
@@ -26,7 +26,34 @@ android {
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.core:core-ktx:1.13.1")
// MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver // The session, the card, and a notification media3 posts and owns itself.
implementation("androidx.media:media:1.7.0") //
// 1.10.1 and NOT 1.11.0, which is newer (2026-08-05) and would break this
// build: media3-session-1.11.0's POM declares
// org.jetbrains.kotlin:kotlin-stdlib:2.2.10 at compile scope, this project
// compiles Kotlin with kotlin-gradle-plugin 1.9.25
// (src-tauri/gen/android/build.gradle.kts), and Gradle resolves to the
// HIGHEST version — so a 1.9 compiler would be handed Kotlin 2.2 metadata.
// Every media3 POM from 1.4.1 through 1.10.1 declares no kotlin-stdlib at
// all, and SimpleBasePlayer.java is byte for byte the same file at both
// tags, so nothing this plugin uses is newer than what is taken here.
//
// What rides along: media3-common at COMPILE scope, and with it
// com.google.guava:guava:33.3.1-android, also compile — that is where
// ListenableFuture, Futures and SettableFuture come from, which is why
// they are not named on a line of their own — plus
// androidx.annotation-experimental, which is androidx.annotation.OptIn.
// Then lifecycle-service 2.5.1, because MediaSessionService extends
// LifecycleService; media3-datasource, the default BitmapLoader that
// decodes the cover; and androidx.media:media:1.7.0 itself, still, at
// RUNTIME scope. The deprecated library has not left the APK and cannot —
// media3 uses its MediaBrowserServiceCompat. What changed is that none of
// OUR code compiles against it any more.
//
// media3 1.10.1's own minSdk is 23; this module's is 26. Guava adds about
// 2.8 MB of unminified classes to the debug APK, and minSdk 26 means
// native multidex, so there is no multiDexEnabled line to add.
implementation("androidx.media3:media3-session:1.10.1")
implementation("androidx.media3:media3-common:1.10.1")
implementation(project(":tauri-android")) implementation(project(":tauri-android"))
} }
@@ -16,9 +16,12 @@
# through Plugin.registerListener — declared on tauri's base class, which no # through Plugin.registerListener — declared on tauri's base class, which no
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro. # rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
# #
# MediaService and androidx's MediaButtonReceiver are declared in this # MediaService is declared in this module's AndroidManifest.xml, so AGP's
# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold # own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs
# them by name. Not repeated here: one place decides each of them. # no rule of its own — the only thing that reaches it is a direct reference
# from MediaService. androidx's MediaButtonReceiver went with the move to
# media3: the service's own SERVICE_INTERFACE filter routes media buttons
# now, and two receivers would have fought over them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; } -keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; } -keep class com.soulstream.mediasession.NowPlayingArgs { *; }
-keep class com.soulstream.mediasession.QueueItemArgs { *; } -keep class com.soulstream.mediasession.QueueItemArgs { *; }
@@ -14,20 +14,20 @@
<application android:appCategory="audio"> <application android:appCategory="audio">
<service <service
android:name="com.soulstream.mediasession.MediaService" android:name="com.soulstream.mediasession.MediaService"
android:exported="false" android:exported="true"
android:foregroundServiceType="mediaPlayback"> android:foregroundServiceType="mediaPlayback">
<!-- lets MediaButtonReceiver forward headset and card buttons here --> <!-- media3 finds its own service through this, and while the
process is alive it is what routes a headset or a card
button here. There is no MediaButtonReceiver any more, and
media3's own is deliberately NOT put in its place: its
docs make that receiver conditional on
MediaSession.Callback.onPlaybackResumption(), which this
app cannot implement — playback lives in Rust behind a
WebView round trip. Two receivers would only have fought
over the buttons. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter> </intent-filter>
</service> </service>
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -4,85 +4,154 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock import android.os.Looper
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Base64 import android.util.Base64
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media.session.MediaButtonReceiver import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import java.io.ByteArrayOutputStream
/** /**
* What is playing, shared between the plugin (which hears the WebView) and * What is playing, shared between the plugin (which hears the WebView) and
* the service (which owns the session). The service is started on the first * the service (which owns the session). The service is BOUND on the first
* update and refreshed on every one after. * update and refreshed on every one after.
*
* Bound, not started, and that is half the safety story of this file.
* `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 a
* `startForegroundService` here would arm Android's ten-second timer and
* satisfy nothing, which is the 2026-09-13 bug with a new coat on. media3
* promotes ITSELF, once there is something playing to promote for:
* `MediaNotificationManager.startForeground()` does the
* `startForegroundService` and the `setForegroundServiceNotification` in the
* same call. `bindService` merely creates the service, is not subject to the
* Android 8+/12+ background-start restriction, and never arms the timer.
*
* The other half is [MediaService.onStartCommand], because the SYSTEM starts
* this service in the foreground on a button press whether media3 then wants
* it there or not.
*/ */
object MediaHub { object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null @Volatile var onAction: ((String, Double) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null @Volatile var state: NowPlayingArgs? = null
@Volatile var art: Bitmap? = null /** the cover as ENCODED bytes — media3 decodes them itself */
@Volatile var artBytes: ByteArray? = null
@Volatile var artKey: String? = null @Volatile var artKey: String? = null
@Volatile var service: MediaService? = null @Volatile var service: MediaService? = null
/** the app's own icon, decoded once — see appIcon() */ @Volatile var player: SoulStreamPlayer? = null
@Volatile private var icon: Bitmap? = null /** the app's own icon, encoded once — see appIcon() */
@Volatile private var icon: ByteArray? = null
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var bound = false
private val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {}
/* the service died; the binding survives and the system rebuilds it */
override fun onServiceDisconnected(name: ComponentName?) {}
}
fun update(ctx: Context, np: NowPlayingArgs) { fun update(ctx: Context, np: NowPlayingArgs) {
if (np.artKey != artKey) { if (np.artKey != artKey) {
artKey = np.artKey artKey = np.artKey
art = null artBytes = null
} }
np.art?.let { b64 -> np.art?.let { b64 ->
try { try {
val bytes = Base64.decode(b64, Base64.DEFAULT) artBytes = Base64.decode(b64, Base64.DEFAULT)
art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) { } catch (_: Exception) {
/* an unreadable cover is no cover */ /* an unreadable cover is no cover */
} }
} }
state = np state = np
val running = service bind(ctx)
if (running == null) { /* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
/* Android 12+ refuses a foreground start from the background; the verifyApplicationThread(), a Tauri @Command is not promised to run
card simply waits for the next update from the foreground */ on the main thread, and the IllegalStateException that follows
try { would be swallowed by media.svelte.js's own `.catch(() => {})` —
ContextCompat.startForegroundService(ctx, Intent(ctx, MediaService::class.java)) the card would just quietly stop updating, with nothing in any log. */
} catch (e: Exception) { mainHandler.post {
android.util.Log.w("SoulStream", "media service could not start: $e") service?.syncFocus(np.playing)
player?.let {
it.settle()
it.refresh()
} }
} else {
running.refresh()
} }
} }
/** The bar emptied. The playlist goes empty, which is how media3 is told
* to take the card down (`shouldShowNotification` refuses an empty
* timeline) and to step out of the foreground. The service is left
* alive and the session left registered ON PURPOSE: a headset button
* should still reach us with nothing on the bar, and re-creating the
* service is the expensive half. Audio focus IS given back, which the
* old service did by dying. */
fun clear(ctx: Context) { fun clear(ctx: Context) {
state = null state = null
val running = service artBytes = null
if (running != null) running.stopSelf() else ctx.stopService(Intent(ctx, MediaService::class.java)) artKey = null
mainHandler.post {
service?.dropFocus()
player?.let {
it.settle()
it.refresh()
}
}
} }
/** The app's own icon, decoded ONCE and kept, as the card's large icon /* two updates can arrive on two binder threads; one binding is enough */
* when there is no cover yet. `art` is cleared the moment `artKey` @Synchronized
private fun bind(ctx: Context) {
if (bound) return
try {
/* the action matters: MediaSessionService.onBind answers only for
SERVICE_INTERFACE and returns null for a bare intent, and
BIND_AUTO_CREATE is what creates the service at all */
val intent = Intent(ctx, MediaService::class.java)
.setAction(MediaSessionService.SERVICE_INTERFACE)
bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
if (!bound) {
android.util.Log.w("SoulStream", "media service would not bind")
try { ctx.unbindService(conn) } catch (_: Exception) {}
}
} catch (e: Exception) {
android.util.Log.w("SoulStream", "media service could not bind: $e")
}
}
/** The app's own icon, encoded ONCE and kept, as the card's artwork when
* there is no cover yet. `artBytes` is cleared the moment `artKey`
* changes and only refilled when the frontend has finished decoding a * changes and only refilled when the frontend has finished decoding a
* cover into bytes — and a track with no cover never refills it at all — * cover into bytes — and a track with no cover never refills it at all —
* so every track change has a real window with a null large icon and the * so every track change has a real window with no artwork and the lock
* lock screen shows a grey hole. This is what stands in it. * screen shows a grey hole. This is what stands in it.
*
* PNG bytes rather than a Bitmap since the media3 migration:
* `MediaMetadata.setArtworkData` wants encoded bytes and media3 decodes
* them itself, which is one BitmapFactory call this plugin no longer
* makes.
* *
* Through the PackageManager rather than a resource id on purpose: this * Through the PackageManager rather than a resource id on purpose: this
* is a library module and its own R has no `ic_launcher`, so from in here * is a library module and its own R has no `ic_launcher`, so from in here
@@ -98,10 +167,10 @@ object MediaHub {
* bleed" does the opposite and crops into the safe zone. Every other * bleed" does the opposite and crops into the safe zone. Every other
* kind of drawable simply fills the tile. * kind of drawable simply fills the tile.
* *
* Decoded on the first ask and never again — `refresh()` runs on every * Encoded on the first ask and never again — getState() runs on every
* position tick, and this must not. A failure caches nothing and answers * invalidation, and this must not. A failure caches nothing and answers
* null, which is the card exactly as it was before this existed. */ * null, which is the card exactly as it was before this existed. */
fun appIcon(ctx: Context): Bitmap? { fun appIcon(ctx: Context): ByteArray? {
val cached = icon val cached = icon
if (cached != null) return cached if (cached != null) return cached
return try { return try {
@@ -109,10 +178,14 @@ object MediaHub {
val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
drawable.setBounds(0, 0, 512, 512) drawable.setBounds(0, 0, 512, 512)
drawable.draw(Canvas(bitmap)) drawable.draw(Canvas(bitmap))
icon = bitmap val out = ByteArrayOutputStream()
bitmap bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
bitmap.recycle()
val bytes = out.toByteArray()
icon = bytes
bytes
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("SoulStream", "app icon could not be decoded: $e") android.util.Log.w("SoulStream", "app icon could not be encoded: $e")
null null
} }
} }
@@ -123,23 +196,48 @@ object MediaHub {
} }
/** /**
* The foreground service: a MediaSession the system reads for the lock * The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
* screen and the shade, a MediaStyle notification with the transport, audio * card on the lock screen and in the shade, audio focus (pause when another
* focus (pause when another app takes the output, or the headphones come * app takes the output, or the headphones come out), and the reason the
* out), and the reason the process is not frozen in the background. The * process is not frozen in the background. The audio never passes through
* audio never passes through here. * here.
*
* media3 posts and owns the notification now, and promotes and demotes the
* service around it — there is no `buildNotification` and no
* `MediaButtonReceiver` in this file any more, and the manifest's
* `androidx.media3.session.MediaSessionService` filter is what routes a
* headset button here while the process is alive.
*
* Focus and becoming-noisy are still hand-rolled, and that is not an
* oversight. `setAudioAttributes(attrs, handleAudioFocus = true)` and
* `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a custom
* `Player` the first lands in `handleSetAudioAttributes`, whose default body
* throws, and the second does not exist. Deleting these blocks would lose
* "another app takes the output → we pause" and "headphones out → we pause"
* with nothing failing anywhere and no walk able to catch it.
*/ */
class MediaService : Service() { @OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaSessionService() {
companion object { companion object {
/** Literally "playback", and load-bearing twice over: the phone
* walk's `media-card` check finds the card by this channel id, and
* `Util.ensureNotificationChannel` is a no-op when the channel
* already exists — so the channel on Kyle's phone survives the
* migration rather than being replaced. */
const val CHANNEL = "playback" const val CHANNEL = "playback"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
/** The stand-in of [holdForeground], on an id of its own — 1 is the
* card, 2 is the transfer notice (`TransferHub.NOTIFICATION_ID`;
* notification ids are per-PACKAGE and these two plugins share
* one), 20938 is media3's own shutdown notification. */
const val HOLD_NOTIFICATION_ID = 3
} }
private lateinit var session: MediaSessionCompat private var session: MediaSession? = null
private var player: SoulStreamPlayer? = null
private var focus: AudioFocusRequest? = null private var focus: AudioFocusRequest? = null
private var pausedByFocus = false private var pausedByFocus = false
private var noisyRegistered = false private var noisyRegistered = false
private var inForeground = false
private val noisy = object : BroadcastReceiver() { private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -169,21 +267,45 @@ class MediaService : Service() {
} }
} }
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
session = MediaSessionCompat(this, "SoulStream") /* ours first. media3 would create the channel itself, at
session.setCallback(object : MediaSessionCompat.Callback() { IMPORTANCE_LOW and nothing else — no lockscreenVisibility, and
override fun onPlay() { MediaHub.act("play") } setShowBadge only below API 28 — so on a fresh install the card
override fun onPause() { MediaHub.act("pause") } would be hidden behind "sensitive notifications". ensureNotification
override fun onSkipToNext() { MediaHub.act("next") } Channel leaves an existing channel alone, so getting in first is
override fun onSkipToPrevious() { MediaHub.act("prev") } the whole fix. */
override fun onStop() { MediaHub.act("stop") }
override fun onSeekTo(pos: Long) { MediaHub.act("seek", pos / 1000.0) }
})
session.isActive = true
createChannel() createChannel()
val p = SoulStreamPlayer(applicationContext)
player = p
MediaHub.player = p
/* BEFORE addSession, and the order is real: addSession spins up the
internal notification controller that reads this provider, and both
calls post to the same main handler (inline, from here) in order. */
val provider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL)
.setChannelName(R.string.ss_playback_channel)
.setNotificationId(NOTIFICATION_ID)
.build()
provider.setSmallIcon(R.drawable.ic_soulstream_note)
setMediaNotificationProvider(provider)
val builder = MediaSession.Builder(this, p).setId("SoulStream")
val launch = packageManager.getLaunchIntentForPackage(packageName)
if (launch != null) {
builder.setSessionActivity(
PendingIntent.getActivity(
this, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
val s = builder.build()
session = s
addSession(s)
/* a system broadcast reaches a non-exported receiver; Android 14 wants /* a system broadcast reaches a non-exported receiver; Android 14 wants
the flag said out loud */ the flag said out loud */
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@@ -192,29 +314,131 @@ class MediaService : Service() {
) )
noisyRegistered = true noisyRegistered = true
MediaHub.service = this MediaHub.service = this
/* The update that MADE this service is already sitting in MediaHub,
and its own post to the main looper found `service` still null —
bindService creates the service a round trip later. Without this
line the focus request waits for whatever update happens to come
next (a status change, or the thirty-second top-up), and the first
track of a cold start plays with no focus held. */
if (MediaHub.state?.playing == true) requestFocus()
} }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
/* ── the foreground contract media3 does not keep for us ──────────────
*
* Nothing in this plugin starts the service — and the SYSTEM starts it
* in the foreground anyway, on every media KEY. `MediaSessionLegacyStub`
* hands the framework the media-button PendingIntent, and because the
* receiver it resolves is a SERVICE it builds that intent with
* `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
* every key. It resolves a service because there is no BroadcastReceiver
* any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
* receiver first, finds none, and falls to the manifest's
* `androidx.media3.session.MediaSessionService` filter. So a headset
* key, a lock-screen button and anything else the framework routes to
* the session arms Android's ten-second `startForeground()` timer — the
* same timer that took a Save down with the process on 2026-09-13.
*
* The CARD's own buttons are milder: `DefaultActionFactory` sets
* `setStartAsForegroundService(!player.playWhenReady)`, but
* `PlaybackPendingIntentBuilder.build()` only honours it for
* COMMAND_PLAY_PAUSE, so the paused card's play button is a foreground
* start and every other card button is a plain `getService`. Play is the
* one press that always ends in media3 promoting anyway. The keys are
* the problem.
*
* media3 promotes only for a session it calls user-engaged:
* playWhenReady AND STATE_READY/BUFFERING, or inside the ten-minute
* tail after that stopped being true
* (`MediaNotificationManager.shouldRunInForeground`). Its own
* `stopSelfSafely()` — which promotes, demotes and stops — covers the
* intents it cannot resolve: a null session from `onGetSession` (ours
* never is), a custom action it cannot name (we declare none), and a
* stale self-start intent from a destroyed instance. Three presses
* qualify for none of it, and each would take the app down about ten
* seconds later:
*
* · a key into a process the system built FOR that key — no state,
* no WebView, nothing to promote for;
* · a key on a card paused longer than ten minutes, where the tail
* has run out and the press does not start playback (previous,
* which the app's `prev()` answers by seeking to zero);
* · any key at all with the bar cleared — which is exactly the case
* `MediaHub.clear` deliberately keeps the session alive for.
*
* So promote FIRST, the way this service always did, and let media3
* step back out on its own schedule. Two ways, and the split is what
* keeps this from fighting the library:
*
* · there is something on the bar → media3's OWN path, with media3's
* own card, so its `startedInForeground` bookkeeping stays true and
* the shade never sees a stand-in. Harmless when it is already in
* the foreground: the same notification is posted again.
* · there is not → media3 cannot be in the foreground (an empty
* timeline fails `shouldShowNotification`, which removes the
* notification and demotes), so a plain notification on an id of
* its own, posted and withdrawn in the same breath, disturbs
* nothing. The contract is satisfied the moment `startForeground()`
* lands.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
/* a headset or card button arrives as a MEDIA_BUTTON intent */ /* BEFORE super, deliberately: super dispatches the button, and it
MediaButtonReceiver.handleIntent(session, intent) may reach media3's `stopSelfSafely()`, which promotes, demotes and
/* Started with startForegroundService, so Android demands stops. Promoting after that would put a service already on its way
startForeground() promptly WHATEVER the state — a paused restore out back into the foreground. A null intent is the system
and an empty bar included — or it kills the whole app re-creating a START_STICKY service — not a foreground start, and
(ForegroundServiceDidNotStartInTimeException: on 2026-09-13 a Save it needs nothing. */
died with the process, 4 of 14 tracks in). Promote first, with the if (intent != null) holdForeground()
card there is or a plain one; refresh() may step back at once. */ return super.onStartCommand(intent, flags, startId)
if (!inForeground) promote(buildNotification(MediaHub.state ?: NowPlayingArgs()))
refresh()
return START_NOT_STICKY
} }
private fun promote(notification: Notification) { private fun holdForeground() {
if (Build.VERSION.SDK_INT >= 29) { val s = session
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) if (s != null && MediaHub.state != null) {
} else { try {
startForeground(NOTIFICATION_ID, notification) /* the boolean is startInForegroundRequired */
onUpdateNotification(s, true)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "notification update refused: $e")
}
return
} }
inForeground = true try {
if (Build.VERSION.SDK_INT >= 29) {
ServiceCompat.startForeground(
this, HOLD_NOTIFICATION_ID, holdNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(HOLD_NOTIFICATION_ID, holdNotification())
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "could not hold the foreground: $e")
}
}
/** The stand-in, up for as long as two binder calls take. Plain on
* purpose: it is never the card, it carries no transport, and the only
* thing it has to do is exist. */
private fun holdNotification(): Notification {
val launch = packageManager.getLaunchIntentForPackage(packageName)
val open = launch?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_soulstream_note)
.setContentTitle("SoulStream")
.setContentIntent(open)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
} }
override fun onDestroy() { override fun onDestroy() {
@@ -223,105 +447,33 @@ class MediaService : Service() {
noisyRegistered = false noisyRegistered = false
} }
abandonFocus() abandonFocus()
session.isActive = false /* the player first, then the session — media3's own order. `release()`
session.release() is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
main thread (onDestroy runs there); markReleased() is our own latch
so a refresh() that slipped through MediaHub does nothing. */
player?.release()
player?.markReleased()
session?.release()
session = null
player = null
MediaHub.player = null
MediaHub.service = null MediaHub.service = null
super.onDestroy() super.onDestroy()
} }
/** Mirror MediaHub.state into the session and the notification. */ /** Focus is asked for when something starts playing and kept until the
fun refresh() { * bar empties or the service dies — called from MediaHub's own post to
val np = MediaHub.state * the main looper, so it rides the same thread everything else here
if (np == null) { * does. */
if (inForeground) { fun syncFocus(playing: Boolean) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) if (playing) requestFocus()
inForeground = false
}
stopSelf()
return
}
val meta = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, np.title ?: "")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, np.artist ?: "")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, np.album ?: "")
np.duration?.let { meta.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong()) }
(MediaHub.art ?: MediaHub.appIcon(this))?.let {
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it)
}
session.setMetadata(meta.build())
var actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_STOP
if (np.hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
if (np.hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
val state = PlaybackStateCompat.Builder()
.setActions(actions)
.setState(
if (np.playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
(np.position * 1000).toLong(),
if (np.playing) 1f else 0f,
SystemClock.elapsedRealtime(),
)
.build()
session.setPlaybackState(state)
if (np.playing) requestFocus()
val notification = buildNotification(np)
if (np.playing) {
promote(notification)
} else {
/* paused: the card stays, the service steps back so the system
may reclaim it and the card can be swiped away */
if (inForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
inForeground = false
}
try {
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
/* no notification permission: the session alone still serves the lock screen */
}
}
} }
private fun buildNotification(np: NowPlayingArgs): Notification { /** The bar emptied. The old service gave focus back by dying; this one
val launch = packageManager.getLaunchIntentForPackage(packageName) * outlives the music, so it gives it back by hand. */
val open = launch?.let { fun dropFocus() {
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) pausedByFocus = false
} abandonFocus()
val prev = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)
val toggle = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)
val next = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
val stop = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP)
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_soulstream_note)
.setContentTitle(np.title ?: "SoulStream")
.setContentText(np.artist ?: "")
.setSubText(np.album)
.setLargeIcon(MediaHub.art ?: MediaHub.appIcon(this))
.setContentIntent(open)
.setDeleteIntent(stop)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setOngoing(np.playing)
.addAction(android.R.drawable.ic_media_previous, "Previous", prev)
.addAction(
if (np.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (np.playing) "Pause" else "Play",
toggle,
)
.addAction(android.R.drawable.ic_media_next, "Next", next)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(session.sessionToken)
.setShowActionsInCompactView(0, 1, 2),
)
.build()
} }
private fun createChannel() { private fun createChannel() {
@@ -29,10 +29,8 @@ class NowPlayingArgs {
var hasNext: Boolean = true var hasNext: Boolean = true
var hasPrev: Boolean = true var hasPrev: Boolean = true
/** the queue around the current item — it and up to ten either side. /** the queue around the current item — it and up to ten either side.
* NOTHING READS IT: MediaService ignores both of these on purpose, so * Since round forty-eight SoulStreamPlayer.getState() turns this into
* the card is byte for byte the card it was. media3's * the media3 playlist the card is built from, and the index into it.
* SimpleBasePlayer.getState() wants a playlist and an index into it,
* and this is that data arriving a round before the migration does.
* Nullable because a non-null Kotlin property whose setter Jackson * Nullable because a non-null Kotlin property whose setter Jackson
* hands a null throws, and the only thing between here and the JS that * hands a null throws, and the only thing between here and the JS that
* builds it is two JSON hops. */ * builds it is two JSON hops. */
@@ -40,6 +38,15 @@ class NowPlayingArgs {
/** where the current item sits in `queue` — not in the whole queue; /** where the current item sits in `queue` — not in the whole queue;
* null when the window is empty */ * null when the window is empty */
var queueIndex: Int? = null var queueIndex: Int? = null
/** where the window starts in the WHOLE queue. A stable media3 uid is
* built out of it, so an entry keeps its identity as the window slides
* past it; it is also half of the row -> playQueueAt() mapping Auto
* will want. Null when the window is empty. */
var queueBase: Int? = null
/** Repeat, in the APP's numbering: 0 off, 1 all, 2 one. media3 numbers
* them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and SoulStreamPlayer maps across
* rather than casting. */
var repeat: Int = 0
} }
/** One entry of the queue window. Flat and small: it crosses the bridge on /** One entry of the queue window. Flat and small: it crosses the bridge on
@@ -78,18 +85,23 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
@Command @Command
fun update(invoke: Invoke) { fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java) val args = invoke.parseArgs(NowPlayingArgs::class.java)
/* The window is on no screen anywhere — MediaService ignores it and /* The window is on no screen anywhere — the card shows ONE track —
the card cannot show it — so logcat is the only place on the so logcat is the only place on the device the whole of it can be
device it can be read back, and tests/mobile/smoke.mjs reads it read back, and tests/mobile/smoke.mjs reads it from exactly here.
from exactly here. One line per update, and an update is a track One line per update, and an update is a track change, a transport
change, a transport change or the thirty-second top-up, never a change or the thirty-second top-up, never a position tick.
position tick. `at=` goes LAST because a title may hold anything, `base` and `rep` are here for the same reason and a sharper one:
spaces and `=` included. */ since round forty-eight they steer the media3 playlist and what
a controller is told about repeat, they appear on no screen, and a
camelCase key spelled snake_case arrives as nothing on either hop
of this bridge without an error anywhere. `at=` goes LAST because
a title may hold anything, spaces and `=` included. */
val window = args.queue ?: emptyList() val window = args.queue ?: emptyList()
android.util.Log.i( android.util.Log.i(
"SoulStream", "SoulStream",
"media window n=" + window.size + " i=" + (args.queueIndex ?: -1) + "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +
" art=" + window.count { !it.artKey.isNullOrEmpty() } + " art=" + window.count { !it.artKey.isNullOrEmpty() } +
" base=" + (args.queueBase ?: -1) + " rep=" + args.repeat +
" at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""), " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),
) )
ensureNotificationPermission() ensureNotificationPermission()
@@ -0,0 +1,344 @@
package com.soulstream.mediasession
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import java.io.File
/**
* The `Player` media3 shows the world, over an engine it cannot see.
*
* rodio owns playback, in Rust, and Kotlin has never touched a sample — so
* there is no `ExoPlayer` here and there never will be. `SimpleBasePlayer`
* is media3's supported answer to exactly that shape: implement
* [getState] as a pure read of whatever the real engine last said, route
* every transport call into the engine, and call [invalidateState] when the
* engine answers. `MediaHub.state` is that "whatever it last said" — the
* last `NowPlayingArgs` the WebView pushed across the bridge — and
* `MediaHub.act` is the way back out to it.
*
* The opt-in is `androidx.annotation.OptIn`, imported by name because
* `kotlin.OptIn` is in every file's default scope and would otherwise win —
* and written `markerClass = [...]` because that is the attribute's real
* name in either reading of it. `@UnstableApi` is `RetentionPolicy.CLASS`
* plus androidx's own `RequiresOptIn`, which makes it a LINT error and not a
* compiler one: the debug build this project has always made never runs
* lint, `lintVitalArm64Release` would, and the annotation keeps that honest
* either way.
*/
@OptIn(markerClass = [UnstableApi::class])
class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()) {
companion object {
/** How long a transport command may wait for the WebView before the
* card is allowed to snap back to the truth. See [awaitUpdate]. */
const val ACT_TIMEOUT_MS = 2500L
}
private val main = Handler(Looper.getMainLooper())
/** Commands sent out to the WebView and not yet answered. Main thread only. */
private val pending = ArrayList<SettableFuture<Void>>()
private val expire = Runnable { settle() }
private var released = false
/* ── the state media3 reads ──────────────────────────────────────── */
override fun getState(): SimpleBasePlayer.State {
val np = MediaHub.state
?: return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
/* THE PLAYLIST MUST NEVER BE EMPTY WHILE A TRACK EXISTS.
MediaNotificationManager.shouldShowNotification returns false the
moment `controller.getCurrentTimeline().isEmpty()`, and it says
nothing anywhere when it does — the card simply never appears.
Today's frontend cannot 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 same state sends `clear`
instead. The one-item fallback is here because the failure it
guards is invisible and the guard is three lines: a frontend older
or newer than this plugin, or a `queue` that did not survive the
two JSON hops, would otherwise take the card down silently. */
val window = np.queue ?: emptyList()
var at = np.queueIndex ?: -1
val items = ArrayList<SimpleBasePlayer.MediaItemData>()
if (window.isEmpty() || at < 0 || at >= window.size) {
at = 0
items.add(item("solo", null, np.title, np.artist, np.album, np.duration, null, true))
} else {
for (i in window.indices) {
val q = window[i]
/* A STABLE uid. `queueBase` is where the window starts in the
whole queue, so an entry keeps its identity as the window
slides past it and media3 reports one media-item transition
per track change rather than re-labelling all twenty-one.
Without it (an older frontend across the bridge) fall back
to the slot, which is unique within one playlist — that is
all setPlaylist demands — at the cost of a rebuild. */
val base = np.queueBase
val uid = if (base != null) "q" + (base + i) else "w" + i + ":" + (q.id ?: "")
items.add(item(uid, q.id, q.title, q.artist, q.album, q.duration, q.artKey, i == at))
}
}
val posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(np))
/* STATE_READY even when paused. shouldShowNotification treats
STATE_IDLE as "nothing to show" unless it has been prepared and
stopped, so a paused player reporting IDLE loses its card.
There is no buffering state to report either: media.svelte.js
already folds `buffering` into `playing`. */
.setPlaybackState(Player.STATE_READY)
.setPlayWhenReady(np.playing, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
.setPlaylist(items)
.setCurrentMediaItemIndex(at)
/* The long overload, not a PositionSupplier: State's own
constructor turns it into PositionSupplier.getExtrapolating
when playWhenReady and STATE_READY, and getConstant otherwise —
which is precisely "the card ticks while playing and holds
while paused", for free and in one place. */
.setContentPositionMs(posMs)
/* The app's numbering is 0 off · 1 ALL · 2 ONE; media3's is
0 OFF · 1 ONE · 2 ALL. They are SWAPPED, and mapping them by
value would silently turn repeat-all into repeat-one. It is
mirrored at all so the player's state is not a lie to whatever
reads it — the system's own media controls now, Android Auto
later — and so media3's view of "is there a next item" agrees
with the hasNext the next button is advertised on. The BUTTON
does not hang on it: BasePlayer forwards a seek it cannot
resolve rather than dropping it (ignoreSeek -> handleSeek with
C.INDEX_UNSET), so an advertised command always arrives. */
.setRepeatMode(
when (np.repeat) {
1 -> Player.REPEAT_MODE_ALL
2 -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
},
)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
)
.build()
}
/** One row of the window as media3 wants it. `current` is the one whose
* cover rides along as bytes; the rest name a file and media3's own
* BitmapLoader reads it if anything ever asks. */
private fun item(
uid: String,
id: String?,
title: String?,
artist: String?,
album: String?,
duration: Double?,
artKey: String?,
current: Boolean,
): SimpleBasePlayer.MediaItemData {
val md = MediaMetadata.Builder()
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
.setArtist(artist ?: "")
.setAlbumTitle(album ?: "")
.setIsBrowsable(false)
.setIsPlayable(true)
if (current) {
/* The cover, or the app's own icon. `artBytes` is cleared the
moment `artKey` changes and only refilled once the frontend has
decoded one — and a track with no cover never refills it — so
without the fallback every track change has a window where the
lock screen shows a grey hole. media3 decodes these bytes
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
is one BitmapFactory call this file no longer makes. */
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (!artKey.isNullOrEmpty()) {
/* A LOCAL path only. A catalog record's art key is an http URL,
and media3's default loader would happily fetch it — twenty
times, off a queue nothing is looking at. */
val f = File(artKey)
if (f.isAbsolute) md.setArtworkUri(Uri.fromFile(f))
}
val meta = md.build()
val durUs = if (duration != null && duration > 0) (duration * 1_000_000).toLong() else C.TIME_UNSET
return SimpleBasePlayer.MediaItemData.Builder(uid)
.setMediaItem(MediaItem.Builder().setMediaId(id ?: uid).setMediaMetadata(meta).build())
.setMediaMetadata(meta)
.setIsSeekable(true)
.setIsDynamic(false)
.setDurationUs(durUs)
.build()
}
/** What the card, the lock screen and a headset are allowed to ask for.
*
* COMMAND_GET_TIMELINE and COMMAND_GET_CURRENT_MEDIA_ITEM go together
* and neither is optional. The media notification controller reads the
* playlist THROUGH the session, and PlayerInfo.filterByAvailableCommands
* cuts what it may see down to what its commands allow: one window
* without COMMAND_GET_TIMELINE, and Timeline.EMPTY without either. An
* empty timeline on the far side is shouldShowNotification refusing the
* card — the same silent failure as an empty playlist, from the other
* direction.
*
* Both spellings of next and previous, because
* DefaultMediaNotificationProvider.getMediaButtons 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 only
* the plain one and the button appears and does nothing.
*
* Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS
* and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android
* Auto's job and needs a window-index → queue-index mapping; `queueBase`
* is half of it and it lands with the round that uses it. */
private fun commands(np: NowPlayingArgs?): Player.Commands {
val b = Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
.add(Player.COMMAND_PREPARE)
.add(Player.COMMAND_STOP)
.add(Player.COMMAND_RELEASE)
.add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
.add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
.add(Player.COMMAND_GET_TIMELINE)
.add(Player.COMMAND_GET_METADATA)
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
if (np != null) {
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
b.add(Player.COMMAND_SEEK_TO_PREVIOUS)
b.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
}
return b.build()
}
/* ── the transport, back out to the WebView ──────────────────────── */
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
MediaHub.act(if (playWhenReady) "play" else "pause")
return awaitUpdate()
}
/** Nothing to prepare: the Rust side sources a track on its own, and
* `getState` never reports STATE_IDLE while there is one. Answered
* rather than left to throw, because the default body of every
* `handle*` is `throw IllegalStateException`. */
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
override fun handleStop(): ListenableFuture<*> {
MediaHub.act("stop")
return awaitUpdate()
}
override fun handleRelease(): ListenableFuture<*> {
released = true
main.removeCallbacks(expire)
val batch = ArrayList(pending)
pending.clear()
/* posted, not completed here: finishing a future re-enters media3's
own listener, and this one is already inside a player call */
main.post { for (f in batch) f.set(null) }
return Futures.immediateVoidFuture()
}
/** A no-op that must exist. `setAudioAttributes(attrs, handleAudioFocus)`
* is an ExoPlayer convenience: on SimpleBasePlayer it lands HERE, whose
* default body throws, and media3 does none of the focus work for a
* custom player — `AudioFocusManager` is inside media3-exoplayer and is
* not public API. Focus is still ours, requested and abandoned by hand
* in MediaService, exactly as it was before the migration. */
override fun handleSetAudioAttributes(
audioAttributes: AudioAttributes,
handleAudioFocus: Boolean,
): ListenableFuture<*> = Futures.immediateVoidFuture()
/** Every seek, next and previous arrives here — there is no
* `handleSeekToNext` and never was — distinguished by the command that
* triggered it. The index is ignored on purpose: the app's own `next()`
* and `prev()` know about repeat, about the five-second restart rule and
* about the end of the queue, and the window is only ever a view.
*
* It arrives even when media3 has nothing to seek TO: `BasePlayer`
* forwards such a press rather than dropping it, as
* `ignoreSeek` -> `handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)`.
* Which is why the previous button still works at the very head of a
* queue, where the timeline has no previous item but the app's own
* `prev()` seeks to zero. The gate is the COMMAND being advertised,
* and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next")
Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaHub.act("prev")
else -> MediaHub.act("seek", if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0)
}
return awaitUpdate()
}
/* ── the round trip ──────────────────────────────────────────────── */
/**
* A transport command is a round trip: out through `Plugin.trigger` to
* the WebView, into the player, down to Rust, and back as the next
* `update`. Answering media3 immediately would have it call [getState]
* on the spot — which still reports the OLD state — and the lock screen
* flips back before it flips forward.
*
* So hand it a future nobody has completed. While one is outstanding
* media3 shows its own suggested placeholder (play-pause flipped, the
* seek applied), which is exactly the optimistic answer we want, and
* [invalidateState] deliberately does nothing until the last one
* resolves. `MediaHub.update` resolves them; [expire] resolves them
* anyway after [ACT_TIMEOUT_MS], because a future that is never
* completed does not degrade the card — it FREEZES it.
*/
private fun awaitUpdate(): ListenableFuture<*> {
if (released) return Futures.immediateVoidFuture()
val f = SettableFuture.create<Void>()
pending.add(f)
main.removeCallbacks(expire)
main.postDelayed(expire, ACT_TIMEOUT_MS)
return f
}
/** The engine answered (or stopped answering). Main thread only — every
* caller is `MediaHub`'s own post to the main looper, or [expire]. */
fun settle() {
if (pending.isEmpty()) return
main.removeCallbacks(expire)
val batch = ArrayList(pending)
pending.clear()
/* completing these re-reads getState() on this same thread, inline */
for (f in batch) f.set(null)
}
/** `invalidateState` is `protected final`; this is the door MediaHub
* uses. A no-op once released, and a no-op inside media3 anyway while
* anything is still pending. */
fun refresh() {
if (released) return
invalidateState()
}
fun markReleased() {
released = true
main.removeCallbacks(expire)
pending.clear()
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The channel the card sits on. Its ID stays literally "playback":
tests/mobile/smoke.mjs:2448 finds the card by that id, and the
channel already on the phone — IMPORTANCE_LOW, lockscreen
PUBLIC, no badge — is left exactly as it is. -->
<string name="ss_playback_channel">Playback</string>
</resources>
+21 -6
View File
@@ -44,12 +44,11 @@ pub struct NowPlaying {
/// The queue around what is playing: the current item and up to ten /// The queue around what is playing: the current item and up to ten
/// either side, rebuilt on every update. /// either side, rebuilt on every update.
/// ///
/// NOTHING READS IT YET, and that is the point — `MediaService` ignores /// Round forty-eight is where this BECAME the card. `SoulStreamPlayer`
/// both of these fields, so the card is exactly the card it was. media3's /// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and
/// `SimpleBasePlayer.getState()` wants a playlist and an index into it, /// an index into one, and this window and `queue_index` are exactly that
/// and when that migration comes the data it needs should have been /// — so what the lock screen shows is built out of these fields rather
/// running on the phone for a while rather than have been written the /// than beside them.
/// same day.
/// ///
/// A CAP, not a length: a two-thousand-track queue crosses the bridge as /// A CAP, not a length: a two-thousand-track queue crosses the bridge as
/// twenty-one entries exactly like a twenty-one-track one does. /// twenty-one entries exactly like a twenty-one-track one does.
@@ -58,6 +57,22 @@ pub struct NowPlaying {
/// Where the current item sits IN `queue` — not in the whole queue. /// Where the current item sits IN `queue` — not in the whole queue.
/// `None` when the window is empty. /// `None` when the window is empty.
pub queue_index: Option<u32>, pub queue_index: Option<u32>,
/// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData`
/// uid is built from it, so an entry keeps its identity as the window
/// slides past it and a track change is ONE media-item transition
/// rather than twenty-one re-labelled rows; it is also half of the
/// mapping Android Auto needs to turn a tapped queue row back into
/// `playQueueAt()`. `None` when the window is empty.
pub queue_base: Option<u32>,
/// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3
/// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side
/// maps across rather than casting, which would turn repeat-all into
/// repeat-one. It crosses at all so the player's state is not a lie to
/// whatever reads it — the system's own media controls now, Android
/// Auto later — and so media3's view of "is there a next item" agrees
/// with the `has_next` the card's next button is advertised on.
#[serde(default)]
pub repeat: u8,
} }
fn yes() -> bool { fn yes() -> bool {
+19 -5
View File
@@ -9,11 +9,10 @@ import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js"
import { art } from "./art.svelte.js"; import { art } from "./art.svelte.js";
/* THE QUEUE WINDOW. Every update carries a bounded slice of the queue — /* THE QUEUE WINDOW. Every update carries a bounded slice of the queue —
the current item and QUEUE_WINDOW either side — because media3's the current item and QUEUE_WINDOW either side. Since round forty-eight
SimpleBasePlayer.getState() wants a playlist and an index into it, and SoulStreamPlayer.getState() builds the media3 playlist and the index
the migration to it should find this data already running rather than into it out of exactly these fields, so what the lock screen shows is
written the same day. Nothing reads it yet: MediaService ignores both made of them rather than beside them.
fields and the card is exactly the card it was.
TEN either side, twenty-one entries at most. One would cover the card's TEN either side, twenty-one entries at most. One would cover the card's
own prev and next and nothing else; a queue LIST — which is what Auto own prev and next and nothing else; a queue LIST — which is what Auto
@@ -94,6 +93,21 @@ async function push() {
const at = player.qi - lo; const at = player.qi - lo;
state.queue = win.map(windowItem); state.queue = win.map(windowItem);
state.queueIndex = at >= 0 && at < win.length ? at : null; state.queueIndex = at >= 0 && at < win.length ? at : null;
/* where the window STARTS in the whole queue. It is what gives an
entry a stable media3 uid as the window slides past it — without it
a track change re-labels all twenty-one rows and media3 reports a
phantom media-item transition — and it is half of the mapping Auto
needs to turn a tapped queue row back into playQueueAt(). camelCase,
like everything on this bridge: a snake_case key is silently ignored
on both hops, with no error anywhere. */
state.queueBase = player.qi < 0 ? null : lo;
/* 0 off, 1 all, 2 one — ours. media3 numbers them 0 OFF, 1 ONE, 2 ALL
and SoulStreamPlayer maps across rather than casting, which would
turn repeat-all into repeat-one. It 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. */
state.repeat = player.repeat;
/* the cover's bytes ride along once per record, and only once they exist */ /* the cover's bytes ride along once per record, and only once they exist */
if (key && key !== lastArtKey && url && url.startsWith("data:image")) { if (key && key !== lastArtKey && url && url.startsWith("data:image")) {
state.art = url.slice(url.indexOf(",") + 1); state.art = url.slice(url.indexOf(",") + 1);
+122 -18
View File
@@ -2470,36 +2470,88 @@ function shadeNotice() {
} }
} }
/* ── the service, the focus stack and the noisy receiver ──────────────
* Three things `dumpsys` can say about the media session that no page and
* no notification can. ALL THREE ARE ADVISORY: they print on every run and
* fail nothing. None of these dump formats has ever been read back from
* this phone, and a format surprise must not turn into a false red on a
* walk that is otherwise green.
*
* What they are for is the morning after a change to how the service is
* made. `fgs` says whether the service exists at all and whether media3
* promoted it; `focus` whether the audio-focus request this plugin still
* makes BY HAND is in the system's stack, which is the direct regression
* guard for "another app takes the output and we pause"; `noisy` whether
* the becoming-noisy receiver is REGISTERED, which is the half of
* "headphones out pauses" that a creation-path change actually breaks.
*
* Neither of the last two proves the pause itself, and nothing here can:
* ACTION_AUDIO_BECOMING_NOISY is a protected broadcast, so `am broadcast`
* from the shell uid is refused outright, and the receiver is registered
* RECEIVER_NOT_EXPORTED besides. Those two are Kyle's hands. */
function mediaPlumbing() {
const out = { fgs: "", focus: "", noisy: "" };
try {
const svc = adb("shell", "dumpsys", "activity", "services", "com.soulstream.app").toString();
const at = svc.indexOf("com.soulstream.mediasession.MediaService");
out.fgs = at < 0 ? "absent" : (/isForeground=true/.test(svc.slice(at, at + 6000)) ? "foreground" : "created");
} catch (e) { out.fgs = "ERR " + e.message; }
try {
const audio = adb("shell", "dumpsys", "audio").toString();
out.focus = /pack: com\.soulstream\.app/.test(audio) ? "held" : "not in the stack";
} catch (e) { out.focus = "ERR " + e.message; }
try {
const bc = adb("shell", "dumpsys", "activity", "broadcasts").toString();
/* each registered receiver is one `ReceiverList{...}` block with its
package on the first line and its filters under it; the windows
keep a match from wandering into the next block */
const ours = bc.split("ReceiverList{").filter((b) =>
/com\.soulstream\.app/.test(b.slice(0, 400)) && /AUDIO_BECOMING_NOISY/.test(b.slice(0, 2000)));
out.noisy = ours.length ? "registered" : "not found";
} catch (e) { out.noisy = "ERR " + e.message; }
return out;
}
/* The system's view of the app's media session: who owns it and what the /* The system's view of the app's media session: who owns it and what the
* playback state says. From `dumpsys media_session`. */ * playback state says. From `dumpsys media_session`. */
function mediaSessionState() { function mediaSessionState() {
try { try {
const dump = adb("shell", "dumpsys", "media_session").toString(); const dump = adb("shell", "dumpsys", "media_session").toString();
/* how many sessions the system holds for US. Two would mean the
old MediaSessionCompat stack and media3's are both live, which is
the one way the migration in round forty-eight could have gone
wrong without anything crashing. Counted loosely and REPORTED
rather than trusted: a zero here means the dump did not say
`package=<us>` where this looked, not that there is no session —
`media-session` is what asserts that there is one. */
const sessions = (dump.match(/package=com\.soulstream\.app\b/g) || []).length;
/* sessions are listed as blocks; find ours by package, then the first /* sessions are listed as blocks; find ours by package, then the first
playback state after it */ playback state after it */
const at = dump.search(/com\.soulstream\.app/); const at = dump.search(/com\.soulstream\.app/);
if (at < 0) return { owner: "", state: "" }; if (at < 0) return { owner: "", state: "", sessions };
const block = dump.slice(Math.max(0, at - 400), at + 3000); const block = dump.slice(Math.max(0, at - 400), at + 3000);
const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []); const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []);
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null }; return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null, sessions };
} catch { return { owner: "", state: "" }; } } catch { return { owner: "", state: "", sessions: -1 }; }
} }
/* The queue window the phone was last handed, as MediaSessionPlugin logged /* The queue window the phone was last handed, as MediaSessionPlugin logged
* it. Round forty-six put a bounded slice of the queue into every card * it. Round forty-six put a bounded slice of the queue into every card
* update — the current item and ten either side — and left MediaService * update — the current item and ten either side. Since round forty-eight
* ignoring all of it, so there is nothing on any screen to read it back * SoulStreamPlayer builds the media3 playlist out of it, but the card
* from: logcat is the only place on the device it exists, one line per * still shows ONE track, so logcat remains the only place on the device
* update, and the LAST line is the state now. `at=` is last in the line * the whole window can be read back: one line per update, and the LAST
* line is the state now. `at=` is last in the line
* because a title may hold anything, spaces and `=` included. */ * because a title may hold anything, spaces and `=` included. */
function mediaWindow() { function mediaWindow() {
try { try {
const out = adb("logcat", "-d", "-s", "SoulStream:D").toString(); const out = adb("logcat", "-d", "-s", "SoulStream:D").toString();
const lines = out.split(/\r?\n/).filter((l) => l.includes("media window ")); const lines = out.split(/\r?\n/).filter((l) => l.includes("media window "));
const m = (lines[lines.length - 1] || "").match(/media window n=(\d+) i=(-?\d+) art=(\d+) at=(.*)$/); const m = (lines[lines.length - 1] || "").match(
if (!m) return { n: -1, i: -1, art: -1, at: "", lines: lines.length }; /media window n=(\d+) i=(-?\d+) art=(\d+) base=(-?\d+) rep=(\d+) at=(.*)$/);
return { n: +m[1], i: +m[2], art: +m[3], at: m[4].trim(), lines: lines.length }; if (!m) return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "", lines: lines.length };
} catch (e) { return { n: -1, i: -1, art: -1, at: "ERR " + e.message, lines: 0 }; } return { n: +m[1], i: +m[2], art: +m[3], base: +m[4], rep: +m[5], at: m[6].trim(), lines: lines.length };
} catch (e) { return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "ERR " + e.message, lines: 0 }; }
} }
/* ── the phone, turned over: the two settings that do it ─────────────── /* ── the phone, turned over: the two settings that do it ───────────────
@@ -2933,7 +2985,14 @@ async function main() {
later (a Save died with it). Wait past that window BEFORE later (a Save died with it). Wait past that window BEFORE
resuming, and the pid must be the same one afterwards. */ resuming, and the pid must be the same one afterwards. */
adb("logcat", "-c"); adb("logcat", "-c");
await sleep(13000); /* TWENTY-FIVE seconds. Android kills an app about ten seconds
after a foreground start that never called startForeground(),
and since round forty-eight nothing here starts the service at
all — it is bound, and media3 promotes it on its own schedule —
so the margin is over the window rather than beside it. The
doc claimed 25 s in one place and thirteen in another; both
say 25 now. */
await sleep(25000);
underrunsBefore = audioUnderruns(); underrunsBefore = audioUnderruns();
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim(); const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length; const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
@@ -3010,6 +3069,42 @@ async function main() {
if (!cardOk) problems.push("EXPECT media-card " + JSON.stringify(cardSeen)); if (!cardOk) problems.push("EXPECT media-card " + JSON.stringify(cardSeen));
console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen)); console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen));
/* ── ONE card, and one session ────────────────────────────
The check that proves round forty-eight landed rather than
doubled. The card moved from a hand-built MediaStyle
notification over a MediaSessionCompat to media3, which posts
and owns the notification itself — and the way THAT goes wrong
is not a crash and not a missing card but two of them: two
notifications on the `playback` channel, two records in
`dumpsys media_session`, a stale duplicate in the shade and
media buttons arriving at whichever component the system
picked. Nothing above this line can tell one card from two:
`media-card` passes on the first `playback` record it finds.
`shadeNotice()` already returns every channel our package has
live, joined with `|`, so counting the `playback` entries is
the whole test — and it reads the dump `media-card` just
read, so it costs nothing and cannot disagree with the line
above it. */
const cards = cardNote.shadeChans.split("|").filter((c) => c === "playback").length;
const oneCard = cards === 1;
if (!oneCard) problems.push("EXPECT one-card " + JSON.stringify({ cards, chans: cardNote.shadeChans }));
console.log((oneCard ? " ok " : " FAIL ") + "one-card".padEnd(16) + " " + JSON.stringify({ cards }));
/* The same question of the system's own session list, and it
FAILS ONLY ON MORE THAN ONE. `dumpsys media_session`'s shape
is the one thing in this block that has never been held
against a real dump, so a count of zero means "I could not
read it", not "there is no session" — `media-session`, two
checks up, is what asserts that there IS one. */
const oneSession = !(sess.sessions > 1);
if (!oneSession) problems.push("EXPECT one-session " + JSON.stringify(sess));
console.log((oneSession ? " ok " : " FAIL ") + "one-session".padEnd(16) + " " + JSON.stringify({ sessions: sess.sessions }));
/* and the plumbing underneath it, read while something is
playing, printed and never failed — see mediaPlumbing() */
console.log(" -- " + "media-plumbing".padEnd(16) + " " + JSON.stringify(mediaPlumbing()) + " (advisory: prints, never fails)");
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE"); adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
await sleep(1500); await sleep(1500);
const keyPaused = await cdp.evaluate(PLAYER); const keyPaused = await cdp.evaluate(PLAYER);
@@ -3024,11 +3119,10 @@ async function main() {
Round forty-six put a bounded window of the queue into every Round forty-six put a bounded window of the queue into every
card update — the current item and ten either side, each with card update — the current item and ten either side, each with
its id, title, artist, album, duration and art key, plus where its id, title, artist, album, duration and art key, plus where
the current one sits among them — and left MediaService the current one sits among them; round forty-eight made it the
ignoring every bit of it, because media3's media3 playlist the card is built from. Every check above this
SimpleBasePlayer.getState() will want a playlist and an index, one is still blind to the window itself, because the card shows
and that data should be a round old before anything depends on one track and `dumpsys` can only be asked about that one: a
it. Which means every check above this one is blind to it: a
window that arrived empty, an index off by one, or a field the window that arrived empty, an index off by one, or a field the
bridge dropped because a key was spelled the wrong way would bridge dropped because a key was spelled the wrong way would
each look exactly like success. each look exactly like success.
@@ -3099,7 +3193,17 @@ async function main() {
&& wins[0][1].qi === base && wins[1][1].qi === base + 1 && wins[2][1].qi === base && wins[0][1].qi === base && wins[1][1].qi === base + 1 && wins[2][1].qi === base
&& wins[0][2].i === base && wins[1][2].i === base + 1 && wins[2][2].i === base && wins[0][2].i === base && wins[1][2].i === base + 1 && wins[2][2].i === base
&& wins[1][1].u > wins[0][1].u && wins[2][1].u > wins[1][1].u && wins[1][1].u > wins[0][1].u && wins[2][1].u > wins[1][1].u
&& wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines; && wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines
/* and the two fields round forty-eight added, which nothing
else on the device can be asked about. The block arms at
index one, so the window starts at zero and `base` must BE
zero at all three readings; `rep` must be the repeat mode
the page reports, unchanged across the block. Either one
spelled snake_case on either hop arrives as nothing and
lands here as -1: `repeat` dropped has the card report
repeat-off forever, and `queueBase` dropped makes every
track change look like twenty-one new rows to media3. */
&& wins.every(([, , got]) => got.base === 0 && got.rep === armed.rep);
if (!winOk) problems.push("EXPECT queue-window " + JSON.stringify({ armed, base, wins })); if (!winOk) problems.push("EXPECT queue-window " + JSON.stringify({ armed, base, wins }));
console.log((winOk ? " ok " : " FAIL ") + "queue-window".padEnd(16) + " " console.log((winOk ? " ok " : " FAIL ") + "queue-window".padEnd(16) + " "
+ JSON.stringify({ armed, seen: wins.map(([k, sent, got]) => [k, sent && sent.qi, got.n, got.i, got.art, got.at]) })); + JSON.stringify({ armed, seen: wins.map(([k, sent, got]) => [k, sent && sent.qi, got.n, got.i, got.art, got.at]) }));