Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
143 lines
9.5 KiB
Plaintext
143 lines
9.5 KiB
Plaintext
Nothing in this plugin starts the service. It is **bound**, and that is half
|
|
the safety story. `MediaSessionService.onStartCommand` promotes the service for a media
|
|
action, a custom action, or media3's own self-start intent — which it
|
|
recognises by a uid extra of its own making — and returns `START_STICKY`
|
|
without calling `startForeground()` for anything else; a plain
|
|
`Intent(ctx, MediaService::class.java)` is anything else. So the old
|
|
`startForegroundService` would now arm Android's ten-second timer and satisfy
|
|
nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the
|
|
whole app about ten seconds after a foreground start that never called
|
|
`startForeground()`, and on that day the first real Save died with the
|
|
process, four tracks in. media3 promotes ITSELF, once there is something to
|
|
promote for — `MediaNotificationManager.startForeground()` does the
|
|
`startForegroundService` and the `setForegroundServiceNotification` in one
|
|
call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)`
|
|
with the action `androidx.media3.session.MediaSessionService`, which creates
|
|
the service, is not subject to the Android 8+/12+ background-start
|
|
restriction, and arms no timer at all. The check is unchanged and one step
|
|
longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives
|
|
and `logcat` has no `ForegroundServiceDidNotStartInTimeException`.
|
|
|
|
The other half is `MediaService.onStartCommand`, because the **system** starts
|
|
this service in the foreground whether media3 then wants it there or not.
|
|
media3 hands the framework a media-button `PendingIntent` built with
|
|
`PendingIntent.getForegroundService` — unconditionally, for every key.
|
|
`MediaSessionLegacyStub` builds it that way because the receiver it resolves
|
|
is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON`
|
|
receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and
|
|
falls to the `androidx.media3.session.MediaSessionService` filter in the
|
|
manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets
|
|
`setStartAsForegroundService(!player.playWhenReady)`, but
|
|
`PlaybackPendingIntentBuilder.build()` only honours it for
|
|
`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting
|
|
anyway; every other card button is a plain `getService`. The KEYS are the
|
|
problem. A key press arms the ten-second timer, and media3 promotes only for a
|
|
session it calls *user-engaged*: `playWhenReady` and
|
|
`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that
|
|
stopped being true. Its own `stopSelfSafely()` covers only the intents media3
|
|
cannot resolve — a null session from `onGetSession` (ours never is), a custom
|
|
action it cannot name (we declare none), a stale self-start intent from a
|
|
destroyed instance. Three presses qualify for none of it and would each take
|
|
the app down about ten seconds later: a key into a process the system built
|
|
*for* that key; a key on a card paused longer than ten minutes, once the tail
|
|
has run out and the press does not start playback; and any key at all with the
|
|
bar cleared, which is precisely the case `MediaHub.clear` keeps the session
|
|
alive for. So
|
|
`onStartCommand` promotes first, before `super` — with media3's own card
|
|
through media3's own `onUpdateNotification(session, true)` when there is
|
|
something on the bar, so its `startedInForeground` bookkeeping stays true and
|
|
the shade never sees a stand-in, and otherwise with a plain notification on an
|
|
id of its own (3: 1 is the card, 2 is the transfer notice, and notification
|
|
ids are per-package), posted and withdrawn in the same breath. `Service`'s own
|
|
`startForeground` and `stopForeground` are **final**, so none of this can be
|
|
done by watching them; the split above is what keeps it from fighting the
|
|
library, because media3 cannot be in the foreground with nothing on the bar —
|
|
an empty timeline fails `shouldShowNotification`, which removes the
|
|
notification and demotes.
|
|
|
|
`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`
|
|
hands its `android/` Gradle module to tauri-build (which writes it into
|
|
`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class
|
|
and exposes `update` / `clear`, and the Kotlin side is three files — the
|
|
plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
|
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
|
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
|
|
needs a media3 session to browse, and the rounds after this one build on the
|
|
`Player` this one creates. `MediaService` is a `MediaSessionService` holding
|
|
one `MediaSession`, and `SoulStreamPlayer` is a `SimpleBasePlayer` — media3's
|
|
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
|
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
|
|
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
|
last `NowPlayingArgs` off the bridge, queue window included) and every
|
|
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
|
`src/mobile/media.svelte.js`, which did not change.
|
|
|
|
Four things in that file are load-bearing and silent when they break. The
|
|
playlist **must never be empty while a track exists**:
|
|
`MediaNotificationManager.shouldShowNotification` refuses an empty timeline
|
|
and the card then never appears, with nothing in any log — so `getState()`
|
|
falls back to a one-item playlist rather than trusting the window. Today's
|
|
frontend cannot actually send an empty window with a track on it
|
|
(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and
|
|
`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is
|
|
there because a frontend out of step with this plugin, or a `queue` that did
|
|
not survive the two JSON hops, would take the card down silently.
|
|
`COMMAND_GET_TIMELINE` and
|
|
`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised:
|
|
`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see
|
|
down to what its commands allow — one window without the first,
|
|
`Timeline.EMPTY` without either — and an empty timeline on the far side is the
|
|
same refusal from the other end. A paused player must report `STATE_READY`,
|
|
not `STATE_IDLE`, or the card
|
|
goes when the music does. And both spellings of next and previous must be
|
|
advertised, because `DefaultMediaNotificationProvider` SHOWS the button on
|
|
`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then
|
|
sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one
|
|
alone and the button appears and does nothing.
|
|
|
|
Two things media3 does NOT do for a custom player, whatever its own API
|
|
suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an
|
|
*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in
|
|
`handleSetAudioAttributes`, whose default body throws, and the javadoc's own
|
|
wording is "true if **the player** should handle audio focus".
|
|
`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all.
|
|
So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay
|
|
hand-rolled in `MediaService`, exactly as they were. Deleting them would lose
|
|
"another app takes the output → we pause" and "headphones out → we pause",
|
|
and no walk on this phone can catch either.
|
|
|
|
The transport handlers do not answer media3 immediately. A play, a pause or a
|
|
seek is a round trip out to the WebView and back, and an instant answer has
|
|
media3 call `getState()` on the spot — which still reports the OLD state, so
|
|
the lock screen flips back before it flips forward. Each returns an
|
|
unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`;
|
|
while one is outstanding media3 shows its own optimistic placeholder, which
|
|
is the right answer anyway. A main-handler timeout resolves them after 2.5 s
|
|
regardless, because `invalidateState()` deliberately does nothing while
|
|
anything is pending: a future that is never completed does not degrade the
|
|
card, it freezes it.
|
|
|
|
Two behaviours changed with the migration, both defensible, both of them
|
|
looking like bugs if nobody wrote them down. After a pause the service stays
|
|
in the foreground for up to ten minutes (media3's
|
|
`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back
|
|
at once. And `clear` leaves the service alive with an idle session registered
|
|
rather than stopping it, so a headset button still reaches us with nothing on
|
|
the bar; the audio focus the old service gave back by dying is now abandoned
|
|
by hand in `MediaHub.clear`.
|
|
|
|
What did NOT change, though it is the first thing a reading of the API
|
|
suggests: previous and next still work everywhere the old card did, including
|
|
at the very top of a queue. The card's buttons are delivered as key events —
|
|
`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and
|
|
`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which
|
|
`BasePlayer` falls back to seeking the current item when there is no previous
|
|
one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive:
|
|
`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as
|
|
`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it,
|
|
and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing
|
|
else. So the app's own `prev()` — five-second restart rule and all — runs on
|
|
every press. The one button that really can go missing is next on the last row
|
|
with repeat off, because `hasNext` is false there and the command is not
|
|
advertised at all, which is correct.
|