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:
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
|
||||
|
||||
+267
-21
@@ -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. */
|
||||
|
||||
+38
-7
@@ -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. */
|
||||
|
||||
+81
-21
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user