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.
345 lines
17 KiB
Plaintext
345 lines
17 KiB
Plaintext
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()
|
|
}
|
|
}
|