android auto: the card says what the engine is doing, and a skip no longer leaves the old song playing

Reaching a row whose bytes are not on the phone read as the player going
to the next track and freezing — the title changed, the position sat at
0:00, nothing said why — and after a skip the old song kept sounding under
the new title until the fetch delivered, because start_at notified the
card and spawned the resolver without stopping the engine (only the
natural boundary was silent).

The engine stops before it looks now: silence, the title, a spinner. The
card is built buffering from its first frame (headlessTrack carries
`resolving`), and the engine narrates on the artist line — "Signing in to
Soulseek…", "Searching Soulseek…", "45 copies · connecting to <peer>…",
"Streaming from <peer>…", "<peer> isn't sending · trying another copy…",
"No copy found · trying the next track…" — through headlessState, kept in
NowPlayingArgs.status and shown by SoulStreamPlayer while buffering; the
engine's own Playing event clears it. The first tap's reply builds a fresh
state after the JNI call the engine narrated inside, so it carries the
line over rather than showing the artist for a beat.

Verified on the head unit with a three-row test playlist: a cold tap reads
"Searching Soulseek…" at 0:00 and plays at 0:14 with its cover; a skip
into a row not fetched yet reads "Searching Soulseek…", then "Streaming
from nouvellevag0…", then the artist again at 0:07, with the prefetch
fetching the row after it.
This commit is contained in:
KeelrLeek committed 2026-09-18 15:44:25 -05:00
1 parent 9f7661afab
commit bb7f1f1b2d
5 files changed
+82 -9

No files matched your search

@@ -1608,11 +1608,17 @@ object AutoNative {
try {
val ctx = MediaHub.appContext ?: return
val o = org.json.JSONObject(json)
val message = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
when (o.optString("state")) {
"buffering" -> MediaHub.state?.let { it.buffering = true; MediaHub.update(ctx, it) }
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; MediaHub.update(ctx, it) }
"buffering" -> MediaHub.state?.let {
it.buffering = true
if (message != null) it.status = message
MediaHub.update(ctx, it)
}
"playing" -> MediaHub.state?.let { it.buffering = false; it.playing = true; it.status = null; MediaHub.update(ctx, it) }
"failed" -> {
MediaHub.state?.buffering = false
MediaHub.state?.status = null
val why = if (o.isNull("message")) null else o.optString("message").ifEmpty { null }
MediaHub.fail(ctx, why ?: "SoulStream could not play that.")
}
@@ -1689,11 +1695,16 @@ object AutoNative {
np.playing = true
np.hasNext = o.optBoolean("hasNext", false)
np.hasPrev = true
/* a row whose bytes are not here yet: BUFFERING from the first
frame, with a line that says so, rather than "playing at
0:00" for a beat before the spinner */
np.buffering = o.optBoolean("resolving", false)
np.status = if (np.buffering) "Finding a copy…" else null
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)
android.util.Log.i("SoulStream", "auto advanced to " + np.title + (if (np.buffering) " (resolving)" else ""))
} catch (e: Throwable) {
android.util.Log.w("SoulStream", "auto advance card failed: " + e)
}
@@ -32,6 +32,12 @@ class NowPlayingArgs {
* the car shows a spinner rather than a position. Never sent by the
* page — its own player folds buffering into `playing`. */
var buffering: Boolean = false
/** what the engine is doing while `buffering` — "Searching Soulseek…",
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
* on the card's artist line until the bytes sound. A card that went
* to the next title and sat at 0:00 read as the player freezing
* (2026-09-18); this is the difference. */
var status: String? = null
/** the queue around the current item — it and up to ten either side.
* Since round forty-eight SoulStreamPlayer.getState() turns this into
* the media3 playlist the card is built from, and the index into it.
@@ -188,9 +188,13 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
artKey: String?,
current: Boolean,
): SimpleBasePlayer.MediaItemData {
/* while the engine is finding the current row's bytes, its status
line rides the artist line — the one line the car shows under
the title */
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
val md = MediaMetadata.Builder()
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
.setArtist(artist ?: "")
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
.setAlbumTitle(album ?: "")
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -425,6 +429,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
/* a cold fetch: the title now, a spinner until the first
bytes play (headlessState turns it off) */
np.buffering = o.optBoolean("resolving", false)
/* the engine narrated inside the call that just returned
(headlessState ran before this line): keep its line,
or the first one, rather than the artist for a beat */
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
np.hasNext = o.optBoolean("hasNext", queued > 1)
np.hasPrev = false
// The cover, which is also the colour: Android Auto washes