android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over

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 `<data>/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
`<data>/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.
This commit is contained in:
KeelrLeek committed 2026-09-18 12:35:54 -05:00
1 parent 8f46f9c663
commit 17942fc04b
28 files changed
+2600 -396

No files matched your search

@@ -49,6 +49,26 @@
</intent-filter>
</service>
<!-- media3's own media-button receiver, and the reason the app can
start COLD from a PLAY key: a Bluetooth head unit's play button,
the Quick Settings "resume" card after a reboot, and Android
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
at a process that does not exist. The receiver starts the
service in the foreground (MediaService.onStartCommand promotes
at once, see holdForeground) and media3 then asks
LibraryTree.onPlaybackResumption what to play. It forwards
ONLY play / play-pause / headset-hook into a dead app — a next or
a pause into nothing is dropped, not started. While the app is
alive the framework talks to the session directly and the
receiver is not involved. -->
<receiver
android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
@@ -115,16 +115,24 @@ import org.json.JSONArray
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.
* Play a browse row THROUGH THE APP: the page owns the queue while it
* exists (shuffle, repeat, failover, the resolver, the session it
* restores), so every car tap is handed to it whole — a library album, a
* playlist row, the resume row — rather than started on the engine
* underneath it, which left the bar naming one track while another
* played. The first argument is the row as `AutoNative.describe` spells
* it for the page; the second the position to start at, or -1.
*
* 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.
* tap; null, and the engine in this process plays what it can itself.
*/
@Volatile var onRemote: ((Long, Long) -> Unit)? = null
@Volatile var onPlay: ((String, Long) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
/** Why the last thing asked for did not play, for the car screen; null
* the moment anything plays. See [fail]. */
@Volatile var error: String? = null
/** the cover as ENCODED bytes — media3 decodes them itself */
@Volatile var artBytes: ByteArray? = null
@Volatile var artKey: String? = null
@@ -199,6 +207,24 @@ object MediaHub {
}
}
state = np
/* something is on the bar again: whatever went wrong last is over —
and the error a legacy controller was SENT is withdrawn explicitly,
because a head unit keeps showing one until told otherwise */
val hadError = error != null
error = null
if (hadError) {
mainHandler.post {
try {
libSession?.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.INFO_CANCELLED, "",
),
)
} catch (_: Exception) {
/* a controller that has gone; nothing to withdraw from */
}
}
}
bind(app)
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
verifyApplicationThread(), a Tauri @Command is not promised to run
@@ -211,9 +237,108 @@ object MediaHub {
because the update that MADE the service found `service` still
null; there is nothing to catch up now. */
if (np.playing) requestFocus(app)
player?.let {
it.settle()
it.refresh()
player?.settle()
refreshSoon()
}
}
/* ── the card's rebuild rate ─────────────────────────────────────────
Every refresh() is a getState(), a diff, a PlayerInfo to every
connected controller (seventeen of them on 2026-09-18, after a day
of head-unit sessions), and — when anything the card shows moved — a
notification re-post, which under media3 mints a fresh PendingIntent
for the custom button each time. This 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", then twenty refused foreground starts in two hundred
milliseconds. Whatever asks that often, the answer is the same: the
card is a mirror, and a mirror that is repainted more than a few
times a second is not showing anything new. So refreshes are
COALESCED — the first lands at once, anything inside the window
folds into one rebuild at the window's end — and counted, so the
next storm has a number beside it in the log instead of a mystery. */
private const val REFRESH_WINDOW_MS = 250L
@Volatile private var refreshPending = false
@Volatile private var lastRefreshAt = 0L
private var refreshes = 0
private var refreshAsks = 0
private var rateSince = 0L
private val refreshNow = Runnable {
refreshPending = false
lastRefreshAt = android.os.SystemClock.uptimeMillis()
refreshes += 1
player?.refresh()
}
/** Ask for a state rebuild. Main thread only. */
fun refreshSoon() {
val now = android.os.SystemClock.uptimeMillis()
refreshAsks += 1
if (rateSince == 0L) rateSince = now
if (now - rateSince >= 10_000L) {
if (refreshAsks > 20) {
android.util.Log.w(
"SoulStream",
"card asked to rebuild " + refreshAsks + "x in 10s (" + refreshes + " done)",
)
}
refreshAsks = 0
refreshes = 0
rateSince = now
}
if (refreshPending) return
val since = now - lastRefreshAt
if (since >= REFRESH_WINDOW_MS) {
refreshNow.run()
} else {
refreshPending = true
mainHandler.postDelayed(refreshNow, REFRESH_WINDOW_MS - since)
}
}
/**
* Something the car (or a headset) asked for could not play: the row
* needs the app and the app is not up, the resolver found no source,
* the network is signed out. Until now every one of those was silence
* on the head unit. This puts the reason where a driver looks — the
* playback state goes to error with the message, which Android Auto
* draws as a card — and offers "Open SoulStream" as the way out, the
* resolution the design asked for.
*
* Cleared by the next [update]: a track playing is the error being over.
*/
fun fail(ctx: Context, message: String) {
val app = ctx.applicationContext
error = message
android.util.Log.i("SoulStream", "auto error shown: " + message)
mainHandler.post {
refreshSoon()
val s = libSession ?: return@post
try {
val extras = android.os.Bundle()
extras.putString(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT,
"Open SoulStream",
)
app.packageManager.getLaunchIntentForPackage(app.packageName)?.let { launch ->
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
extras.putParcelable(
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT,
PendingIntent.getActivity(
app, 7, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
s.sendError(
androidx.media3.session.SessionError(
androidx.media3.session.SessionError.ERROR_INVALID_STATE,
message,
extras,
),
)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "auto error could not be sent: $e")
}
}
}
@@ -232,10 +357,8 @@ object MediaHub {
artKey = null
mainHandler.post {
dropFocus(app)
player?.let {
it.settle()
it.refresh()
}
player?.settle()
refreshSoon()
}
}
@@ -489,12 +612,12 @@ object MediaHub {
onAction?.invoke(action, position)
}
/** True when the app is up and its resolver can be reached. */
fun warm(): Boolean = onRemote != null
/** True when the app is up: the page owns the queue and plays every tap. */
fun warm(): Boolean = onPlay != null
/** Hand a playlist row to the app to play. */
fun remote(playlist: Long, item: Long) {
onRemote?.invoke(playlist, item)
/** Hand a row to the app to play, as `AutoNative.describe` spelled it. */
fun play(desc: String, startMs: Long) {
onPlay?.invoke(desc, startMs)
}
}
@@ -593,9 +716,90 @@ object LibraryTree : MediaLibrarySession.Callback {
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()
// The RECENT root is a different question — "what would you continue?"
// — asked by Android's own resumption surfaces and by Auto. It is
// answered with a root whose one child is the resume row
// (auto_browse.rs RECENT_ROOT), read out of resume.json and never the
// database; a host that gets the library tree here would draw four
// tabs where it wanted one song.
if (params?.isRecent == true) {
return Futures.immediateFuture(LibraryResult.ofItem(recentRootItem(), rootParams))
}
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams))
}
/** The id auto_browse.rs answers the resume row for. */
const val RECENT_ROOT = "ss:recent-root"
private fun recentRootItem(): MediaItem =
MediaItem.Builder()
.setMediaId(RECENT_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Continue")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
/**
* "Pick up where you left off", asked by a PLAY that reaches a player
* with nothing in it: a Bluetooth head unit's button, the Quick Settings
* resume card, Android Auto's autoplay on connection. media3 takes what
* comes back, sets it on the player, prepares and plays — so this
* answers with the resume row (`ss:resume`) and the position, and
* [SoulStreamPlayer.handleSetMediaItems] turns that into the saved queue
* at the saved spot. `isForPlayback` false is the same question asked to
* DRAW a card; the answer is identical and nothing starts.
*
* Off the calling thread: it reads resume.json and stats a few files.
*/
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
isForPlayback: Boolean,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
Futures.submit<MediaSession.MediaItemsWithStartPosition>(
{
val ctx = MediaHub.appContext ?: throw IllegalStateException("no context")
val o = org.json.JSONObject(AutoNative.resumeInfo(ctx.dataDir.absolutePath))
if (!o.optBoolean("ok", false)) {
android.util.Log.i("SoulStream", "auto resume asked, nothing to resume")
throw UnsupportedOperationException("nothing to resume")
}
val md = MediaMetadata.Builder()
.setTitle(o.optString("title"))
.setArtist(o.optString("artist"))
.setAlbumTitle(o.optString("album"))
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
artUri(ctx, art, controller.packageName)?.let { md.setArtworkUri(it) }
val item = MediaItem.Builder()
.setMediaId(o.optString("id", "ss:resume"))
.setMediaMetadata(md.build())
.build()
val pos = o.optLong("positionMs", 0L)
android.util.Log.i(
"SoulStream",
"auto resume " + (if (isForPlayback) "for playback" else "to show") +
": " + o.optString("title") + " at " + pos + "ms",
)
MediaSession.MediaItemsWithStartPosition(ImmutableList.of(item), 0, pos)
},
io,
)
/** The older spelling of the same question, for a host on the old path. */
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
onPlaybackResumption(mediaSession, controller, true)
/**
* 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
@@ -956,7 +1160,21 @@ object LibraryTree : MediaLibrarySession.Callback {
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) }
val extras = styleExtras(id) ?: android.os.Bundle()
// A playlist row whose file is on this phone — a local track,
// or a streamed one whose cache copy is whole — wears the
// host's own "downloaded" mark. It is the design's reach
// signal turned the safe way round: the rows that play with
// the app shut are the marked ones, and the mark thins the
// "needs the app" rows out as more of the list is kept.
val here = id.startsWith("ss:pitem:") && !o.isNull("path") && o.optString("path").isNotEmpty()
if (here) {
extras.putLong(
androidx.media3.session.MediaConstants.EXTRAS_KEY_DOWNLOAD_STATUS,
androidx.media3.session.MediaConstants.EXTRAS_VALUE_STATUS_DOWNLOADED,
)
}
if (!extras.isEmpty) md.setExtras(extras)
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
}
} catch (e: Throwable) {
@@ -1135,8 +1353,27 @@ class MediaService : MediaLibraryService() {
stops. Promoting after that would put a service already on its way
out back into the foreground. A null intent is the system
re-creating a START_STICKY service — not a foreground start, and
it needs nothing. */
if (intent != null) holdForeground()
it needs nothing.
ONLY FOR AN INTENT SOMEBODY ELSE SENT — a media key, a card
button — which is one that carries an action or a data uri.
media3 starts this service ITSELF on every foreground notification
update: `MediaNotificationManager.startForeground` calls
`ContextCompat.startForegroundService(startSelfIntent)`, and that
intent is a bare `Intent(context, MediaService::class.java)` with
nothing but a uid extra — no action, no data. Promoting on THAT
re-posted the notification, which made media3 start the service
again, which landed here again: a loop at the rate two binder
calls allow, about six a second, for as long as the service was
in the foreground — while a track played, and for the ten-minute
tail after it paused. That is the whole of 2026-09-18's storm:
"Package enqueue rate is 6.6 … Shedding" for over a minute,
"Too many PendingIntent created for uid" (media3 mints a fresh
one for the custom button on every rebuild), and twenty refused
foreground starts in two hundred milliseconds once the app was
in the background. The self-start needs nothing from us: media3
promotes in the same breath it sends it. */
if (intent != null && (intent.action != null || intent.data != null)) holdForeground()
return super.onStartCommand(intent, flags, startId)
}
@@ -1260,9 +1497,18 @@ object AutoNative {
* left in silence.
*
* A tapped track brings its album with it, so the record keeps playing
* without any JS alive to advance it.
* without any JS alive to advance it. `startMs` below zero means "where
* it was left" for the resume row and the top for everything else.
*/
external fun play(dataDir: String, mediaId: String): String
external fun play(dataDir: String, mediaId: String, startMs: Long): String
/** What would resume, and where, as JSON (`{"ok":false}` when nothing
* can). Reads resume.json and stats a few files; starts nothing. */
external fun resumeInfo(dataDir: String): String
/** A browse row as the PAGE understands it — `{"kind":"album","albumId":n}`
* and its siblings — for the warm hand-off. Two lookups at most. */
external fun describe(dataDir: String, mediaId: String): String
/** pause / resume / stop against the process's engine, for when there is
* no WebView to take the usual round trip. */
@@ -63,6 +63,13 @@ class QueueItemArgs {
var artKey: String? = null
}
/** Why a row the car handed to the page did not play — the Rust
* `RemoteFailure`, camelCase. */
@InvokeArg
class RemoteFailureArgs {
var message: String? = null
}
/**
* The bridge between the WebView and the service that owns the session.
* `update` mirrors the track on the bar (and starts the service the first
@@ -80,19 +87,34 @@ 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. */
/* The car asking the APP to play a row — any row, while the page is
up and owns the queue. 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 ->
MediaHub.onPlay = { desc, startMs ->
val payload = JSObject()
payload.put("action", "playRemote")
payload.put("playlist", playlist)
payload.put("item", item)
payload.put("action", "playMediaId")
payload.put("desc", desc)
payload.put("startMs", startMs)
trigger("action", payload)
}
}
/** The page is going away — the app swiped out of recents, say — while
* the media service may keep this process alive for the car. From here
* there is nobody behind `trigger`, so the hand-off must stop believing
* the app is warm: a tap in the car would otherwise be posted to a
* WebView that no longer exists and answered by nothing. Null means the
* engine in this process plays what it can and the car is told about the
* rest, exactly as on a cold start. A new Activity builds a new plugin,
* whose init arms both again. */
override fun onDestroy() {
MediaHub.onPlay = null
MediaHub.onAction = null
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
super.onDestroy()
}
@Command
fun update(invoke: Invoke) {
val args = invoke.parseArgs(NowPlayingArgs::class.java)
@@ -126,6 +148,15 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
invoke.resolve()
}
/** The page could not play a row the car handed it. The session says
* why, on the car screen, instead of leaving the driver in silence. */
@Command
fun remoteFailed(invoke: Invoke) {
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
invoke.resolve()
}
/** Android 13+: the card is a notification and needs the permission; asked
* once, the first time something plays. Media controls in the shade and
* on the lock screen follow the session either way. */
@@ -55,6 +55,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* ── the state media3 reads ──────────────────────────────────────── */
override fun getState(): SimpleBasePlayer.State {
/* Something asked for could not play, and the reason is what the
car should show rather than a blank player or the last track's
card. A player error is only legal in STATE_IDLE, and media3's
legacy stub turns exactly that into PlaybackStateCompat's
STATE_ERROR with the message — which Android Auto draws as a card
over the browse list. The playlist is kept (if there was one) so
the transport does not lose its shape; the next update clears it. */
MediaHub.error?.let { why ->
val err = androidx.media3.common.PlaybackException(
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
)
return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
.setPlaybackState(Player.STATE_IDLE)
.setPlayerError(err)
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
.build()
}
val np = MediaHub.state
?: return SimpleBasePlayer.State.Builder()
.setAvailableCommands(commands(null))
@@ -179,8 +197,20 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
lock screen shows a grey hole. media3 decodes these bytes
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
is one BitmapFactory call this file no longer makes. */
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
val bytes = MediaHub.artBytes
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
if (bytes != null) {
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (remote != null) {
/* A streamed record's cover is a URL and no bytes ever arrive
for it on the headless path — there is no page to decode
one. media3's own loader fetches a URI, so the car gets
the sleeve and the colour it washes the screen with,
instead of the app icon's blue. */
md.setArtworkUri(Uri.parse(remote))
} else {
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
}
} else if (!artKey.isNullOrEmpty()) {
/* A LOCAL path only. A catalog record's art key is an http URL,
and media3's default loader would happily fetch it — twenty
@@ -256,7 +286,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
"SoulStream",
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
)
if (headless()) {
if (headless() || !MediaHub.warm()) {
/* the engine's own queue, or a page that is gone (the app swiped
away under live audio): pause and resume belong to the engine
either way, and a message to a WebView that no longer exists
is the button doing nothing */
AutoNative.transport(if (playWhenReady) "resume" else "pause")
return Futures.immediateVoidFuture()
}
@@ -312,8 +346,26 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
val id = mediaItems.getOrNull(i)?.mediaId
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
// The position media3 asks for: a resumption carries the saved one, a
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
// left", which only the resume row has an answer to.
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
return Futures.submit<Unit>(
{
// THE APP IS UP: the page owns the queue, so the tap is the
// page's — whatever the row is. Starting it on the engine
// underneath the page instead (which is what this did for a
// library album while the page had a queue of its own) left
// two drivers on one engine: the bar naming one track while
// another played, and the next boundary decided by whichever
// got there first. The page asks for focus itself when it
// plays, and reports back through remote_failed if it cannot.
if (MediaHub.warm()) {
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
MediaHub.play(desc, startMs)
return@submit
}
// FOCUS FIRST, and it is not optional. Without it the track
// opens into a port the framework keeps muted: the first run
// of this path showed a live AAudio track on our uid with
@@ -321,25 +373,27 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
// audible to nobody. On the WebView path the JS asks for
// focus; a car tapping a cold process has no JS to do it.
MediaHub.requestFocus(ctx.applicationContext)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
val queued = o.optInt("queued", 0)
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
if (queued == 0) {
// 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"))
// Nothing on this phone can play it, and the process is
// cold: the focus goes back rather than being held for
// silence — and the car is TOLD, which it never was
// before. A streamed row with no cache copy needs the
// resolver, which needs the app.
MediaHub.dropFocus(ctx.applicationContext)
if (o.optBoolean("remote", false)) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
MediaHub.fail(
ctx.applicationContext,
"This track streams from the network. Open SoulStream on your phone to play it here.",
)
} else if (id == "ss:resume") {
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
} else {
if (remote) {
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
}
MediaHub.dropFocus(ctx.applicationContext)
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
}
} else {
// Write the card ourselves. `getState()` mirrors
@@ -354,9 +408,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
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
/* a resumed track starts where it was left; the engine's
own position takes over from the first tick */
np.position = o.optLong("positionMs", 0L) / 1000.0
np.playing = true
np.hasNext = queued > 1
np.hasNext = o.optBoolean("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,
@@ -379,6 +435,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
}
override fun handleStop(): ListenableFuture<*> {
if (headless() || !MediaHub.warm()) {
AutoNative.transport("stop")
return Futures.immediateVoidFuture()
}
MediaHub.act("stop")
return awaitUpdate()
}
@@ -420,7 +480,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
* 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()) {
if (headless() || !MediaHub.warm()) {
when (seekCommand) {
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
AutoNative.transport("next")