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.
