From 17942fc04bc213591e0654666ce631976fa5bd44 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:35:54 -0500 Subject: [PATCH] =?UTF-8?q?android=20auto:=20the=20car=20plays=20what=20is?= =?UTF-8?q?=20here,=20resumes,=20and=20is=20told=20the=20rest=20=E2=80=94?= =?UTF-8?q?=20soulseek=20dials=20back,=20and=20the=20storm=20is=20over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the car could not do, all from a process with no Activity. A streamed playlist row plays cold when its bytes are already here. The page's memory of which peer file a catalog track resolved to lived only in localStorage; it is mirrored now to `/stream-sources.json` (stream_sources.rs) — every remembered source, and the whole store once at boot — and auto_browse.rs looks a row up there, computes its cache file with the same hash playback::cache_path uses, and calls it playable when the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a tapped row plays from the cache with its catalog cover and colour. The car resumes, the way every other player does. Where playback was is in `/resume.json` (resume.rs): the page names the queue whenever it writes its session, the engine's pump keeps the position. media3's MediaButtonReceiver is declared, so a PLAY key into a dead app — a Bluetooth head unit, the Quick Settings card, Auto's autoplay on connection — starts the service and lands in onPlaybackResumption, which answers with the `ss:resume` row; the RECENT root serves the same row, and the Recent tab lists it first. A force-stopped app came back PLAYING where it left off from one `cmd media_session dispatch play`. The car is told. A tap that cannot play — a streamed row with no cache copy while the app is cold, a hand-off the resolver refused — used to be silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR, which Auto draws as a card) and sends a SessionError carrying "Open SoulStream"; the next track withdraws it. The page reports its own verdict through the new `remote_failed` command. Every tap while the app is UP is the page's. A library row tapped in the car used to start on the engine underneath the page's own queue — the bar naming one track while another played. `AutoNative.describe` spells the row for the page and `playMediaId` plays it as the page's queue; the plugin disarms the hand-off in onDestroy, and with the page gone the card's transport goes to the engine. When the app opens over a car session, the headless queue is handed to the page once (`playback::init` takes it before clearing) and restoreSession builds the bar from it. Every playlist row is playable: Auto draws a non-playable, non-browsable item as a tile and BROWSES it on tap ("No items"). Rows that need the app say so and fail on tap with the card; rows whose file is here wear the host's own downloaded mark. The Recent tab's album subtitle counted played tracks; it counts the album's now. The page never clears the resume point and only drops its own session once it has booted — a visibility change under a launch with the screen off wiped both. The Soulseek session dials itself back after a drop. A server connection dies for reasons that are nobody's — the phone changes networks, Android freezes a backgrounded process past the five-minute keepalive — and until now every one of those ended as `Event::Disconnected`, the page stamped offline, and every streamed track after it refused with "sign in on Community"; in a car nobody opens the login card. `schedule_reconnect` redials with the stored credentials at 5, 15, 45, 120, then 300 s with jitter, under the server's rapid-login throttle. Every session carries a generation: an explicit login or disconnect bumps it, a redial loop stands down when the user acts, and a drop reported by an OLDER session's pump (login_inner shuts the old client down and its reader reports after the new session is up) no longer stamps the fresh session offline — that race was there all along. The page shows "connecting" on `reconnecting`, the resolver waits up to twenty seconds for a dial in progress, and debug builds expose `debug_drop_connection`. Verified by the debug command (drop → redial 1 in 5 s → online at 7 s) and by a real drop when the head unit connected: redial 1 failed on DNS, redial 2 at +15 s logged in. The notification storm was media3 restarting the service into onStartCommand. The morning's logcat had the card re-posted six times a second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then "Too many PendingIntent created for uid" (media3 mints a fresh one for the custom button on every rebuild), then twenty refused foreground starts in two hundred milliseconds. media3 starts this service ITSELF on every foreground notification update — MediaNotificationManager.startForeground calls startForegroundService(startSelfIntent), a bare component intent with nothing but a uid extra — and onStartCommand promoted on every intent it saw, which re-posted the card, which made media3 start the service again. It ran for as long as the service was foreground: while a track played, and for the ten-minute tail after a pause (the capture stops at pause + 10:00 exactly). onStartCommand now promotes only for an intent that carries an action or a data uri. A 250 ms coalescer on card rebuilds (MediaHub.refreshSoon) logs the rate; it stayed silent through the storm, which is what pointed at the restart. A full minute of playback after the fix: zero shedding lines, zero PendingIntent complaints, zero refused starts. Verified on the Desktop Head Unit and the Pixel: the tree, the Continue row, cold cached playback, the error card and recovery from it, the warm hand-off, the media-key resume, the redial; mock walk 255/255, phone walk 142/142. --- ANDROID.md | 466 +++++++++++------- .../android/src/main/AndroidManifest.xml | 20 + .../soulstream/mediasession/MediaService.kt | 288 ++++++++++- .../mediasession/MediaSessionPlugin.kt | 45 +- .../mediasession/SoulStreamPlayer.kt | 102 +++- crates/tauri-plugin-media-session/build.rs | 5 +- .../autogenerated/commands/remote_failed.toml | 13 + .../permissions/autogenerated/reference.md | 27 + .../permissions/default.toml | 2 +- .../permissions/schemas/schema.json | 16 +- crates/tauri-plugin-media-session/src/lib.rs | 31 +- src-tauri/src/auto_browse.rs | 443 +++++++++++++---- src-tauri/src/commands.rs | 309 +++++++++++- src-tauri/src/config.rs | 6 + src-tauri/src/headless.rs | 335 +++++++++++-- src-tauri/src/lib.rs | 13 + src-tauri/src/playback.rs | 51 +- src-tauri/src/resume.rs | 143 ++++++ src-tauri/src/state.rs | 43 ++ src-tauri/src/stream_sources.rs | 309 ++++++++++++ src/lib/api.js | 21 + src/lib/mockLibrary.js | 16 + src/lib/sources.js | 40 ++ src/lib/state.svelte.js | 7 + src/mobile/media.svelte.js | 87 +++- src/mobile/player.svelte.js | 114 ++++- src/mobile/resolve.svelte.js | 18 +- src/mobile/slsk.svelte.js | 26 + 28 files changed, 2600 insertions(+), 396 deletions(-) create mode 100644 crates/tauri-plugin-media-session/permissions/autogenerated/commands/remote_failed.toml create mode 100644 src-tauri/src/resume.rs create mode 100644 src-tauri/src/stream_sources.rs diff --git a/ANDROID.md b/ANDROID.md index 0d1c12c..071b01a 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -762,80 +762,80 @@ screen — off Windows they report None and 0. ## Background playback and the lock-screen card -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 +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 `MediaLibraryService` holding one process-lifetime `MediaLibrarySession`, which is what lets Android Auto list and open the app at all, 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` → +`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. The session, the player, the notification provider, the audio-focus request @@ -873,75 +873,75 @@ dies; the walk's `one-session` check is what keeps that honest. `MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs `auto service destroyed`. Those two lines are the only way to count the loop -on a real phone — see "### The Auto prober". - -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 +on a real phone — see "### The Auto prober". + +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. @@ -1073,16 +1073,16 @@ 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 +### 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`); and both of the intent filters Android Auto knocks on — the legacy `android.media.browse.MediaBrowserService` and media3's own @@ -1103,19 +1103,106 @@ last resolved it (`auto-desc`). **The browse tree is not proven here and cannot be.** With the app force-stopped there is no WebView, so no DevTools socket, so `smoke.mjs` has nothing to attach to; `tools/auto-prober/` is the instrument for that, and -"### The Auto prober" under Verifying is where it lives. - -**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 +"### The Auto prober" under Verifying is where it lives. + +**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. +## Android Auto: cold playback, resume, and what the car is told + +The car binds `MediaService` with the app COLD — no Activity, no WebView, no +`AppHandle` — and everything below is about making that process play what +it can, say what it cannot, and pick up where the phone left off. Four +pieces, all of them landed 2026-09-18: + +**What plays cold.** Library tracks always did. Now a playlist's streamed +rows play cold too, when their bytes are already on the phone: the page's +memory of which peer file a catalog track resolved to +(`soulstream.streamSources.v1` in localStorage) is mirrored to +`/stream-sources.json` (`stream_sources.rs`) — one entry every time a +source is remembered, and the whole store once at boot for memories made +before the mirror existed — and `auto_browse.rs` looks a streamed row up +there, computes its cache file with the same hash `playback::cache_path` +uses, and calls it playable when the file is byte-complete. Such a row is +"artist · album" in the car; one that is not is "artist · needs the app", +which is the amber-dot row of the design. A playlist's subtitle counts +cached streams as "here". A tap on a row that needs the app is handed to +the app when it is up (`playRemote`), exactly as before. + +**Keep offline.** A playlist's More sheet has "Keep offline" +(`src/mobile/offline.svelte.js`, the list in `config.json` +`offline_playlists`): every streamed row is fetched into the stream cache in +the background — the remembered peer file first, else the resolver's ladder, +then the same `preload_stream` the player uses for the next track, one row +at a time, polled until whole — and pinned (`keep` in the mirror), which the +cache pruner honours, so the retention window cannot undo it. The fetch +rides the downloads notice in the shade, and that is also what keeps the +process alive once the app leaves the screen; Android 12+ lets that service +start only from the foreground, so a pass that begins on screen keeps going +in a pocket. The playlist header says "Kept offline · N of M here" once a +pass has looked. + +**Resume, like every other player.** Where playback was is written to +`/resume.json` (`resume.rs`) by two hands: the page names the queue +(a window of 25 either side of the current track, each entry by the file +that plays it cold — a local path, or the peer file whose cache may hold it) +every time it writes its own session, and the engine's pump keeps the +position, every five seconds while playing and at once on a pause or seek. +Three things read it. media3's `MediaButtonReceiver` is now declared, so a +PLAY key into a dead app — a Bluetooth head unit's button, the Quick +Settings resume card, Android Auto's autoplay on connection — starts the +service and lands in `LibraryTree.onPlaybackResumption`, which answers with +the `ss:resume` row and the position; media3 sets it, and +`play_media_id("ss:resume", startMs)` starts the saved queue at the saved +entry and seeks. A host asking for the RECENT root (`EXTRA_RECENT`) gets a +root whose one child is that row, "Continue · · m:ss". And the +Recent tab lists it first. An entry whose file is gone is skipped; if the +one that was playing is gone, the next playable one starts at zero rather +than three minutes into a different song. + +**The handover.** When the app opens over a car session the engine is +already playing a queue the page has never heard of. `playback::init` takes +that queue off the headless engine before clearing it, `player_state` hands +it over once, and `restoreSession` builds the bar from it — instead of +restoring the page's own older session over live audio, which put the wrong +title on the bar. + +**What the car is told.** A tap that cannot play — a streamed row with no +cache copy while the app is cold, a hand-off the resolver refused, a row +whose file is gone — used to be silence. `MediaHub.fail` now puts the reason +in the playback state (`STATE_ERROR` with the message, which Auto draws as a +card) and sends a `SessionError` carrying "Open SoulStream" as the +resolution action; the next track that plays withdraws it. The page reports +its own verdict back through `remote_failed` when a hand-off fails. + +**The notification storm, found and fixed.** 2026-09-18's logcat had the +card re-posted six times a second for over a minute, then "Too many +PendingIntent created" (media3 mints a fresh one for the custom button on +every rebuild) and twenty refused foreground starts in two hundred +milliseconds. The cause was a loop between `MediaService.onStartCommand` and +media3: media3 starts the service ITSELF on every foreground notification +update (`MediaNotificationManager.startForeground` → +`startForegroundService(startSelfIntent)`, a bare component intent with a +uid extra and nothing else), and `onStartCommand` promoted on every intent +— re-posting the card, which made media3 start the service again. It ran +for as long as the service was in the foreground: while a track played, and +for the ten-minute tail after it paused (the capture shows it stopping at +exactly pause + 10 min). `onStartCommand` now promotes only for an intent +that carries an action or a data uri — a media key, a card button — and +leaves media3's self-start to media3. Separately, every `refresh()` of the +media3 player goes through `MediaHub.refreshSoon`, which coalesces asks +inside a 250 ms window and logs `card asked to rebuild Nx in 10s` when the +rate is silly; it stayed silent through the storm, which is what pointed +away from our own rebuilds and at the service restart. + ## What a screen reader says Every control on the phone is named by what it does, in the app's own @@ -1186,6 +1273,26 @@ would notice. ## When things go wrong +**The network comes back on its own (2026-09-18).** A Soulseek server +connection dies for reasons that are nobody's: the phone changes networks, +Android freezes a backgrounded process for long enough that the five-minute +keepalive never goes out and the server closes the socket, a laptop sleeps. +Until now every one of those ended as `Event::Disconnected`, the page stamped +"offline", and every streamed track after that was refused with "sign in on +Community" until somebody opened the login card — in a car, nobody does. +`commands::schedule_reconnect` now dials the dropped session back with the +stored credentials: 5 s, 15 s, 45 s, 2 min, then every 5 min, with jitter, +which stays well under the server's rapid-login throttle (eight to ten and it +silently drops the handshake). The page hears `soulseek:state { kind: +"reconnecting", attempt, in_secs }` and shows "connecting" rather than +"offline"; the resolver waits up to twenty seconds for a dial in progress +before refusing a track. An explicit Disconnect ends the wish; an explicit +login starts a new generation, and a drop reported by an older session's +pump — which used to stamp a fresh session offline, a race that was there +all along — now says nothing. Debug builds expose `debug_drop_connection` +(`__ss.api.debugDropConnection()`), which severs the socket exactly the way a +network drop would, so the redial can be watched. + Thirty rounds built the happy path. A phone is also in a lift, signed out, on a dead cell, or holding a library folder that has gone away, and until this round every one of those showed up as an empty box, a spinner with no end, or @@ -2413,27 +2520,16 @@ symptom. 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 can see the app, and there is nothing inside.** Round - forty-nine made `MediaService` a `MediaLibraryService`, put three actions - on one intent filter (media3's `MediaLibraryService` and - `MediaSessionService`, plus the legacy - `android.media.browse.MediaBrowserService` that Auto actually binds), and - added `res/xml/automotive_app_desc.xml` with the - `com.google.android.gms.car.application` meta-data that declares the app to - Auto. So Auto lists SoulStream, opens it, and gets a root — immediately, - from `Futures.immediateFuture`, with nothing on the main thread — and the - root is empty. `onGetChildren` is not overridden, so the default answers - `ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in - it. What is still missing is everything under it: the library read out of - the database **read-only** from a process with no Activity and no Tauri - (`crates/library` is plain rusqlite and `JNI_OnLoad` publishes the Context - at `System.loadLibrary` time, so browse can work cold), playing what it - lists — which needs `COMMAND_SET_MEDIA_ITEM` and its neighbours, still - deliberately unadvertised, plus the `queueBase` → `playQueueAt()` mapping — - and search. Catalog and Soulseek rows will never play from cold: the - resolver is 872 lines of shared JS and Soulseek needs a live session, so - they get `STATE_ERROR` with a resolution intent instead, which is also what - Google's quality bar asks for. The walk proves the two front doors resolve - (`auto-browse`, `auto-library`) and `tools/auto-prober/` proves the root - comes back cold; the car screen itself still needs the Desktop Head Unit or - a car. +- **Android Auto, what is still not there.** The tree, cold playback of + library tracks and cached streams, search and voice, resume and autoplay, + and the error card are in (see "Android Auto: cold playback, resume, and + what the car is told"). Not yet: a streamed row with NO cache copy still + cannot play from a cold process — the resolver is shared JS and Soulseek + needs a live session — and the car says so and offers to open the app; + "Keep offline" is the answer for a playlist that matters. **Save**, the + design's second transport action, is unbuilt. The car's own plays are not + written to `plays` (the headless process opens the library read-only by + rule), so a record heard only in the car does not climb the phone's Recent + — the resume row covers the common case. Catalog browsing beyond + playlists, the queue as a browsable list, and the `queueBase` → + `playQueueAt()` mapping for tapping a queue row are still absent. 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 f2f7898..b68e10f 100644 --- a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml +++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml @@ -49,6 +49,26 @@ + + + + + + +