android auto: a catalog row is handed to the app, not failed at

Every entry in a streamed playlist is `kind='stream'` with no file on this
phone — only a catalog id. A service-only process cannot turn that into audio:
the resolver is hundreds of lines of shared JS that exist while a WebView does
and not otherwise. So the car tapped those rows and nothing happened, which is
the worst of the three possible answers.

Now the process asks whether the APP can do it instead. Rust, finding nothing
local, reports WHICH playlist and entry were meant rather than just failing —
a catalog row is not a broken row, it is one this process cannot do alone, and
that difference is what decides between an error and a hand-off. Kotlin carries
it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded,
which is only while a WebView exists: the same fact is both the channel and the
test for "is the app warm". And the JS plays it with `playFrom(playlist, items,
item)` — the exact call the half-sheet makes when the same row is tapped in the
app, so the queue, the provenance and the failure message are the app's own.
Nothing about the car is special once it arrives.

Cold, it says so instead of pretending: the focus is handed back rather than
held for silence, and the log names the row. A phone in a pocket genuinely
cannot play a Deezer id, and a tap that quietly does nothing is worse than one
that stops.

Two things the same change fixed on the way past. A streamed entry has no cover
FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their
sleeves and a remote uri now goes to the host untouched instead of through a
FileProvider whose root check it could never pass. And Recent, playlists and an
artist's albums join Albums as GRIDS — all four are things known by the picture,
which is what the grid is for.

`probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at
all: the script force-stops SoulStream, which is precisely the app the hand-off
needs alive.

**Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm
answers `auto hand-off to the app` and the event reaches the JS with no console
error. **Not proven:** that the audio then starts. Those Deezer ids have to go
through the resolver exactly as they do when tapped in the app, and if the app
cannot play them the car will not either — which is correct behaviour and
indistinguishable, from here, from a bridge that silently failed. It wants
checking with the app in front of you.
This commit is contained in:
KeelrLeek committed 2026-09-15 17:01:43 -05:00
1 parent 54245a33b5
commit 8f46f9c663
8 files changed
+192 -9

No files matched your search

@@ -114,6 +114,16 @@ import org.json.JSONArray
@OptIn(markerClass = [UnstableApi::class])
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
/**
* Play something this process cannot resolve on its own — a catalog or
* Soulseek row — by handing it to the app, which has the resolver.
*
* Non-null ONLY while the plugin is loaded, which means only while a
* WebView exists. That is exactly the test for "is the app warm", so it
* doubles as the discriminator: set, and the car's tap becomes the app's
* tap; null, and there is nothing on this phone that could play it.
*/
@Volatile var onRemote: ((Long, Long) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
/** the cover as ENCODED bytes — media3 decodes them itself */
@Volatile var artBytes: ByteArray? = null
@@ -478,6 +488,14 @@ object MediaHub {
fun act(action: String, position: Double = -1.0) {
onAction?.invoke(action, position)
}
/** True when the app is up and its resolver can be reached. */
fun warm(): Boolean = onRemote != null
/** Hand a playlist row to the app to play. */
fun remote(playlist: Long, item: Long) {
onRemote?.invoke(playlist, item)
}
}
/**
@@ -607,7 +625,15 @@ object LibraryTree : MediaLibrarySession.Callback {
* 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:")
// Anything whose children are SLEEVES. Recent is albums and
// playlists, a playlist is covers per track, and an artist's children
// are records — all of them are known by the picture, which is the
// whole reason the grid exists.
val grid =
id == "ss:albums" ||
id == "ss:recent" ||
id.startsWith("ss:artist:") ||
id.startsWith("ss:playlist:")
if (!grid) return null
val b = android.os.Bundle()
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
@@ -814,6 +840,19 @@ object LibraryTree : MediaLibrarySession.Callback {
*/
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
if (path.isNullOrEmpty()) return null
// A REMOTE cover is already a uri and needs no provider and no grant —
// the host fetches it. Streamed playlist entries have no cover file at
// all, only a URL, so routing one through FileProvider would fail the
// root check and lose the art of every catalog row.
if (path.startsWith("http://") || path.startsWith("https://") ||
path.startsWith("content://")
) {
return try {
android.net.Uri.parse(path)
} catch (e: Throwable) {
null
}
}
return try {
val uri =
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
@@ -80,6 +80,17 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
payload.put("position", position)
trigger("action", payload)
}
/* The car asking the APP to play something this process cannot
resolve. Setting this is also what tells the media service the app
is warm — it is null whenever there is no WebView. */
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
MediaHub.onRemote = { playlist, item ->
val payload = JSObject()
payload.put("action", "playRemote")
payload.put("playlist", playlist)
payload.put("item", item)
trigger("action", payload)
}
}
@Command
@@ -326,9 +326,21 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
if (queued == 0) {
// Nothing resolved: give the focus back rather than
// holding another app's audio hostage for silence.
MediaHub.dropFocus(ctx.applicationContext)
// Nothing local. If the app is up, its resolver can play
// this — a catalog row is not a broken row, it is one this
// process cannot do alone. If the app is NOT up there is
// nothing on the phone that could play it, and the focus
// goes back rather than being held for silence.
val remote = o.optBoolean("remote", false)
if (remote && MediaHub.warm()) {
android.util.Log.i("SoulStream", "auto hand-off to the app: $id")
MediaHub.remote(o.optLong("playlist"), o.optLong("item"))
} else {
if (remote) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
}
MediaHub.dropFocus(ctx.applicationContext)
}
} else {
// Write the card ourselves. `getState()` mirrors
// MediaHub.state, and the only thing that ever fills it is