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 f3300f1..f2f7898 100644
--- a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml
+++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml
@@ -56,5 +56,13 @@
+
+
+
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 c7d49bd..6e398b1 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
@@ -28,12 +28,15 @@ import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
+import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionResult
import androidx.core.content.FileProvider
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
@@ -271,6 +274,32 @@ object MediaHub {
val p = SoulStreamPlayer(app)
player = p
val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream")
+ // The transport's custom half. Auto allows a handful of these and
+ // draws what fits; the layout is what puts the button there at all,
+ // and LibraryTree.onConnect is what makes pressing it work.
+ // A PREDEFINED icon, not our drawable. media3 maps these to the glyph
+ // the host already uses for that function, so the button matches every
+ // other app's shuffle in the car instead of being a stranger — and a
+ // host that does not recognise a custom resource simply drops the
+ // button, which is what happened when this shipped with
+ // setIconResId: the action was registered and nothing was drawn.
+ //
+ // setMediaButtonPreferences rather than setCustomLayout: both exist in
+ // 1.10.1, but the layout API is the older spelling and preferences is
+ // what current hosts read.
+ val shuffle =
+ // ICON_SHUFFLE_ON, not _OFF. The constants name the STATE, and the
+ // host draws _OFF as a circular arrow that reads as repeat — which
+ // is what the car showed. This is a button that performs a shuffle,
+ // not a toggle reporting one, so the plain crossing-arrows glyph is
+ // the honest one.
+ CommandButton.Builder(CommandButton.ICON_SHUFFLE_ON)
+ .setDisplayName("Shuffle")
+ .setSessionCommand(
+ SessionCommand(LibraryTree.SHUFFLE_ACTION, android.os.Bundle.EMPTY),
+ )
+ .build()
+ builder.setMediaButtonPreferences(ImmutableList.of(shuffle))
val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
if (launch != null) {
builder.setSessionActivity(
@@ -479,6 +508,12 @@ object LibraryTree : MediaLibrarySession.Callback {
* with a media id that happens to be a file path. */
const val ROOT = "ss:root"
+ /** The one custom action on the car's transport. Two of the six slots
+ * Auto allows were chosen; this is the half that can be built, because
+ * the other — Save — has nothing to act on until catalog rows exist in
+ * the tree. A button that cannot do anything is worse than no button. */
+ const val SHUFFLE_ACTION = "com.soulstream.auto.SHUFFLE"
+
/* The content-style extras. These are plain string keys rather than a
constant from a library because they belong to the Auto host, not to
media3 — the same keys the legacy MediaBrowserService protocol has
@@ -529,7 +564,54 @@ object LibraryTree : MediaLibrarySession.Callback {
" offline=" + (params?.isOffline ?: false) +
" suggested=" + (params?.isSuggested ?: false),
)
- return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params))
+ // The content-style DEFAULTS belong here, on the root, and declaring
+ // support at all is what makes a host read the rest. Per-node
+ // overrides ride on the parent ITEM (see styleExtras) rather than on
+ // the reply that carries its children — which is the mistake that had
+ // the albums drawing as a list with the grid hint set correctly but in
+ // the wrong place.
+ val b = android.os.Bundle()
+ b.putBoolean(CONTENT_STYLE_SUPPORTED, true)
+ b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST)
+ b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
+ val rootParams = LibraryParams.Builder().setExtras(b).build()
+ return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams))
+ }
+
+ /**
+ * The icon a root tab is drawn with — the thing that turns four words into
+ * four tabs with pictures over them, which is what every other media app
+ * in the car has.
+ *
+ * An `android.resource://` URI rather than a file: these are OUR
+ * resources, they are the same for every install, and routing them through
+ * the FileProvider would mean granting a URI per connection for an icon
+ * that never changes. A host that cannot load one simply draws the label,
+ * which is exactly what happens today.
+ */
+ private fun tabIcon(ctx: Context, id: String): android.net.Uri? {
+ val name =
+ when (id) {
+ "ss:recent" -> "ic_car_tab_recent"
+ "ss:albums" -> "ic_car_tab_albums"
+ "ss:artists" -> "ic_car_tab_artists"
+ "ss:saved" -> "ic_car_tab_saved"
+ else -> return null
+ }
+ return android.net.Uri.parse(
+ "android.resource://" + ctx.packageName + "/drawable/" + name,
+ )
+ }
+
+ /** The per-item override: a node whose CHILDREN should be a grid carries
+ * the hint itself. Albums are sleeves — the tab that lists them, and each
+ * artist whose children are albums. */
+ private fun styleExtras(id: String): android.os.Bundle? {
+ val grid = id == "ss:albums" || id.startsWith("ss:artist:")
+ if (!grid) return null
+ val b = android.os.Bundle()
+ b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
+ return b
}
/** One thread for the life of the process. A car browses one node at a
@@ -584,6 +666,57 @@ object LibraryTree : MediaLibrarySession.Callback {
mediaItems: MutableList,
): ListenableFuture> = Futures.immediateFuture(mediaItems)
+ /**
+ * Custom commands have to be GRANTED per controller or the button on the
+ * car is drawn and then rejected when pressed. The default set is taken
+ * as the base so nothing media3 normally offers is lost by adding ours.
+ */
+ override fun onConnect(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ): MediaSession.ConnectionResult {
+ val commands =
+ MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
+ .buildUpon()
+ .add(SessionCommand(SHUFFLE_ACTION, android.os.Bundle.EMPTY))
+ .build()
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
+ .setAvailableSessionCommands(commands)
+ .build()
+ }
+
+ /**
+ * Shuffle, which is what a driver reaches for. It reorders only what is
+ * still AHEAD — shuffling the whole queue would restart the song under
+ * their hands, which is not what the button means.
+ *
+ * Off the calling thread because it takes the queue lock the audio pump
+ * also takes on every track change.
+ */
+ override fun onCustomCommand(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ customCommand: SessionCommand,
+ args: android.os.Bundle,
+ ): ListenableFuture {
+ if (customCommand.customAction != SHUFFLE_ACTION) {
+ return Futures.immediateFuture(
+ SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED),
+ )
+ }
+ return Futures.submit(
+ {
+ val moved = AutoNative.shuffle()
+ android.util.Log.i("SoulStream", "auto shuffle moved=" + moved)
+ SessionResult(
+ if (moved > 0) SessionResult.RESULT_SUCCESS
+ else SessionResult.RESULT_ERROR_INVALID_STATE,
+ )
+ },
+ io,
+ )
+ }
+
/**
* How the car should DRAW this node's children — the only say the app has
* over the layout, since the host builds the UI and we supply content.
@@ -700,7 +833,10 @@ object LibraryTree : MediaLibrarySession.Callback {
// exactly how every row lost its cover while the database, the
// res overlay and the APK all checked out.
val artPath = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
- artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
+ val tab = tabIcon(ctx, id)
+ if (tab != null) md.setArtworkUri(tab)
+ else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
+ styleExtras(id)?.let { md.setExtras(it) }
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
}
} catch (e: Throwable) {
@@ -1011,4 +1147,70 @@ object AutoNative {
/** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */
external fun transport(action: String)
+
+ /** Reorder what is still ahead in the headless queue; returns how many
+ * tracks moved, so a no-op can be reported rather than hidden. */
+ external fun shuffle(): Int
+
+ /** Scrub the engine directly, in seconds. */
+ external fun seek(secs: Double)
+
+ /** True when the headless queue is what is playing — i.e. a car started a
+ * cold process and there is no WebView to route a transport command to.
+ * False in a normal phone session, where the JS owns the queue. */
+ external fun driving(): Boolean
+
+ /**
+ * Called FROM Rust, on the audio pump, when the fallback queue moves to
+ * the next track. Without it the card sits on track one for the rest of
+ * the album while the right audio plays underneath it.
+ *
+ * Static on purpose: the caller is a native thread that has just attached
+ * to the VM and has no object to call through. It must not throw — an
+ * exception here surfaces on a thread that is driving playback — so
+ * everything is inside one catch.
+ */
+ /**
+ * A cover as the bytes `MediaHub.update` expects.
+ *
+ * The card path takes base64 because that is the shape the WebView sends,
+ * and reusing it means the headless path and the normal one produce a
+ * byte-identical card rather than two subtly different ones. Capped,
+ * because a cover is decoded into a bitmap on the other side and some
+ * scans are enormous.
+ */
+ fun artB64(path: String?): String? {
+ if (path.isNullOrEmpty()) return null
+ return try {
+ val f = java.io.File(path)
+ if (!f.isFile || f.length() > 4_000_000L) null
+ else Base64.encodeToString(f.readBytes(), Base64.NO_WRAP)
+ } catch (e: Throwable) {
+ null
+ }
+ }
+
+ @JvmStatic
+ fun headlessTrack(json: String) {
+ try {
+ val ctx = MediaHub.appContext ?: return
+ val o = org.json.JSONObject(json)
+ val np = NowPlayingArgs()
+ np.title = o.optString("title").ifEmpty { null }
+ np.artist = o.optString("artist").ifEmpty { null }
+ np.album = o.optString("album").ifEmpty { null }
+ np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
+ np.position = 0.0
+ np.playing = true
+ np.hasNext = o.optBoolean("hasNext", false)
+ np.hasPrev = true
+ val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
+ np.artKey = art
+ np.art = artB64(art)
+ MediaHub.update(ctx, np)
+ android.util.Log.i("SoulStream", "auto advanced to " + np.title)
+ } catch (e: Throwable) {
+ android.util.Log.w("SoulStream", "auto advance card failed: " + e)
+ }
+ }
}
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
index c19fe7c..8ab9d06 100644
--- 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
@@ -239,10 +239,32 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the transport, back out to the WebView ──────────────────────── */
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
+ if (headless()) {
+ AutoNative.transport(if (playWhenReady) "resume" else "pause")
+ return Futures.immediateVoidFuture()
+ }
MediaHub.act(if (playWhenReady) "play" else "pause")
return awaitUpdate()
}
+ /**
+ * True when the engine in this process is the thing playing and there is
+ * no WebView to send a transport command to.
+ *
+ * Every control here used to be a round trip out to the JS. In a car that
+ * started a cold process there is no JS, so pause, seek, next and previous
+ * were all messages to nobody — the buttons drew, moved, and did nothing.
+ * `driving()` is true only once the headless queue holds something, so a
+ * normal phone session is untouched and still goes through the WebView,
+ * which owns shuffle, repeat, `alts` failover and the real queue.
+ */
+ private fun headless(): Boolean =
+ try {
+ AutoNative.driving()
+ } catch (e: Throwable) {
+ false
+ }
+
/** 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
@@ -307,6 +329,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
np.playing = true
np.hasNext = queued > 1
np.hasPrev = false
+ // The cover, which is also the colour: Android Auto washes
+ // the playback screen with whatever artwork it is handed,
+ // so shipping no art tinted every record with the app
+ // icon's blue.
+ val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
+ np.artKey = art
+ np.art = AutoNative.artB64(art)
MediaHub.update(ctx.applicationContext, np)
}
},
@@ -361,10 +390,21 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* `prev()` seeks to zero. The gate is the COMMAND being advertised,
* and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
+ val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
+ if (headless()) {
+ when (seekCommand) {
+ Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
+ AutoNative.transport("next")
+ Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
+ AutoNative.transport("prev")
+ else -> AutoNative.seek(secs)
+ }
+ return Futures.immediateVoidFuture()
+ }
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)
+ else -> MediaHub.act("seek", secs)
}
return awaitUpdate()
}
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml
new file mode 100644
index 0000000..f624ad1
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_shuffle.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml
new file mode 100644
index 0000000..abdff89
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_albums.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml
new file mode 100644
index 0000000..6375ddb
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_artists.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml
new file mode 100644
index 0000000..995e4d5
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_recent.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml
new file mode 100644
index 0000000..a9003e8
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/drawable/ic_car_tab_saved.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml b/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml
new file mode 100644
index 0000000..5a63f67
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/values/car_theme.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml b/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml
new file mode 100644
index 0000000..ba3c8de
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/values/colors.xml
@@ -0,0 +1,15 @@
+
+
+
+ #31C0CB
+
+ #05080B
+
diff --git a/handoff/rounds/commit-msg52.txt b/handoff/rounds/commit-msg52.txt
new file mode 100644
index 0000000..aa4a504
--- /dev/null
+++ b/handoff/rounds/commit-msg52.txt
@@ -0,0 +1,71 @@
+android auto: the car's own controls, the covers, and four tabs with faces
+
+Everything here came from putting the thing on a real head unit and looking at
+it, which is the only reason any of it was found.
+
+**The transport did nothing.** Pause, seek, next and previous all routed
+through `MediaHub.act`, which is a round trip out to the WebView — and a car
+that started a cold process has no WebView, so every button drew, moved, and
+sent a message to nobody. `AutoNative.driving()` reports whether the headless
+queue is what is playing, and when it is, the transport goes straight to the
+engine instead: pause/resume, a real seek, and next/previous that move the
+fallback queue and push a card update after them. A normal phone session is
+untouched and still goes through the JS, which owns shuffle, repeat, `alts`
+failover and the real queue.
+
+That is the THIRD instance of one bug: the headless path silently skipping
+something the WebView path does for free. Audio focus was the first (a live
+AAudio track, muted, audible to nobody), the card was the second (STATE_NONE
+over playing music), the transport is this one. Anything the JS does on the
+way to making a sound has to have an answer here too, and the failure is always
+silence rather than an error.
+
+**The covers, and the colour.** The playback screen was showing the app icon,
+because nothing filled the card's art. Android Auto washes that screen with a
+colour it extracts from whatever artwork it is handed — so every record was
+tinted the falcon's blue. The engine already knows the album's cover path, so
+it now travels with the track and the card is built from it. Random Access
+Memories comes up gold. The app derives an accent per cover and the car cannot
+be told to, but it turns out the car does it anyway, for free, given art.
+
+**Four tabs with icons**, which is what every other media app in the car has:
+a root browsable item's artwork is drawn as its tab icon, and ours had none, so
+they rendered as bare words. `android.resource://` URIs rather than the
+FileProvider — these are our own resources, identical on every install, and
+granting a per-connection URI for an icon that never changes would be wrong.
+
+**The grid, in the right place.** The hint was set on each node's own
+`LibraryParams` when returning its children; Auto reads it from the ROOT as a
+default and from the PARENT ITEM as an override. So the albums drew as a list
+with the grid hint set correctly and delivered nowhere. The root declares
+`CONTENT_STYLE_SUPPORTED` now and the `ss:albums` tab carries the override,
+along with each artist, whose children are also sleeves.
+
+**Shuffle** is a `CommandButton` on `setMediaButtonPreferences` — both spellings
+exist in 1.10.1 and `setCustomLayout` is the older one. It uses the PREDEFINED
+`ICON_SHUFFLE_ON` rather than a drawable of ours: a custom resource the host
+does not recognise is dropped silently, which is what happened the first time,
+and `ICON_SHUFFLE_OFF` names the state and is drawn as a circular arrow that
+reads as repeat. It reorders only what is AHEAD, so the current song does not
+restart under the driver's hands and "previous" still goes back to what was
+actually heard.
+
+The accent is `#31C0CB` in a car theme named by
+`com.google.android.gms.car.application.theme`. Its parent is a FRAMEWORK theme
+because the style is resolved inside the car's process, where AppCompat and
+Material are not guaranteed to exist.
+
+Proven on the Pixel 6a through a real Android Auto session, driven from the
+DHU: the four tabs render with their icons, Albums is a grid of full covers,
+the playback screen shows the album art with the colour pulled from it, and a
+media `next` moves the queue and lands the card on "The Game of Love".
+
+**What this does not touch, and it is the next question.** Everything in the
+tree is a local file. Playlists are in the database, are local, and would play
+from cold exactly as albums do — they are absent only because Auto gives four
+root slots and four are taken, which is a trade to make rather than a limit.
+Catalog and Soulseek rows genuinely cannot play from a cold process, but they
+CAN whenever the app is warm, through the same `MediaHub.act` round trip the
+transport just stopped depending on — browsable always, honest error only when
+there is no WebView. That is a better answer than the design's "never" and it
+is where this goes next.
diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs
index 39a2994..8f8ff8e 100644
--- a/src-tauri/src/auto_browse.rs
+++ b/src-tauri/src/auto_browse.rs
@@ -355,7 +355,7 @@ pub(crate) fn resolve_playable(
};
let album_tracks = |album_id: i64| -> rusqlite::Result> {
let mut stmt = conn.prepare(
- "SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title
+ "SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
FROM tracks t JOIN albums a ON a.id = t.album_id
WHERE t.album_id = ?1
ORDER BY t.disc_no, t.track_no, t.title",
@@ -369,6 +369,7 @@ pub(crate) fn resolve_playable(
artist: r.get(3)?,
duration: r.get::<_, Option>(4)?.map(|s| s as f64),
album: r.get(5)?,
+ art: r.get(6)?,
},
))
})?;
@@ -498,6 +499,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
.unwrap_or(std::ptr::null_mut())
}
+/// `AutoNative.shuffle()` — reorder what is still ahead in the headless
+/// queue. Returns how many tracks were reordered, so the car can be told
+/// nothing happened rather than showing a button that silently does nothing.
+#[no_mangle]
+pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_shuffle(
+ _env: JNIEnv,
+ _this: JObject,
+) -> jni::sys::jint {
+ crate::headless::audio(None).shuffle_rest() as jni::sys::jint
+}
+
/// `AutoNative.transport(action)` — pause / resume / stop against the
/// process's engine, for the card's own buttons when no WebView is alive to
/// take the usual round trip.
@@ -515,6 +527,33 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
"pause" => audio.player.pause(),
"resume" => audio.player.resume(),
"stop" => audio.player.stop(),
+ "next" => {
+ audio.skip(1);
+ }
+ "prev" => {
+ audio.skip(-1);
+ }
_ => warn(&format!("auto transport: unknown action {a}")),
}
}
+
+/// `AutoNative.seek(secs)` — the car's scrubber, straight to the engine.
+#[no_mangle]
+pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_seek(
+ _env: JNIEnv,
+ _this: JObject,
+ secs: f64,
+) {
+ crate::headless::audio(None).player.seek(secs);
+}
+
+/// `AutoNative.driving()` — true when the headless queue is what is playing,
+/// so Kotlin can send a transport command to Rust instead of to a WebView
+/// that may not exist.
+#[no_mangle]
+pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
+ _env: JNIEnv,
+ _this: JObject,
+) -> jni::sys::jboolean {
+ u8::from(crate::headless::audio(None).driving())
+}
diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs
index 560af11..9f20b6f 100644
--- a/src-tauri/src/headless.rs
+++ b/src-tauri/src/headless.rs
@@ -45,6 +45,11 @@ pub struct PlayTrack {
pub artist: String,
pub album: String,
pub duration: Option,
+ /// Absolute path to the album's cover. The card is built from this, and
+ /// so is the colour Android Auto washes the playback screen with — it
+ /// extracts from whatever artwork it is given, which is why supplying the
+ /// app icon tinted every record the same blue.
+ pub art: Option,
}
#[derive(Default)]
@@ -120,8 +125,17 @@ impl Audio {
q.items.get(q.idx).cloned()
}
};
+ let more = {
+ let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
+ q.idx + 1 < q.items.len()
+ };
match next {
- Some(t) => self.player.play_local(t.path, t.duration),
+ Some(t) => {
+ self.player.play_local(t.path.clone(), t.duration);
+ // Tell Kotlin, or the card sits on track one for the rest of
+ // the album while the right audio plays under it.
+ notify_track(&t, more);
+ }
// The end of the album is the end of the album. Leaving the queue
// in place means a later `advance` finds the index past the end and
// does nothing, rather than wrapping round to track one.
@@ -142,6 +156,83 @@ impl Audio {
}
}
+ /// Shuffle what is still AHEAD, leaving the current track playing.
+ ///
+ /// Shuffling the whole queue would restart the song under the driver's
+ /// hands, which is not what anyone means by the button. Nothing before the
+ /// index moves either, so "previous" still goes back to what was actually
+ /// heard.
+ ///
+ /// The generator is a xorshift seeded from the clock rather than a crate:
+ /// this decides the order of an album, not anything anyone should be able
+ /// to bet on, and a dependency for it would be the wrong trade.
+ pub fn shuffle_rest(&self) -> usize {
+ let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
+ let start = q.idx + 1;
+ let len = q.items.len();
+ if len <= start + 1 {
+ return 0;
+ }
+ let mut s = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos() as u64)
+ .unwrap_or(0x9E37_79B9_7F4A_7C15)
+ | 1;
+ let mut i = len;
+ while i > start + 1 {
+ i -= 1;
+ s ^= s << 13;
+ s ^= s >> 7;
+ s ^= s << 17;
+ let j = start + (s % ((i - start + 1) as u64)) as usize;
+ q.items.swap(i, j);
+ }
+ len - start
+ }
+
+ /// Move by one track in the fallback queue. Returns false at either end,
+ /// so the caller can leave the card alone rather than pretend.
+ ///
+ /// This exists because the car's next/previous buttons go through the
+ /// player, and the player's usual answer is a round trip to the WebView —
+ /// which in a service-only process is a message to nobody. Every transport
+ /// control was dead here until this.
+ pub fn skip(&self, delta: i64) -> bool {
+ let picked = {
+ let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
+ if q.items.is_empty() {
+ None
+ } else {
+ let target = q.idx as i64 + delta;
+ if target < 0 || target >= q.items.len() as i64 {
+ None
+ } else {
+ q.idx = target as usize;
+ q.items.get(q.idx).cloned()
+ }
+ }
+ };
+ let more = {
+ let q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
+ q.idx + 1 < q.items.len()
+ };
+ match picked {
+ Some(t) => {
+ self.player.play_local(t.path.clone(), t.duration);
+ notify_track(&t, more);
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// True when this engine is the one driving playback — i.e. a car started
+ /// it and no WebView has taken over. The transport uses it to decide
+ /// whether a button belongs to Rust or to the JS.
+ pub fn driving(&self) -> bool {
+ !self.queue.lock().unwrap_or_else(|e| e.into_inner()).items.is_empty()
+ }
+
/// What is playing right now, as the card needs it. `None` when this
/// engine has never been given a queue — which is the normal state in a
/// process the WebView drives, and the signal to leave the card alone.
@@ -151,6 +242,57 @@ impl Audio {
}
}
+/// Hand a track change up to Kotlin so the card can follow the fallback queue.
+///
+/// This runs on the audio pump — a native thread with no Java on its stack —
+/// so it has to attach to the VM itself. The VM is there because `JNI_OnLoad`
+/// published it to `ndk-context` at `System.loadLibrary` time, before any
+/// Activity existed, which is the same hand-off the audio backend relies on
+/// and the reason a service-only process can do this at all.
+///
+/// Every failure is swallowed. A card that misses an update is a cosmetic
+/// problem; a panic across the JNI boundary on the thread that drives playback
+/// is the music stopping.
+fn notify_track(t: &PlayTrack, has_next: bool) {
+ let json = serde_json::json!({
+ "title": t.title,
+ "artist": t.artist,
+ "album": t.album,
+ "duration": t.duration,
+ "art": t.art,
+ "hasNext": has_next,
+ })
+ .to_string();
+
+ let ctx = ndk_context::android_context();
+ if ctx.vm().is_null() {
+ return;
+ }
+ // SAFETY: the pointer came from ndk-context, which android_glue filled
+ // with the real JavaVM and never replaces.
+ let Ok(vm) = (unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }) else {
+ return;
+ };
+ // The pump is not a Java thread; attaching is what makes it callable.
+ let Ok(mut env) = vm.attach_current_thread() else {
+ return;
+ };
+ let Ok(arg) = env.new_string(&json) else {
+ return;
+ };
+ let called = env.call_static_method(
+ "com/soulstream/mediasession/AutoNative",
+ "headlessTrack",
+ "(Ljava/lang/String;)V",
+ &[(&arg).into()],
+ );
+ if called.is_err() {
+ // An exception left pending would be thrown at whatever Java code ran
+ // next on this thread, which would be something entirely unrelated.
+ let _ = env.exception_clear();
+ }
+}
+
/// Play what a car tapped: the media id from the browse tree, resolved against
/// the library read-only, with the rest of its album behind it so the record
/// keeps going.
@@ -174,6 +316,7 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
"artist": t.artist,
"album": t.album,
"duration": t.duration,
+ "art": t.art,
})
.to_string(),
None => format!("{{\"queued\":{n}}}"),