diff --git a/ANDROID.md b/ANDROID.md index 2ebd768..4eab01f 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -21,7 +21,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README). | `src/mobile/xfernote.svelte.js` | the shade's download notice: mirrors what is still in flight into the transfers plugin (throttled, one post per 700 ms of changed content) and carries out its Stop all and Open | | `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events | | `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere | -| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows | +| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin `MediaSessionService` (`MediaService`) and a `SimpleBasePlayer` over the Rust engine (`SoulStreamPlayer`), on androidx.media3. media3 posts and owns the card; audio focus and the becoming-noisy pause are still ours. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows | | `crates/tauri-plugin-transfer-notice/` | a second Tauri plugin with a second Kotlin foreground service (`TransferService`, `dataSync`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing | | `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) | | `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) | @@ -761,30 +761,148 @@ screen — off Windows they report None and 0. ## Background playback and the lock-screen card -The service is started with `startForegroundService`, so it promotes itself -FIRST in `onStartCommand` — with the card there is, or a plain one — and -only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when -the track is paused or stopping when the bar is empty. Android 14 kills the -whole app about ten seconds after a foreground start that never called -`startForeground()`, and every restart restores a PAUSED track, which is -exactly that case: on 2026-09-13 the first real Save died with the process -that way, four tracks in. The check: force-stop, relaunch with a paused -track, wait 25 s, the pid survives. - -`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs` -hands its `android/` Gradle module to tauri-build (which writes it into -`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin -class and exposes `update` / `clear`, and the Kotlin side is two files — -the plugin (arguments in, the `action` event out) and `MediaService`, a -`mediaPlayback` foreground service holding a `MediaSessionCompat` and a -MediaStyle notification. Playing → `startForeground`; paused → the service -steps back and the card stays, dismissable. Android 13+ asks for -`POST_NOTIFICATIONS` the first time something plays. Audio focus is -requested while playing (loss pauses, a transient loss pauses and resumes), -and headphones coming out pause. The app's capability grants -`media-session:default`; the plugin is registered on every platform so the -permission resolves everywhere, and on the desktop its commands answer -"not here" (the desktop has `smtc.rs`). +Nothing in this plugin starts the service. It is **bound**, and that is half +the safety story. `MediaSessionService.onStartCommand` promotes the service for a media +action, a custom action, or media3's own self-start intent — which it +recognises by a uid extra of its own making — and returns `START_STICKY` +without calling `startForeground()` for anything else; a plain +`Intent(ctx, MediaService::class.java)` is anything else. So the old +`startForegroundService` would now arm Android's ten-second timer and satisfy +nothing, which is the 2026-09-13 bug wearing a new coat: Android 14 kills the +whole app about ten seconds after a foreground start that never called +`startForeground()`, and on that day the first real Save died with the +process, four tracks in. media3 promotes ITSELF, once there is something to +promote for — `MediaNotificationManager.startForeground()` does the +`startForegroundService` and the `setForegroundServiceNotification` in one +call — and `MediaHub.update` only calls `bindService(…, BIND_AUTO_CREATE)` +with the action `androidx.media3.session.MediaSessionService`, which creates +the service, is not subject to the Android 8+/12+ background-start +restriction, and arms no timer at all. The check is unchanged and one step +longer: force-stop, relaunch with a paused track, wait 25 s, the pid survives +and `logcat` has no `ForegroundServiceDidNotStartInTimeException`. + +The other half is `MediaService.onStartCommand`, because the **system** starts +this service in the foreground whether media3 then wants it there or not. +media3 hands the framework a media-button `PendingIntent` built with +`PendingIntent.getForegroundService` — unconditionally, for every key. +`MediaSessionLegacyStub` builds it that way because the receiver it resolves +is a *service*: it asks the PackageManager for an `ACTION_MEDIA_BUTTON` +receiver, finds none now that androidx's `MediaButtonReceiver` is gone, and +falls to the `androidx.media3.session.MediaSessionService` filter in the +manifest. The CARD's own buttons are milder — `DefaultActionFactory` sets +`setStartAsForegroundService(!player.playWhenReady)`, but +`PlaybackPendingIntentBuilder.build()` only honours it for +`COMMAND_PLAY_PAUSE`, and play is the one press that ends in media3 promoting +anyway; every other card button is a plain `getService`. The KEYS are the +problem. A key press arms the ten-second timer, and media3 promotes only for a +session it calls *user-engaged*: `playWhenReady` and +`STATE_READY`/`STATE_BUFFERING`, or inside the ten-minute tail after that +stopped being true. Its own `stopSelfSafely()` covers only the intents media3 +cannot resolve — a null session from `onGetSession` (ours never is), a custom +action it cannot name (we declare none), a stale self-start intent from a +destroyed instance. Three presses qualify for none of it and would each take +the app down about ten seconds later: a key into a process the system built +*for* that key; a key on a card paused longer than ten minutes, once the tail +has run out and the press does not start playback; and any key at all with the +bar cleared, which is precisely the case `MediaHub.clear` keeps the session +alive for. So +`onStartCommand` promotes first, before `super` — with media3's own card +through media3's own `onUpdateNotification(session, true)` when there is +something on the bar, so its `startedInForeground` bookkeeping stays true and +the shade never sees a stand-in, and otherwise with a plain notification on an +id of its own (3: 1 is the card, 2 is the transfer notice, and notification +ids are per-package), posted and withdrawn in the same breath. `Service`'s own +`startForeground` and `stopForeground` are **final**, so none of this can be +done by watching them; the split above is what keeps it from fighting the +library, because media3 cannot be in the foreground with nothing on the bar — +an empty timeline fails `shouldShowNotification`, which removes the +notification and demotes. + +`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs` +hands its `android/` Gradle module to tauri-build (which writes it into +`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin class +and exposes `update` / `clear`, and the Kotlin side is three files — the +plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and +`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not +`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto +needs a media3 session to browse, and the rounds after this one build on the +`Player` this one creates. `MediaService` is a `MediaSessionService` holding +one `MediaSession`, and `SoulStreamPlayer` is a `SimpleBasePlayer` — media3's +supported way to put a non-ExoPlayer engine behind a session, which is exactly +this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and +forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the +last `NowPlayingArgs` off the bridge, queue window included) and every +transport call routes back out through `MediaHub.act` → `Plugin.trigger` → +`src/mobile/media.svelte.js`, which did not change. + +Four things in that file are load-bearing and silent when they break. The +playlist **must never be empty while a track exists**: +`MediaNotificationManager.shouldShowNotification` refuses an empty timeline +and the card then never appears, with nothing in any log — so `getState()` +falls back to a one-item playlist rather than trusting the window. Today's +frontend cannot actually send an empty window with a track on it +(`media.svelte.js` builds `win = []` only when `player.qi < 0`, and +`player.track` is `queue[qi]`, so that state sends `clear`); the fallback is +there because a frontend out of step with this plugin, or a `queue` that did +not survive the two JSON hops, would take the card down silently. +`COMMAND_GET_TIMELINE` and +`COMMAND_GET_CURRENT_MEDIA_ITEM` must both be advertised: +`PlayerInfo.filterByAvailableCommands` cuts the timeline a controller may see +down to what its commands allow — one window without the first, +`Timeline.EMPTY` without either — and an empty timeline on the far side is the +same refusal from the other end. A paused player must report `STATE_READY`, +not `STATE_IDLE`, or the card +goes when the music does. And both spellings of next and previous must be +advertised, because `DefaultMediaNotificationProvider` SHOWS the button on +`containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)` and then +sets the button's command to the `_MEDIA_ITEM` one — advertise the plain one +alone and the button appears and does nothing. + +Two things media3 does NOT do for a custom player, whatever its own API +suggests. `setAudioAttributes(attrs, handleAudioFocus = true)` is an +*ExoPlayer* convenience: on `SimpleBasePlayer` it lands in +`handleSetAudioAttributes`, whose default body throws, and the javadoc's own +wording is "true if **the player** should handle audio focus". +`setHandleAudioBecomingNoisy` is ExoPlayer-only and has no counterpart at all. +So the audio-focus request and the `AUDIO_BECOMING_NOISY` receiver stay +hand-rolled in `MediaService`, exactly as they were. Deleting them would lose +"another app takes the output → we pause" and "headphones out → we pause", +and no walk on this phone can catch either. + +The transport handlers do not answer media3 immediately. A play, a pause or a +seek is a round trip out to the WebView and back, and an instant answer has +media3 call `getState()` on the spot — which still reports the OLD state, so +the lock screen flips back before it flips forward. Each returns an +unfinished `SettableFuture` instead, resolved by the next `MediaHub.update`; +while one is outstanding media3 shows its own optimistic placeholder, which +is the right answer anyway. A main-handler timeout resolves them after 2.5 s +regardless, because `invalidateState()` deliberately does nothing while +anything is pending: a future that is never completed does not degrade the +card, it freezes it. + +Two behaviours changed with the migration, both defensible, both of them +looking like bugs if nobody wrote them down. After a pause the service stays +in the foreground for up to ten minutes (media3's +`DEFAULT_FOREGROUND_SERVICE_TIMEOUT_MS`, 600 000 ms) instead of stepping back +at once. And `clear` leaves the service alive with an idle session registered +rather than stopping it, so a headset button still reaches us with nothing on +the bar; the audio focus the old service gave back by dying is now abandoned +by hand in `MediaHub.clear`. + +What did NOT change, though it is the first thing a reading of the API +suggests: previous and next still work everywhere the old card did, including +at the very top of a queue. The card's buttons are delivered as key events — +`DefaultActionFactory` builds an `ACTION_MEDIA_BUTTON` intent, and +`MediaSessionImpl` maps `KEYCODE_MEDIA_PREVIOUS` to `seekToPrevious()`, which +`BasePlayer` falls back to seeking the current item when there is no previous +one, reaching `handleSeek`. And even the `_MEDIA_ITEM` spelling would arrive: +`BasePlayer.ignoreSeek` forwards a seek it cannot resolve as +`handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)` rather than dropping it, +and `SimpleBasePlayer` gates that on the COMMAND being advertised and nothing +else. So the app's own `prev()` — five-second restart rule and all — runs on +every press. The one button that really can go missing is next on the last row +with repeat off, because `hasNext` is false there and the command is not +advertised at all, which is correct. The glyph in the status bar is the falcon, not a note. `ic_soulstream_note.xml` is the logo kit's monochrome path verbatim @@ -802,14 +920,14 @@ actually breaks. `media-card` proves it: after `resumed`, the card is still posted, on the `playback` channel, carrying the playing track's own title, and `media-keys` a line below still gets the transport back. -The card's large icon falls back to the app's own icon. `MediaHub.art` is -cleared the moment `artKey` changes and only refilled once the frontend has -decoded a cover into bytes, and a track with no cover never refills it at -all — so without a fallback every track change has a window where the lock -screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and -keeps it (`refresh()` runs on every position tick, so a per-update decode -would be a real cost), through the PackageManager rather than a resource id -because a library module's own R has no `ic_launcher`. It is drawn at +The card's artwork falls back to the app's own icon. `MediaHub.artBytes` +is cleared the moment `artKey` changes and only refilled once the frontend +has decoded a cover into bytes, and a track with no cover never refills it +at all — so without a fallback every track change has a window where the +lock screen shows a grey hole. `MediaHub.appIcon()` encodes the icon ONCE +and keeps the PNG bytes (`getState()` runs on every invalidation, so a +per-update encode would be a real cost), through the PackageManager rather +than a resource id because a library module's own R has no `ic_launcher`. It is drawn at `setBounds(0, 0, 512, 512)`, and that is less obvious than it looks: an `AdaptiveIconDrawable`'s bounds are the **72** units the launcher shows, not the 108 its layers are drawn in — it scales its own layers up by 3/2 and @@ -818,8 +936,9 @@ is two thirds of the layer PNG. So the plain call already IS the launcher's view, and a box set outside the tile to "reveal the 18% bleed" does the opposite: it crops into the safe zone. The kit's foreground inks y 20.5→86.5 of 108, so a box 128 units oversize on each side would have shown 30→78 — -the head and the tail cut off. The same bitmap serves -`METADATA_KEY_ALBUM_ART`. +the head and the tail cut off. The same bytes go across as +`MediaMetadata.artworkData`, which media3 decodes itself — one +`BitmapFactory` call this plugin no longer makes. `android:appCategory="audio"` is contributed by the media-session plugin's own library manifest. The app manifest lives in gitignored `gen/` and cannot @@ -857,16 +976,25 @@ with a `ClassNotFoundException`. `src-tauri/android/build.sh` exports it; after adding a plugin, touch `src-tauri/build.rs` too, so the app's build script re-runs and rewrites the wiring. -Every update also carries a **queue window** that nothing reads. `NowPlaying` -(Rust) and `NowPlayingArgs` (Kotlin) hold a `queue` of at most twenty-one -entries — the current item and ten either side, each with its id, title, -artist, album, duration and art key — plus `queueIndex`, where the current one -sits *within that window* rather than within the whole queue. -`src/mobile/media.svelte.js` fills both from `player.queue` and `player.qi`; -`MediaService` ignores them completely, so the card is byte for byte the card -it was. The reason is media3: `SimpleBasePlayer.getState()` wants a playlist -and an index into it, and the data that migration needs should have been -running on the phone for a while before anything depends on it. Ten either +Every update also carries a **queue window**, and since round forty-eight the +card is built out of it. `NowPlaying` (Rust) and `NowPlayingArgs` (Kotlin) +hold a `queue` of at most twenty-one entries — the current item and ten either +side, each with its id, title, artist, album, duration and art key — plus +`queueIndex`, where the current one sits *within that window* rather than +within the whole queue, `queueBase`, where the window starts in the whole +queue, and `repeat`. `src/mobile/media.svelte.js` fills all of them from +`player.queue`, `player.qi` and `player.repeat`, and +`SoulStreamPlayer.getState()` turns them into the media3 playlist, the current +index and the repeat mode. `queueBase` is what gives an entry a stable +`MediaItemData` uid as the window slides past it, so a track change is one +media-item transition rather than twenty-one re-labelled rows — and it is half +of the mapping Android Auto will need to turn a tapped queue row back into a +`playQueueAt()`. `repeat` crosses so the player's state is not a lie to +whatever reads it — the system's own media controls now, Auto later — and so +media3's view of "is there a next item" agrees with the `hasNext` the next +button is advertised on. Its numberings are SWAPPED: the app 0 off · 1 ALL · +2 ONE, media3 0 OFF · 1 ONE · 2 ALL, so `SoulStreamPlayer` maps across rather +than casting, which would have turned repeat-all into repeat-one. Ten either side because one covers the card's own prev and next and nothing else, while a queue *list* — which is what Auto asks for — wants a screenful plus a scroll in both directions; and it is a cap, not a length, so a two-thousand-track @@ -878,14 +1006,18 @@ and an update is a track change, a transport change or the thirty-second top-up, never a position tick. Because it is on no screen, the walk reads it out of `logcat`: -`MediaSessionPlugin.update` logs one `media window n=… i=… art=… at=…` line -per update under the tag `SoulStream`, and the phone walk's `queue-window` -check holds that against `__ss.card` — the mirror `media.svelte.js` keeps of -what it last sent, beside xfernote's — field for field, through a next and a -previous. Field for field is deliberate: a key spelled snake_case where the -bridge wants camelCase arrives as nothing at all on both hops, with no error -anywhere, which is why the art-key count is compared and not just the size and -the index. +`MediaSessionPlugin.update` logs one +`media window n=… i=… art=… base=… rep=… at=…` line per update under the tag +`SoulStream`, and the phone walk's `queue-window` check holds that against +`__ss.card` — the mirror `media.svelte.js` keeps of what it last sent, beside +xfernote's — field for field, through a next and a previous. Field for field +is deliberate: a key spelled snake_case where the bridge wants camelCase +arrives as nothing at all on both hops, with no error anywhere, which is why +the art-key count is compared and not just the size and the index. `base` and +`rep` are on that line for a sharper version of the same reason: they steer +the media3 playlist and the card's next button, they are on no screen and no +card, and this is the only place on the device either of them can be read +back at all. Three things in that check are load-bearing and easy to undo by accident. It arms at queue index **one** rather than wherever the walk left off, which buys @@ -901,6 +1033,33 @@ FAILS rather than skips when it cannot be armed — a queue under three rows, or repeat-one, under which `next()` seeks instead of stepping — with the reason in the message. +### What the walk proves about the card, and what it does not + +**Proven, on the phone, by `node tests/mobile/smoke.mjs --phone`:** exactly +one card, on the `playback` channel, carrying the playing track's own title +(`media-card`, `one-card`); the system holding one media session for the app, +PLAYING (`media-session`, `one-session`); media keys reaching the player +through that session with the app in front (`media-keys`); ten seconds in the +background with the position still advancing (`background`); the paused +restore waited out past 25 s with the pid intact and no +`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the +queue window arriving across the bridge, field for field (`queue-window`). +Printed but never failed, because their `dumpsys` formats have never been read +back from this phone: whether the service is foreground, whether our audio +focus is in the system's stack, and whether the becoming-noisy receiver is +registered (`media-plumbing`). + +**Kyle's hands only. Nothing here can be automated, and the walk going green +says nothing about any of it.** Unplugging headphones pauses the music — +`ACTION_AUDIO_BECOMING_NOISY` is a protected broadcast, refused to the shell +uid, and our receiver is `RECEIVER_NOT_EXPORTED` besides. Another app taking +audio focus pauses this one, and giving it back resumes it. The card's LOOK: +the cover, and the falcon in the status bar — `dumpsys notification` prints a +resource id and never a pixel, so a correct glyph and a white rectangle read +identically. And a Bluetooth headset's own buttons, which take a path +`input keyevent` does not. These four are the first things to try after the +first deploy of anything that touches this plugin. + ## What a screen reader says Every control on the phone is named by what it does, in the app's own @@ -1978,13 +2137,19 @@ read over real IPC; a cold artist page, its ⋯, the whole-discography page and its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend strings through `__ss.say`; and a refused transfer's sentence. -Then, after the step loop, eight things the mock cannot do at all: a -force-stop and the paused restore that follows it (waited out past the -thirteen-second FGS-kill window), the position restored, playback resumed, the -system media session and its ownership, media keys, ten seconds in the -background with the position still advancing, SoulStream's own mixer -underruns, and the queue window that rides along with every card update and -that nothing on the card reads. +Then, after the step loop, ten things the mock cannot do at all: a force-stop +and the paused restore that follows it (waited out past twenty-five seconds, +which is well past the ten-second FGS-kill window), the position restored, +playback resumed, the system media session and its ownership, media keys, +exactly one card in the shade and exactly one session in the system's list — +the two checks that prove the media3 migration landed rather than doubled — +ten seconds in the background with the position still advancing, SoulStream's +own mixer underruns, and the queue window that rides along with every card +update and that the lock-screen card is now built out of. One more line +prints and never fails: whether the service is foreground, whether our audio +focus is in the system's stack, and whether the becoming-noisy receiver is +registered. What none of it touches is in "What the walk proves about the +card, and what it does not", above. One block in the phone walk pulls bytes, and only when the app is signed in: the trailing network block types `--query` into Search, streams a peer's copy @@ -2112,3 +2277,15 @@ symptom. and the hands-only list below, the pickers and the notification buttons and the share sheet, which no walk reaches. Work down the smoke list in "The release build" before trusting a release APK on a phone. + +- **Android Auto, half-built.** Round forty-eight put the card on + `androidx.media3` — a `MediaSessionService` over a `SimpleBasePlayer` — + which is the base a browsable service needs and the reason that round + happened at all. What is missing is the browse tree: `MediaService` becomes + a `MediaLibraryService`, `onGetLibraryRoot` and `onGetChildren` answer with + the library's own shape, an `automotive_app_desc.xml` and its + `` 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. diff --git a/crates/tauri-plugin-media-session/android/build.gradle.kts b/crates/tauri-plugin-media-session/android/build.gradle.kts index f2efe19..1857eb7 100644 --- a/crates/tauri-plugin-media-session/android/build.gradle.kts +++ b/crates/tauri-plugin-media-session/android/build.gradle.kts @@ -26,7 +26,34 @@ android { dependencies { implementation("androidx.core:core-ktx:1.13.1") - // MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver - implementation("androidx.media:media:1.7.0") + // The session, the card, and a notification media3 posts and owns itself. + // + // 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")) } diff --git a/crates/tauri-plugin-media-session/android/consumer-rules.pro b/crates/tauri-plugin-media-session/android/consumer-rules.pro index 555c7b3..88dcd7e 100644 --- a/crates/tauri-plugin-media-session/android/consumer-rules.pro +++ b/crates/tauri-plugin-media-session/android/consumer-rules.pro @@ -16,9 +16,12 @@ # through Plugin.registerListener — declared on tauri's base class, which no # rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro. # -# MediaService and androidx's MediaButtonReceiver are declared in this -# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold -# them by name. Not repeated here: one place decides each of them. +# MediaService is declared in this module's AndroidManifest.xml, so AGP's +# own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs +# 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.NowPlayingArgs { *; } -keep class com.soulstream.mediasession.QueueItemArgs { *; } diff --git a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml index 1d45384..9750fe6 100644 --- a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml +++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml @@ -14,20 +14,20 @@ - + - + - - - - - - diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index 2edf38b..7189dbb 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -4,85 +4,154 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.app.Service import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.ServiceConnection import android.content.pm.ServiceInfo import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Canvas import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build +import android.os.Handler import android.os.IBinder -import android.os.SystemClock -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat +import android.os.Looper import android.util.Base64 +import androidx.annotation.OptIn import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat 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 - * 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. + * + * 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 { @Volatile var onAction: ((String, Double) -> Unit)? = 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 service: MediaService? = null - /** the app's own icon, decoded once — see appIcon() */ - @Volatile private var icon: Bitmap? = null + @Volatile var player: SoulStreamPlayer? = 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) { if (np.artKey != artKey) { artKey = np.artKey - art = null + artBytes = null } np.art?.let { b64 -> try { - val bytes = Base64.decode(b64, Base64.DEFAULT) - art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + artBytes = Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { /* an unreadable cover is no cover */ } } state = np - val running = service - if (running == null) { - /* Android 12+ refuses a foreground start from the background; the - card simply waits for the next update from the foreground */ - try { - ContextCompat.startForegroundService(ctx, Intent(ctx, MediaService::class.java)) - } catch (e: Exception) { - android.util.Log.w("SoulStream", "media service could not start: $e") + bind(ctx) + /* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls + verifyApplicationThread(), a Tauri @Command is not promised to run + on the main thread, and the IllegalStateException that follows + would be swallowed by media.svelte.js's own `.catch(() => {})` — + the card would just quietly stop updating, with nothing in any log. */ + mainHandler.post { + 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) { state = null - val running = service - if (running != null) running.stopSelf() else ctx.stopService(Intent(ctx, MediaService::class.java)) + artBytes = null + 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 - * when there is no cover yet. `art` is cleared the moment `artKey` + /* two updates can arrive on two binder threads; one binding is enough */ + @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 * 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 - * lock screen shows a grey hole. This is what stands in it. + * so every track change has a real window with no artwork and the lock + * 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 * 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 * kind of drawable simply fills the tile. * - * Decoded on the first ask and never again — `refresh()` runs on every - * position tick, and this must not. A failure caches nothing and answers + * Encoded on the first ask and never again — getState() runs on every + * invalidation, and this must not. A failure caches nothing and answers * 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 if (cached != null) return cached return try { @@ -109,10 +178,14 @@ object MediaHub { val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888) drawable.setBounds(0, 0, 512, 512) drawable.draw(Canvas(bitmap)) - icon = bitmap - bitmap + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + bitmap.recycle() + val bytes = out.toByteArray() + icon = bytes + bytes } 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 } } @@ -123,23 +196,48 @@ object MediaHub { } /** - * The foreground service: a MediaSession the system reads for the lock - * screen and the shade, a MediaStyle notification with the transport, audio - * focus (pause when another app takes the output, or the headphones come - * out), and the reason the process is not frozen in the background. The - * audio never passes through here. + * The media3 session service: a `MediaSession` over [SoulStreamPlayer], the + * card on the lock screen and in the shade, audio focus (pause when another + * app takes the output, or the headphones come out), and the reason the + * process is not frozen in the background. The audio never passes through + * 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 { + /** 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 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 pausedByFocus = false private var noisyRegistered = false - private var inForeground = false private val noisy = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -169,21 +267,45 @@ class MediaService : Service() { } } - override fun onBind(intent: Intent?): IBinder? = null - override fun onCreate() { super.onCreate() - session = MediaSessionCompat(this, "SoulStream") - session.setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { MediaHub.act("play") } - override fun onPause() { MediaHub.act("pause") } - override fun onSkipToNext() { MediaHub.act("next") } - override fun onSkipToPrevious() { MediaHub.act("prev") } - override fun onStop() { MediaHub.act("stop") } - override fun onSeekTo(pos: Long) { MediaHub.act("seek", pos / 1000.0) } - }) - session.isActive = true + /* ours first. media3 would create the channel itself, at + IMPORTANCE_LOW and nothing else — no lockscreenVisibility, and + setShowBadge only below API 28 — so on a fresh install the card + would be hidden behind "sensitive notifications". ensureNotification + Channel leaves an existing channel alone, so getting in first is + the whole fix. */ 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 the flag said out loud */ ContextCompat.registerReceiver( @@ -192,29 +314,131 @@ class MediaService : Service() { ) noisyRegistered = true 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 { - /* a headset or card button arrives as a MEDIA_BUTTON intent */ - MediaButtonReceiver.handleIntent(session, intent) - /* Started with startForegroundService, so Android demands - startForeground() promptly WHATEVER the state — a paused restore - and an empty bar included — or it kills the whole app - (ForegroundServiceDidNotStartInTimeException: on 2026-09-13 a Save - died with the process, 4 of 14 tracks in). Promote first, with the - card there is or a plain one; refresh() may step back at once. */ - if (!inForeground) promote(buildNotification(MediaHub.state ?: NowPlayingArgs())) - refresh() - return START_NOT_STICKY + /* BEFORE super, deliberately: super dispatches the button, and it + may reach media3's `stopSelfSafely()`, which promotes, demotes and + stops. Promoting after that would put a service already on its way + out back into the foreground. A null intent is the system + re-creating a START_STICKY service — not a foreground start, and + it needs nothing. */ + if (intent != null) holdForeground() + return super.onStartCommand(intent, flags, startId) } - private fun promote(notification: Notification) { - if (Build.VERSION.SDK_INT >= 29) { - ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) - } else { - startForeground(NOTIFICATION_ID, notification) + private fun holdForeground() { + val s = session + if (s != null && MediaHub.state != null) { + try { + /* 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() { @@ -223,105 +447,33 @@ class MediaService : Service() { noisyRegistered = false } abandonFocus() - session.isActive = false - session.release() + /* the player first, then the session — media3's own order. `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 super.onDestroy() } - /** Mirror MediaHub.state into the session and the notification. */ - fun refresh() { - val np = MediaHub.state - if (np == null) { - if (inForeground) { - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - 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 */ - } - } + /** Focus is asked for when something starts playing and kept until the + * bar empties or the service dies — called from MediaHub's own post to + * the main looper, so it rides the same thread everything else here + * does. */ + fun syncFocus(playing: Boolean) { + if (playing) requestFocus() } - private fun buildNotification(np: NowPlayingArgs): Notification { - val launch = packageManager.getLaunchIntentForPackage(packageName) - val open = launch?.let { - PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - 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() + /** The bar emptied. The old service gave focus back by dying; this one + * outlives the music, so it gives it back by hand. */ + fun dropFocus() { + pausedByFocus = false + abandonFocus() } private fun createChannel() { diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt index 6c2f8cc..26014f0 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaSessionPlugin.kt @@ -29,10 +29,8 @@ class NowPlayingArgs { var hasNext: Boolean = true var hasPrev: Boolean = true /** the queue around the current item — it and up to ten either side. - * NOTHING READS IT: MediaService ignores both of these on purpose, so - * the card is byte for byte the card it was. media3's - * SimpleBasePlayer.getState() wants a playlist and an index into it, - * and this is that data arriving a round before the migration does. + * Since round forty-eight SoulStreamPlayer.getState() turns this into + * the media3 playlist the card is built from, and the index into it. * Nullable because a non-null Kotlin property whose setter Jackson * hands a null throws, and the only thing between here and the JS that * builds it is two JSON hops. */ @@ -40,6 +38,15 @@ class NowPlayingArgs { /** where the current item sits in `queue` — not in the whole queue; * null when the window is empty */ 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 @@ -78,18 +85,23 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) { @Command fun update(invoke: Invoke) { val args = invoke.parseArgs(NowPlayingArgs::class.java) - /* The window is on no screen anywhere — MediaService ignores it and - the card cannot show it — so logcat is the only place on the - device it can be read back, and tests/mobile/smoke.mjs reads it - from exactly here. One line per update, and an update is a track - change, a transport change or the thirty-second top-up, never a - position tick. `at=` goes LAST because a title may hold anything, - spaces and `=` included. */ + /* The window is on no screen anywhere — the card shows ONE track — + so logcat is the only place on the device the whole of it can be + read back, and tests/mobile/smoke.mjs reads it from exactly here. + One line per update, and an update is a track change, a transport + change or the thirty-second top-up, never a position tick. + `base` and `rep` are here for the same reason and a sharper one: + 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() android.util.Log.i( "SoulStream", "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) + " art=" + window.count { !it.artKey.isNullOrEmpty() } + + " base=" + (args.queueBase ?: -1) + " rep=" + args.repeat + " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""), ) ensureNotificationPermission() diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt new file mode 100644 index 0000000..cfed560 --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/SoulStreamPlayer.kt @@ -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>() + 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()) + .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() + 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() + 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() + } +} diff --git a/crates/tauri-plugin-media-session/android/src/main/res/values/strings.xml b/crates/tauri-plugin-media-session/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..7c5971c --- /dev/null +++ b/crates/tauri-plugin-media-session/android/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + Playback + diff --git a/crates/tauri-plugin-media-session/src/lib.rs b/crates/tauri-plugin-media-session/src/lib.rs index 0aeefb5..ec8f7d2 100644 --- a/crates/tauri-plugin-media-session/src/lib.rs +++ b/crates/tauri-plugin-media-session/src/lib.rs @@ -44,12 +44,11 @@ pub struct NowPlaying { /// The queue around what is playing: the current item and up to ten /// either side, rebuilt on every update. /// - /// NOTHING READS IT YET, and that is the point — `MediaService` ignores - /// both of these fields, so the card is exactly the card it was. media3's - /// `SimpleBasePlayer.getState()` wants a playlist and an index into it, - /// and when that migration comes the data it needs should have been - /// running on the phone for a while rather than have been written the - /// same day. + /// Round forty-eight is where this BECAME the card. `SoulStreamPlayer` + /// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and + /// an index into one, and this window and `queue_index` are exactly that + /// — so what the lock screen shows is built out of these fields rather + /// than beside them. /// /// 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. @@ -58,6 +57,22 @@ pub struct NowPlaying { /// Where the current item sits IN `queue` — not in the whole queue. /// `None` when the window is empty. pub queue_index: Option, + /// 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, + /// 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 { diff --git a/src/mobile/media.svelte.js b/src/mobile/media.svelte.js index cf89493..d0cc65d 100644 --- a/src/mobile/media.svelte.js +++ b/src/mobile/media.svelte.js @@ -9,11 +9,10 @@ import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js" import { art } from "./art.svelte.js"; /* THE QUEUE WINDOW. Every update carries a bounded slice of the queue — - the current item and QUEUE_WINDOW either side — because media3's - SimpleBasePlayer.getState() wants a playlist and an index into it, and - the migration to it should find this data already running rather than - written the same day. Nothing reads it yet: MediaService ignores both - fields and the card is exactly the card it was. + the current item and QUEUE_WINDOW either side. Since round forty-eight + SoulStreamPlayer.getState() builds the media3 playlist and the index + into it out of exactly these fields, so what the lock screen shows is + made of them rather than beside them. 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 @@ -94,6 +93,21 @@ async function push() { const at = player.qi - lo; state.queue = win.map(windowItem); 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 */ if (key && key !== lastArtKey && url && url.startsWith("data:image")) { state.art = url.slice(url.indexOf(",") + 1); diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 39263bd..3ec39c2 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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 * playback state says. From `dumpsys media_session`. */ function mediaSessionState() { try { 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=` 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 playback state after it */ 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 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 }; - } catch { return { owner: "", state: "" }; } + return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null, sessions }; + } catch { return { owner: "", state: "", sessions: -1 }; } } /* 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 - * update — the current item and ten either side — and left MediaService - * ignoring all of it, so there is nothing on any screen to read it back - * from: logcat is the only place on the device it exists, one line per - * update, and the LAST line is the state now. `at=` is last in the line + * update — the current item and ten either side. Since round forty-eight + * SoulStreamPlayer builds the media3 playlist out of it, but the card + * still shows ONE track, so logcat remains the only place on the device + * 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. */ function mediaWindow() { try { const out = adb("logcat", "-d", "-s", "SoulStream:D").toString(); 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=(.*)$/); - if (!m) return { n: -1, i: -1, art: -1, at: "", lines: lines.length }; - return { n: +m[1], i: +m[2], art: +m[3], at: m[4].trim(), lines: lines.length }; - } catch (e) { return { n: -1, i: -1, art: -1, at: "ERR " + e.message, lines: 0 }; } + const m = (lines[lines.length - 1] || "").match( + /media window n=(\d+) i=(-?\d+) art=(\d+) base=(-?\d+) rep=(\d+) at=(.*)$/); + if (!m) return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "", lines: lines.length }; + 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 ─────────────── @@ -2933,7 +2985,14 @@ async function main() { later (a Save died with it). Wait past that window BEFORE resuming, and the pid must be the same one afterwards. */ 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(); 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; @@ -3010,6 +3069,42 @@ async function main() { if (!cardOk) problems.push("EXPECT media-card " + 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"); await sleep(1500); 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 card update — the current item and ten either side, each with its id, title, artist, album, duration and art key, plus where - the current one sits among them — and left MediaService - ignoring every bit of it, because media3's - SimpleBasePlayer.getState() will want a playlist and an index, - and that data should be a round old before anything depends on - it. Which means every check above this one is blind to it: a + the current one sits among them; round forty-eight made it the + media3 playlist the card is built from. Every check above this + one is still blind to the window itself, because the card shows + one track and `dumpsys` can only be asked about that one: a window that arrived empty, an index off by one, or a field the bridge dropped because a key was spelled the wrong way would 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][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][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 })); 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]) }));