android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,344 @@
|
||||
package com.soulstream.mediasession
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The `Player` media3 shows the world, over an engine it cannot see.
|
||||
*
|
||||
* rodio owns playback, in Rust, and Kotlin has never touched a sample — so
|
||||
* there is no `ExoPlayer` here and there never will be. `SimpleBasePlayer`
|
||||
* is media3's supported answer to exactly that shape: implement
|
||||
* [getState] as a pure read of whatever the real engine last said, route
|
||||
* every transport call into the engine, and call [invalidateState] when the
|
||||
* engine answers. `MediaHub.state` is that "whatever it last said" — the
|
||||
* last `NowPlayingArgs` the WebView pushed across the bridge — and
|
||||
* `MediaHub.act` is the way back out to it.
|
||||
*
|
||||
* The opt-in is `androidx.annotation.OptIn`, imported by name because
|
||||
* `kotlin.OptIn` is in every file's default scope and would otherwise win —
|
||||
* and written `markerClass = [...]` because that is the attribute's real
|
||||
* name in either reading of it. `@UnstableApi` is `RetentionPolicy.CLASS`
|
||||
* plus androidx's own `RequiresOptIn`, which makes it a LINT error and not a
|
||||
* compiler one: the debug build this project has always made never runs
|
||||
* lint, `lintVitalArm64Release` would, and the annotation keeps that honest
|
||||
* either way.
|
||||
*/
|
||||
@OptIn(markerClass = [UnstableApi::class])
|
||||
class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||
companion object {
|
||||
/** How long a transport command may wait for the WebView before the
|
||||
* card is allowed to snap back to the truth. See [awaitUpdate]. */
|
||||
const val ACT_TIMEOUT_MS = 2500L
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
/** Commands sent out to the WebView and not yet answered. Main thread only. */
|
||||
private val pending = ArrayList<SettableFuture<Void>>()
|
||||
private val expire = Runnable { settle() }
|
||||
private var released = false
|
||||
|
||||
/* ── the state media3 reads ──────────────────────────────────────── */
|
||||
|
||||
override fun getState(): SimpleBasePlayer.State {
|
||||
val np = MediaHub.state
|
||||
?: return SimpleBasePlayer.State.Builder()
|
||||
.setAvailableCommands(commands(null))
|
||||
.setPlaybackState(Player.STATE_IDLE)
|
||||
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
|
||||
.build()
|
||||
|
||||
/* THE PLAYLIST MUST NEVER BE EMPTY WHILE A TRACK EXISTS.
|
||||
MediaNotificationManager.shouldShowNotification returns false the
|
||||
moment `controller.getCurrentTimeline().isEmpty()`, and it says
|
||||
nothing anywhere when it does — the card simply never appears.
|
||||
Today's frontend cannot send an empty window with a track on it:
|
||||
media.svelte.js builds `win = []` only when `player.qi < 0`, and
|
||||
`player.track` is `queue[qi]`, so that same state sends `clear`
|
||||
instead. The one-item fallback is here because the failure it
|
||||
guards is invisible and the guard is three lines: a frontend older
|
||||
or newer than this plugin, or a `queue` that did not survive the
|
||||
two JSON hops, would otherwise take the card down silently. */
|
||||
val window = np.queue ?: emptyList()
|
||||
var at = np.queueIndex ?: -1
|
||||
val items = ArrayList<SimpleBasePlayer.MediaItemData>()
|
||||
if (window.isEmpty() || at < 0 || at >= window.size) {
|
||||
at = 0
|
||||
items.add(item("solo", null, np.title, np.artist, np.album, np.duration, null, true))
|
||||
} else {
|
||||
for (i in window.indices) {
|
||||
val q = window[i]
|
||||
/* A STABLE uid. `queueBase` is where the window starts in the
|
||||
whole queue, so an entry keeps its identity as the window
|
||||
slides past it and media3 reports one media-item transition
|
||||
per track change rather than re-labelling all twenty-one.
|
||||
Without it (an older frontend across the bridge) fall back
|
||||
to the slot, which is unique within one playlist — that is
|
||||
all setPlaylist demands — at the cost of a rebuild. */
|
||||
val base = np.queueBase
|
||||
val uid = if (base != null) "q" + (base + i) else "w" + i + ":" + (q.id ?: "")
|
||||
items.add(item(uid, q.id, q.title, q.artist, q.album, q.duration, q.artKey, i == at))
|
||||
}
|
||||
}
|
||||
|
||||
val posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
||||
return SimpleBasePlayer.State.Builder()
|
||||
.setAvailableCommands(commands(np))
|
||||
/* STATE_READY even when paused. shouldShowNotification treats
|
||||
STATE_IDLE as "nothing to show" unless it has been prepared and
|
||||
stopped, so a paused player reporting IDLE loses its card.
|
||||
There is no buffering state to report either: media.svelte.js
|
||||
already folds `buffering` into `playing`. */
|
||||
.setPlaybackState(Player.STATE_READY)
|
||||
.setPlayWhenReady(np.playing, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||
.setPlaylist(items)
|
||||
.setCurrentMediaItemIndex(at)
|
||||
/* The long overload, not a PositionSupplier: State's own
|
||||
constructor turns it into PositionSupplier.getExtrapolating
|
||||
when playWhenReady and STATE_READY, and getConstant otherwise —
|
||||
which is precisely "the card ticks while playing and holds
|
||||
while paused", for free and in one place. */
|
||||
.setContentPositionMs(posMs)
|
||||
/* The app's numbering is 0 off · 1 ALL · 2 ONE; media3's is
|
||||
0 OFF · 1 ONE · 2 ALL. They are SWAPPED, and mapping them by
|
||||
value would silently turn repeat-all into repeat-one. It is
|
||||
mirrored at all so the player's state is not a lie to whatever
|
||||
reads it — the system's own media controls now, Android Auto
|
||||
later — and so media3's view of "is there a next item" agrees
|
||||
with the hasNext the next button is advertised on. The BUTTON
|
||||
does not hang on it: BasePlayer forwards a seek it cannot
|
||||
resolve rather than dropping it (ignoreSeek -> handleSeek with
|
||||
C.INDEX_UNSET), so an advertised command always arrives. */
|
||||
.setRepeatMode(
|
||||
when (np.repeat) {
|
||||
1 -> Player.REPEAT_MODE_ALL
|
||||
2 -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
},
|
||||
)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** One row of the window as media3 wants it. `current` is the one whose
|
||||
* cover rides along as bytes; the rest name a file and media3's own
|
||||
* BitmapLoader reads it if anything ever asks. */
|
||||
private fun item(
|
||||
uid: String,
|
||||
id: String?,
|
||||
title: String?,
|
||||
artist: String?,
|
||||
album: String?,
|
||||
duration: Double?,
|
||||
artKey: String?,
|
||||
current: Boolean,
|
||||
): SimpleBasePlayer.MediaItemData {
|
||||
val md = MediaMetadata.Builder()
|
||||
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
||||
.setArtist(artist ?: "")
|
||||
.setAlbumTitle(album ?: "")
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
if (current) {
|
||||
/* The cover, or the app's own icon. `artBytes` is cleared the
|
||||
moment `artKey` changes and only refilled once the frontend has
|
||||
decoded one — and a track with no cover never refills it — so
|
||||
without the fallback every track change has a window where the
|
||||
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)
|
||||
} 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
|
||||
times, off a queue nothing is looking at. */
|
||||
val f = File(artKey)
|
||||
if (f.isAbsolute) md.setArtworkUri(Uri.fromFile(f))
|
||||
}
|
||||
val meta = md.build()
|
||||
val durUs = if (duration != null && duration > 0) (duration * 1_000_000).toLong() else C.TIME_UNSET
|
||||
return SimpleBasePlayer.MediaItemData.Builder(uid)
|
||||
.setMediaItem(MediaItem.Builder().setMediaId(id ?: uid).setMediaMetadata(meta).build())
|
||||
.setMediaMetadata(meta)
|
||||
.setIsSeekable(true)
|
||||
.setIsDynamic(false)
|
||||
.setDurationUs(durUs)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** What the card, the lock screen and a headset are allowed to ask for.
|
||||
*
|
||||
* COMMAND_GET_TIMELINE and COMMAND_GET_CURRENT_MEDIA_ITEM go together
|
||||
* and neither is optional. The media notification controller reads the
|
||||
* playlist THROUGH the session, and PlayerInfo.filterByAvailableCommands
|
||||
* cuts what it may see down to what its commands allow: one window
|
||||
* without COMMAND_GET_TIMELINE, and Timeline.EMPTY without either. An
|
||||
* empty timeline on the far side is shouldShowNotification refusing the
|
||||
* card — the same silent failure as an empty playlist, from the other
|
||||
* direction.
|
||||
*
|
||||
* Both spellings of next and previous, because
|
||||
* DefaultMediaNotificationProvider.getMediaButtons SHOWS the button on
|
||||
* containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) and
|
||||
* then sets the button's command to the _MEDIA_ITEM one: advertise only
|
||||
* the plain one and the button appears and does nothing.
|
||||
*
|
||||
* Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS
|
||||
* and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android
|
||||
* Auto's job and needs a window-index → queue-index mapping; `queueBase`
|
||||
* is half of it and it lands with the round that uses it. */
|
||||
private fun commands(np: NowPlayingArgs?): Player.Commands {
|
||||
val b = Player.Commands.Builder()
|
||||
.add(Player.COMMAND_PLAY_PAUSE)
|
||||
.add(Player.COMMAND_PREPARE)
|
||||
.add(Player.COMMAND_STOP)
|
||||
.add(Player.COMMAND_RELEASE)
|
||||
.add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
|
||||
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_GET_TIMELINE)
|
||||
.add(Player.COMMAND_GET_METADATA)
|
||||
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
|
||||
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
|
||||
if (np != null) {
|
||||
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
|
||||
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
|
||||
b.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
b.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
}
|
||||
return b.build()
|
||||
}
|
||||
|
||||
/* ── the transport, back out to the WebView ──────────────────────── */
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
MediaHub.act(if (playWhenReady) "play" else "pause")
|
||||
return awaitUpdate()
|
||||
}
|
||||
|
||||
/** Nothing to prepare: the Rust side sources a track on its own, and
|
||||
* `getState` never reports STATE_IDLE while there is one. Answered
|
||||
* rather than left to throw, because the default body of every
|
||||
* `handle*` is `throw IllegalStateException`. */
|
||||
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
MediaHub.act("stop")
|
||||
return awaitUpdate()
|
||||
}
|
||||
|
||||
override fun handleRelease(): ListenableFuture<*> {
|
||||
released = true
|
||||
main.removeCallbacks(expire)
|
||||
val batch = ArrayList(pending)
|
||||
pending.clear()
|
||||
/* posted, not completed here: finishing a future re-enters media3's
|
||||
own listener, and this one is already inside a player call */
|
||||
main.post { for (f in batch) f.set(null) }
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
/** A no-op that must exist. `setAudioAttributes(attrs, handleAudioFocus)`
|
||||
* is an ExoPlayer convenience: on SimpleBasePlayer it lands HERE, whose
|
||||
* default body throws, and media3 does none of the focus work for a
|
||||
* custom player — `AudioFocusManager` is inside media3-exoplayer and is
|
||||
* not public API. Focus is still ours, requested and abandoned by hand
|
||||
* in MediaService, exactly as it was before the migration. */
|
||||
override fun handleSetAudioAttributes(
|
||||
audioAttributes: AudioAttributes,
|
||||
handleAudioFocus: Boolean,
|
||||
): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
/** Every seek, next and previous arrives here — there is no
|
||||
* `handleSeekToNext` and never was — distinguished by the command that
|
||||
* triggered it. The index is ignored on purpose: the app's own `next()`
|
||||
* and `prev()` know about repeat, about the five-second restart rule and
|
||||
* about the end of the queue, and the window is only ever a view.
|
||||
*
|
||||
* It arrives even when media3 has nothing to seek TO: `BasePlayer`
|
||||
* forwards such a press rather than dropping it, as
|
||||
* `ignoreSeek` -> `handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)`.
|
||||
* Which is why the previous button still works at the very head of a
|
||||
* queue, where the timeline has no previous item but the app's own
|
||||
* `prev()` seeks to zero. The gate is the COMMAND being advertised,
|
||||
* and nothing else. */
|
||||
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
||||
when (seekCommand) {
|
||||
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")
|
||||
else -> MediaHub.act("seek", if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0)
|
||||
}
|
||||
return awaitUpdate()
|
||||
}
|
||||
|
||||
/* ── the round trip ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A transport command is a round trip: out through `Plugin.trigger` to
|
||||
* the WebView, into the player, down to Rust, and back as the next
|
||||
* `update`. Answering media3 immediately would have it call [getState]
|
||||
* on the spot — which still reports the OLD state — and the lock screen
|
||||
* flips back before it flips forward.
|
||||
*
|
||||
* So hand it a future nobody has completed. While one is outstanding
|
||||
* media3 shows its own suggested placeholder (play-pause flipped, the
|
||||
* seek applied), which is exactly the optimistic answer we want, and
|
||||
* [invalidateState] deliberately does nothing until the last one
|
||||
* resolves. `MediaHub.update` resolves them; [expire] resolves them
|
||||
* anyway after [ACT_TIMEOUT_MS], because a future that is never
|
||||
* completed does not degrade the card — it FREEZES it.
|
||||
*/
|
||||
private fun awaitUpdate(): ListenableFuture<*> {
|
||||
if (released) return Futures.immediateVoidFuture()
|
||||
val f = SettableFuture.create<Void>()
|
||||
pending.add(f)
|
||||
main.removeCallbacks(expire)
|
||||
main.postDelayed(expire, ACT_TIMEOUT_MS)
|
||||
return f
|
||||
}
|
||||
|
||||
/** The engine answered (or stopped answering). Main thread only — every
|
||||
* caller is `MediaHub`'s own post to the main looper, or [expire]. */
|
||||
fun settle() {
|
||||
if (pending.isEmpty()) return
|
||||
main.removeCallbacks(expire)
|
||||
val batch = ArrayList(pending)
|
||||
pending.clear()
|
||||
/* completing these re-reads getState() on this same thread, inline */
|
||||
for (f in batch) f.set(null)
|
||||
}
|
||||
|
||||
/** `invalidateState` is `protected final`; this is the door MediaHub
|
||||
* uses. A no-op once released, and a no-op inside media3 anyway while
|
||||
* anything is still pending. */
|
||||
fun refresh() {
|
||||
if (released) return
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
fun markReleased() {
|
||||
released = true
|
||||
main.removeCallbacks(expire)
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user