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.
This commit is contained in:
KeelrLeek committed 2026-09-15 13:30:54 -05:00
1 parent f50cf2018a
commit 4d75395411
13 files changed
+587 -5

No files matched your search

@@ -56,5 +56,13 @@
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> android:resource="@xml/automotive_app_desc" />
<!-- The one colour pair Android Auto gives a media app, chosen at
build time. It cannot follow the artwork the way the phone's
accent does, so it is the brand's constant instead. See
res/values/car_theme.xml. -->
<meta-data
android:name="com.google.android.gms.car.application.theme"
android:resource="@style/SoulStreamCarTheme" />
</application> </application>
</manifest> </manifest>
@@ -28,12 +28,15 @@ import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
@@ -271,6 +274,32 @@ object MediaHub {
val p = SoulStreamPlayer(app) val p = SoulStreamPlayer(app)
player = p player = p
val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream") 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) val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
if (launch != null) { if (launch != null) {
builder.setSessionActivity( builder.setSessionActivity(
@@ -479,6 +508,12 @@ object LibraryTree : MediaLibrarySession.Callback {
* with a media id that happens to be a file path. */ * with a media id that happens to be a file path. */
const val ROOT = "ss:root" 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 /* 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 constant from a library because they belong to the Auto host, not to
media3 — the same keys the legacy MediaBrowserService protocol has media3 — the same keys the legacy MediaBrowserService protocol has
@@ -529,7 +564,54 @@ object LibraryTree : MediaLibrarySession.Callback {
" offline=" + (params?.isOffline ?: false) + " offline=" + (params?.isOffline ?: false) +
" suggested=" + (params?.isSuggested ?: 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 /** 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<MediaItem>, mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> = Futures.immediateFuture(mediaItems) ): ListenableFuture<MutableList<MediaItem>> = 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<SessionResult> {
if (customCommand.customAction != SHUFFLE_ACTION) {
return Futures.immediateFuture(
SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED),
)
}
return Futures.submit<SessionResult>(
{
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 * 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. * 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 // exactly how every row lost its cover while the database, the
// res overlay and the APK all checked out. // res overlay and the APK all checked out.
val artPath = if (o.isNull("art")) null else o.optString("art").ifEmpty { null } 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()) out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -1011,4 +1147,70 @@ object AutoNative {
/** pause / resume / stop against the process's engine, for when there is /** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */ * no WebView to take the usual round trip. */
external fun transport(action: String) 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)
}
}
} }
@@ -239,10 +239,32 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the transport, back out to the WebView ──────────────────────── */ /* ── the transport, back out to the WebView ──────────────────────── */
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { 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") MediaHub.act(if (playWhenReady) "play" else "pause")
return awaitUpdate() 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 /** Nothing to prepare: the Rust side sources a track on its own, and
* `getState` never reports STATE_IDLE while there is one. Answered * `getState` never reports STATE_IDLE while there is one. Answered
* rather than left to throw, because the default body of every * 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.playing = true
np.hasNext = queued > 1 np.hasNext = queued > 1
np.hasPrev = false 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) 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, * `prev()` seeks to zero. The gate is the COMMAND being advertised,
* and nothing else. */ * and nothing else. */
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { 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) { when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next") 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") 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() return awaitUpdate()
} }
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Shuffle, for the car's playback actions. White and flat on purpose: Android
Auto tints these itself and draws them at one size, and anything with its own
colour or gradient comes out wrong against the car's day/night switch. The
app's own animated glyphs must never reach this surface — SA-1 in the quality
checklist forbids animation on a car screen.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6zM10,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp" android:height="36dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Named by `com.google.android.gms.car.application.theme` in this module's
AndroidManifest.xml. Android Auto reads the colour attributes out of it
at connection time; nothing else in the app uses this style, and the car
never sees the phone's theme.
The parent is a framework theme on purpose: this is resolved inside the
car's process against the platform, so an AppCompat or Material parent is
not guaranteed to be there.
-->
<style name="SoulStreamCarTheme" parent="android:Theme.DeviceDefault">
<item name="android:colorPrimary">@color/ss_car_ground</item>
<item name="android:colorPrimaryDark">@color/ss_car_ground</item>
<item name="android:colorAccent">@color/ss_car_accent</item>
</style>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The falcon's teal, from the desktop icon.
Android Auto allows a media app exactly ONE colour pair, chosen at build
time, and it cannot follow the artwork — so where the app derives an
accent per cover, the car has to have a constant, and the constant is the
brand. It tints the FAB and the ring around play/pause, and nothing else.
-->
<color name="ss_car_accent">#31C0CB</color>
<!-- The app's own ground, so the car's chrome sits on SoulStream's dark
rather than the framework's default grey. -->
<color name="ss_car_ground">#05080B</color>
</resources>
+71
View File
@@ -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.
+40 -1
View File
@@ -355,7 +355,7 @@ pub(crate) fn resolve_playable(
}; };
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> { let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
let mut stmt = conn.prepare( 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 FROM tracks t JOIN albums a ON a.id = t.album_id
WHERE t.album_id = ?1 WHERE t.album_id = ?1
ORDER BY t.disc_no, t.track_no, t.title", ORDER BY t.disc_no, t.track_no, t.title",
@@ -369,6 +369,7 @@ pub(crate) fn resolve_playable(
artist: r.get(3)?, artist: r.get(3)?,
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64), duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
album: r.get(5)?, 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()) .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 /// `AutoNative.transport(action)` — pause / resume / stop against the
/// process's engine, for the card's own buttons when no WebView is alive to /// process's engine, for the card's own buttons when no WebView is alive to
/// take the usual round trip. /// take the usual round trip.
@@ -515,6 +527,33 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_transport(
"pause" => audio.player.pause(), "pause" => audio.player.pause(),
"resume" => audio.player.resume(), "resume" => audio.player.resume(),
"stop" => audio.player.stop(), "stop" => audio.player.stop(),
"next" => {
audio.skip(1);
}
"prev" => {
audio.skip(-1);
}
_ => warn(&format!("auto transport: unknown action {a}")), _ => 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())
}
+144 -1
View File
@@ -45,6 +45,11 @@ pub struct PlayTrack {
pub artist: String, pub artist: String,
pub album: String, pub album: String,
pub duration: Option<f64>, pub duration: Option<f64>,
/// 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<String>,
} }
#[derive(Default)] #[derive(Default)]
@@ -120,8 +125,17 @@ impl Audio {
q.items.get(q.idx).cloned() 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 { 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 // 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 // in place means a later `advance` finds the index past the end and
// does nothing, rather than wrapping round to track one. // 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 /// 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 /// 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. /// 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 /// 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 /// the library read-only, with the rest of its album behind it so the record
/// keeps going. /// keeps going.
@@ -174,6 +316,7 @@ pub fn play_media_id(data_dir: &str, media_id: &str) -> String {
"artist": t.artist, "artist": t.artist,
"album": t.album, "album": t.album,
"duration": t.duration, "duration": t.duration,
"art": t.art,
}) })
.to_string(), .to_string(),
None => format!("{{\"queued\":{n}}}"), None => format!("{{\"queued\":{n}}}"),