package dev.stave.playback
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands
import androidx.media3.session.SessionResult
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import dev.stave.R
import dev.stave.data.SettingsStore
import dev.stave.data.toDomain
import dev.stave.domain.model.DidlItem
import dev.stave.upnp.BrowseResult
import dev.stave.upnp.ContentDirectoryClient
import dev.stave.upnp.UpnpServiceHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Holds the ExoPlayer and a MediaLibrarySession. Extends [MediaLibraryService]
* (which IS a MediaSessionService) so that:
*
* 1. The TV launcher and notification get the same transport buttons as before.
* 2. Android Auto can browse the UPnP library tree and play tracks by
* discovering this service through the manifest's media-browser intent filter.
*
* The browse callback bridges the UPnP ContentDirectory (via [ContentDirectoryClient])
* to the MediaItem tree that Auto's built-in media UI expects. Each container
* becomes a browsable MediaItem; each track becomes a playable MediaItem whose
* mediaId holds the UPnP object ID and whose requestMetadata holds the HTTP URL
* for playback.
*
* Android Auto integration notes:
* - When no server is preferred, the browse tree shows available servers as top-level
* items. Selecting one sets it as preferred and refreshes the tree.
* - onSearch delegates to the UPnP ContentDirectory's Search action.
* - The service starts UPnP discovery independently of the Activity so Auto can
* browse even when the TV UI isn't running.
*/
/** Notification id for the casting keep-alive foreground notification.
* Top-level (not in the class companion) because PlaybackService's
* companion object holds the nav/CMD constants referenced app-wide —
* a second companion is illegal and broke every one of those refs. */
private const val CASTING_NOTIFICATION_ID = 41042
/** Notification id for the OBSERVING keep-alive foreground notification —
* raw-cast follow mode. Distinct from the casting id so transitions between
* the two modes replace rather than leak notifications. */
private const val OBSERVING_NOTIFICATION_ID = 41043
class PlaybackService : MediaLibraryService() {
private var player: ExoPlayer? = null
// ── Casting keep-alive state ──────────────────────────────────────
private var castingWifiLock: android.net.wifi.WifiManager.WifiLock? = null
private var castingWakeLock: android.os.PowerManager.WakeLock? = null
private fun enterCastingKeepAlive() {
// Foreground promotion — a minimal, silent, low-priority
// notification. Media3 isn't providing one because the local
// player is paused during cast.
val channelId = "earhorn_casting"
val nm = getSystemService(android.app.NotificationManager::class.java)
if (android.os.Build.VERSION.SDK_INT >= 26 &&
nm.getNotificationChannel(channelId) == null
) {
nm.createNotificationChannel(
android.app.NotificationChannel(
channelId, "Casting",
android.app.NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
)
}
val notif = if (android.os.Build.VERSION.SDK_INT >= 26) {
android.app.Notification.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notif_cast_connected)
.setContentTitle("Casting")
.setContentText("Keeping the stream alive for your speakers")
.setOngoing(true)
.build()
} else {
@Suppress("DEPRECATION")
android.app.Notification.Builder(this)
.setSmallIcon(R.drawable.ic_notif_cast_connected)
.setContentTitle("Casting")
.setContentText("Keeping the stream alive for your speakers")
.setOngoing(true)
.setPriority(android.app.Notification.PRIORITY_LOW)
.build()
}
runCatching {
if (android.os.Build.VERSION.SDK_INT >= 29) {
startForeground(
CASTING_NOTIFICATION_ID, notif,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(CASTING_NOTIFICATION_ID, notif)
}
}
// Network + CPU locks: the proxy must keep serving bytes with the
// screen off and Wi-Fi power-saving active.
if (castingWifiLock == null) {
val wm = applicationContext.getSystemService(android.net.wifi.WifiManager::class.java)
castingWifiLock = wm?.createWifiLock(
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF, "earhorn:castProxy",
)
}
if (castingWakeLock == null) {
val pm = getSystemService(android.os.PowerManager::class.java)
castingWakeLock = pm?.newWakeLock(
android.os.PowerManager.PARTIAL_WAKE_LOCK, "earhorn:castProxy",
)
}
runCatching { if (castingWifiLock?.isHeld != true) castingWifiLock?.acquire() }
runCatching { if (castingWakeLock?.isHeld != true) castingWakeLock?.acquire() }
}
private fun exitCastingKeepAlive() {
runCatching { if (castingWifiLock?.isHeld == true) castingWifiLock?.release() }
runCatching { if (castingWakeLock?.isHeld == true) castingWakeLock?.release() }
// Drop our foreground claim only if local playback isn't running —
// if the session handed back to local play, Media3's own media
// notification takes over foreground duty. Same courtesy for
// observing: if the user hopped from casting to following, the
// observing keep-alive now owns foreground and we must not yank it.
val locallyPlaying = runCatching { player?.isPlaying == true }.getOrDefault(false)
val observing = runCatching { rawCast.observing.value }.getOrDefault(false)
if (!locallyPlaying && !observing) {
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
}
}
// ── Observing keep-alive ──────────────────────────────────────────
// Raw-cast follow mode ("observer mode") holds a TLS socket to the CCA
// and nothing else — the local player is idle, so Media3 provides no
// foreground notification, and on Android 12+ the cached-app freezer
// freezes the process the moment the user leaves the app. Frozen
// process = dead socket = observer mode silently gone when the user
// comes back, with the stale local queue still on screen (the exact
// confusing UX reported from the field). Mirror of the casting
// keep-alive: foreground promotion + Wi-Fi lock while observing. No
// wake lock — unlike casting we serve no audio bytes, so if the CPU
// dozes off the observer's own reconnect supervisor and the persisted
// reattach path recover state on wake instead of burning battery
// holding the CPU up all night.
private var observingWifiLock: android.net.wifi.WifiManager.WifiLock? = null
private fun buildObservingNotification(
deviceName: String?,
trackLine: String?,
): android.app.Notification {
val channelId = "earhorn_observing"
val nm = getSystemService(android.app.NotificationManager::class.java)
if (android.os.Build.VERSION.SDK_INT >= 26 &&
nm.getNotificationChannel(channelId) == null
) {
nm.createNotificationChannel(
android.app.NotificationChannel(
channelId, "Following a Cast session",
android.app.NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
)
}
val title = deviceName?.let { "Following $it" } ?: "Following Cast playback"
val text = trackLine ?: "Keeping the connection alive"
// Tap → open the app on Now Playing. The launcher activity restores
// where the user was; with observing alive the NowPlaying screen
// renders the observed session, which is where a notification tap
// should land.
val contentIntent = packageManager.getLaunchIntentForPackage(packageName)?.let {
android.app.PendingIntent.getActivity(
this, 0, it,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE,
)
}
return if (android.os.Build.VERSION.SDK_INT >= 26) {
android.app.Notification.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notif_cast_connected)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.apply { contentIntent?.let { setContentIntent(it) } }
.build()
} else {
@Suppress("DEPRECATION")
android.app.Notification.Builder(this)
.setSmallIcon(R.drawable.ic_notif_cast_connected)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setPriority(android.app.Notification.PRIORITY_LOW)
.apply { contentIntent?.let { setContentIntent(it) } }
.build()
}
}
private fun enterObservingKeepAlive() {
val notif = buildObservingNotification(
deviceName = runCatching { rawCast.connectedDeviceName.value }.getOrNull(),
trackLine = null,
)
runCatching {
if (android.os.Build.VERSION.SDK_INT >= 29) {
startForeground(
OBSERVING_NOTIFICATION_ID, notif,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(OBSERVING_NOTIFICATION_ID, notif)
}
}
if (observingWifiLock == null) {
val wm = applicationContext.getSystemService(android.net.wifi.WifiManager::class.java)
// WIFI_MODE_FULL keeps the connection up exactly as before; it
// stops forbidding the chip's own power saving on every wall
// screen all day (I.21, design decision 2026-09-02).
observingWifiLock = wm?.createWifiLock(
android.net.wifi.WifiManager.WIFI_MODE_FULL, "earhorn:observe",
)
}
runCatching { if (observingWifiLock?.isHeld != true) observingWifiLock?.acquire() }
}
private fun exitObservingKeepAlive() {
runCatching { if (observingWifiLock?.isHeld == true) observingWifiLock?.release() }
val locallyPlaying = runCatching { player?.isPlaying == true }.getOrDefault(false)
val casting = runCatching { cast.isCasting.value }.getOrDefault(false)
if (!locallyPlaying && !casting) {
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
} else {
// Foreground duty belongs to local play / casting; just retire
// our notification.
runCatching {
getSystemService(android.app.NotificationManager::class.java)
.cancel(OBSERVING_NOTIFICATION_ID)
}
}
}
private var session: MediaLibrarySession? = null
// The ForwardingPlayer wrapper that the MediaSession actually reads from —
// its cast-aware getters return CCA state when casting, exo state otherwise.
// Kept as a field so the wake receiver can reflectively call its notify*
// methods to force a notification metadata refresh after deep sleep.
private var sessionPlayerRef: androidx.media3.common.Player? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// Maintainer, 2026-10-29: the server the car is currently browsing, kept in
// memory only. Opening a server row in the car used to call
// settings.setPreferredServer and silently change the phone/TV app's
// saved server (D7). Written/read from the browse coroutines on IO.
@Volatile
private var carBrowseUdn: String? = null
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ServiceDeps {
fun contentDirectoryClient(): ContentDirectoryClient
fun settingsStore(): SettingsStore
fun upnpServiceHolder(): UpnpServiceHolder
fun localMediaScanner(): dev.stave.local.LocalMediaScanner
fun castIntegration(): dev.stave.cast.CastIntegration
fun queuePersistenceStore(): dev.stave.data.QueuePersistenceStore
fun rawCastObserver(): dev.stave.cast.raw.RawCastObserver
}
private lateinit var cdClient: ContentDirectoryClient
private lateinit var settings: SettingsStore
private lateinit var upnpHolder: UpnpServiceHolder
private lateinit var localScanner: dev.stave.local.LocalMediaScanner
private lateinit var cast: dev.stave.cast.CastIntegration
private lateinit var queueStore: dev.stave.data.QueuePersistenceStore
private lateinit var rawCast: dev.stave.cast.raw.RawCastObserver
// ServiceConnection for UPnP — the PlaybackService binds independently of
// MainActivity so Android Auto can browse even when the TV Activity isn't alive.
private val upnpConnection = object : android.content.ServiceConnection {
override fun onServiceConnected(name: android.content.ComponentName?, binder: android.os.IBinder?) {
(binder as? org.jupnp.android.AndroidUpnpService)?.let {
upnpHolder.attach(it)
}
}
override fun onServiceDisconnected(name: android.content.ComponentName?) {
upnpHolder.detach()
}
}
private var upnpBound = false
override fun onCreate() {
super.onCreate()
val deps = EntryPointAccessors.fromApplication(applicationContext, ServiceDeps::class.java)
cdClient = deps.contentDirectoryClient()
settings = deps.settingsStore()
upnpHolder = deps.upnpServiceHolder()
localScanner = deps.localMediaScanner()
cast = deps.castIntegration()
queueStore = deps.queuePersistenceStore()
rawCast = deps.rawCastObserver()
// ── Observing keep-alive + reattach ────────────────────────────
// The observer socket lives in the process; this service's job is
// to keep the process alive while observing (foreground promotion,
// mirroring the casting keep-alive below) and to resurrect a
// session the process died with (reattachIfPersisted). See
// enterObservingKeepAlive for the full failure-mode writeup.
scope.launch {
rawCast.observing.collect { observing ->
if (observing) enterObservingKeepAlive() else exitObservingKeepAlive()
}
}
// Keep the keep-alive notification's content current: show the
// observed track (title — artist) instead of a static line, updated
// on track change only (not position ticks).
scope.launch {
kotlinx.coroutines.flow.combine(
rawCast.observing,
rawCast.playbackState
.map { it.current?.let { t -> t.title to t.artist } }
.distinctUntilChanged(),
) { observing, titleArtist -> observing to titleArtist }
.collect { (observing, titleArtist) ->
if (!observing || titleArtist == null) return@collect
val (title, artist) = titleArtist
val line = if (artist.isNullOrBlank()) title else "$title — $artist"
runCatching {
getSystemService(android.app.NotificationManager::class.java)
.notify(
OBSERVING_NOTIFICATION_ID,
buildObservingNotification(
rawCast.connectedDeviceName.value, line,
),
)
}
}
}
// Reattach: if the process died mid-observation, reconnect and — only
// if the CCA session is still live — resume following. Runs off-main;
// failure modes are all handled inside (see RawCastObserver docs).
scope.launch(Dispatchers.IO) {
runCatching { rawCast.reattachIfPersisted() }
.onFailure { android.util.Log.w("EarhornCast", "reattach failed", it) }
}
// ── Casting keep-alive (background-stall fix) ──────────────────
// While casting, the CCA streams through this phone's
// CastStreamProxy, but the LOCAL player is paused — so Media3
// drops the service out of foreground, and on Android 12+ the
// cached-app freezer then freezes the whole process the moment
// the user switches apps. Frozen process = dead proxy = the
// speaker group's buffer runs dry and playback stops until the
// user reopens the app (at which point the receiver, having hit
// a stream error, skips to the next track). Keep the service
// foreground with a lightweight "Casting" notification and hold
// Wifi/partial-wake locks for the duration of the session.
// Libre flavor: isCasting is constantly false → this is inert.
scope.launch {
cast.isCasting.collect { casting ->
if (casting) enterCastingKeepAlive() else exitCastingKeepAlive()
}
}
// Bind UPnP service from the PlaybackService itself — this ensures Auto
// can discover servers even when the main Activity hasn't been opened.
// Set the wifi/ethernet-only interface restriction BEFORE bind so jUPnP
// doesn't bind SSDP multicast to a VPN tun interface (NetGuard etc.) or
// cellular. See [NetworkInterfaceResolver] for rationale.
// Unconditional on every device (design decision 2026-09-02,
// reverting the automation-switch gating): UPnP and kiosk together.
dev.stave.upnp.NetworkInterfaceResolver.applyToSystemProperties(applicationContext)
runCatching {
bindService(
android.content.Intent(this, org.jupnp.android.AndroidUpnpServiceImpl::class.java),
upnpConnection,
android.content.Context.BIND_AUTO_CREATE,
)
upnpBound = true
}.onFailure {
android.util.Log.e("EarhornAuto", "Failed to bind UPnP from PlaybackService", it)
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
// OkHttp for HTTP fetches — preserves Earhorn user-agent on UPnP requests.
val httpDataSourceFactory = OkHttpDataSource.Factory(okhttp)
.setUserAgent(earhornUserAgent())
// DefaultDataSource.Factory dispatches by URI scheme:
// - http:// https:// → delegated to httpDataSourceFactory (OkHttp + Earhorn UA)
// - content:// → ContentDataSource (MediaStore audio for on-device files)
// - file:// → FileDataSource
// - asset://, rawresource://, android.resource:// → built-in handlers
// Without this wrapper, OkHttpDataSource is the *only* data source the player
// knows about, so any content:// URI from the local-files browser fails to open
// — player stays in IDLE/ERROR while the MediaController still holds the queue
// items, producing the silent-playback-with-pause-icon state. TV isn't affected
// because TV plays UPnP HTTP URLs and the http branch of DefaultDataSource still
// routes through the OkHttp factory exactly as before.
val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(dataSourceFactory)
val exo = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
/* handleAudioFocus = */ true
)
.setHandleAudioBecomingNoisy(true)
.build()
player = exo
// Maintainer, 2026-10-29: skip unplayable tracks instead of stopping
// silently (D3). ExoPlayer halts the whole queue at the first item it
// can't play (no <res>, 404, unsupported format) with no message.
// Skip to the next item keeping playWhenReady, tell the user, and give
// up after 5 errors in a row (counter resets once something actually
// plays). Never while casting: the local player is muted/paused then.
exo.addListener(object : Player.Listener {
private var consecutiveErrors = 0
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
if (cast.isCasting.value) return
val title = exo.currentMediaItem?.mediaMetadata?.title?.toString()
?.takeIf { it.isNotBlank() } ?: "track"
android.util.Log.w("EarhornPlayback", "playback error on '$title'", error)
consecutiveErrors++
if (consecutiveErrors >= 5) {
consecutiveErrors = 0
android.widget.Toast.makeText(
applicationContext,
"Playback stopped — several tracks couldn't be played",
android.widget.Toast.LENGTH_LONG,
).show()
return
}
if (!exo.hasNextMediaItem()) {
consecutiveErrors = 0
android.widget.Toast.makeText(
applicationContext,
"Couldn't play $title",
android.widget.Toast.LENGTH_SHORT,
).show()
return
}
exo.seekToNextMediaItem()
exo.prepare()
android.widget.Toast.makeText(
applicationContext,
"Couldn't play $title — skipped",
android.widget.Toast.LENGTH_SHORT,
).show()
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) consecutiveErrors = 0
}
})
// Pre-warm the local media scanner cache on a background thread.
// This prevents the first Auto browse from blocking for seconds
// while MediaStore scans thousands of files. After this completes,
// all scanAlbums/scanArtists/allTracks calls return instantly from
// the in-memory cache.
scope.launch(Dispatchers.IO) {
runCatching {
android.util.Log.i("EarhornAuto", "Pre-warming local media cache…")
val t0 = System.currentTimeMillis()
localScanner.allTracks() // populates the cache
val ms = System.currentTimeMillis() - t0
android.util.Log.i("EarhornAuto", "Cache warm: ${localScanner.allTracks().size} tracks in ${ms}ms")
}
}
// ForwardingPlayer wraps ExoPlayer so the MediaSession (and its
// notification) routes play/pause/seek/volume commands to the Cast
// device when a Cast session is active.
//
// Listener tracking: we intercept addListener/removeListener to
// maintain our own set. When cast state changes, we fire
// onDeviceInfoChanged on all listeners so the MediaSession picks
// up the PLAYBACK_TYPE_REMOTE → PLAYBACK_TYPE_LOCAL transition
// (or vice versa) and the system routes background/lockscreen
// volume keys accordingly. Without this, the session caches the
// DeviceInfo it read at creation time and never re-checks.
val sessionPlayer = @OptIn(UnstableApi::class) object : ForwardingPlayer(exo) {
private val trackedListeners = mutableSetOf<Player.Listener>()
override fun addListener(listener: Player.Listener) {
super.addListener(listener)
trackedListeners.add(listener)
}
override fun removeListener(listener: Player.Listener) {
super.removeListener(listener)
trackedListeners.remove(listener)
}
fun notifyDeviceInfoChanged() {
val info = getDeviceInfo()
trackedListeners.forEach { runCatching { it.onDeviceInfoChanged(info) } }
}
/** Fan out the synthetic play-state to all tracked listeners.
* Called whenever cast play-state or session-active state
* changes — without this the MediaSession (and therefore
* the system notification) keeps reporting exo's frozen
* paused state while the receiver is actually playing.
* The session reads the player getters when it receives
* these events; our overrides below return the cast-aware
* values so the session picks up the right state. */
fun notifyPlayStateChanged() {
val playing = isPlaying
val pwr = getPlayWhenReady()
val state = getPlaybackState()
trackedListeners.forEach { l ->
runCatching {
l.onIsPlayingChanged(playing)
l.onPlayWhenReadyChanged(
pwr,
Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE,
)
l.onPlaybackStateChanged(state)
}
}
}
/** Fan out current-item-changed events to all listeners. The
* notification reads track metadata (title/artist/art) from
* the player's current MediaItem — when the receiver
* advances to a new track our cast-aware getters above
* return the new index/item/metadata, but the session
* doesn't know to re-read until a listener event fires.
* Without this, the lockscreen stays pinned to the track
* that was current when the cast session started. */
fun notifyMediaItemChanged() {
val item = getCurrentMediaItem()
val metadata = getMediaMetadata()
trackedListeners.forEach { l ->
runCatching {
l.onMediaItemTransition(
item,
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO,
)
l.onMediaMetadataChanged(metadata)
}
}
}
// Hide shuffle/repeat from the notification template.
// Pixel's media notification automatically adds shuffle and
// repeat buttons when the Player advertises these commands.
// The user wants "More by artist" and "Stop" instead —
// those are in the custom layout. Removing the commands
// from the Player's advertisement prevents the system from
// adding its own shuffle/repeat icons.
override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands().buildUpon()
.remove(Player.COMMAND_SET_SHUFFLE_MODE)
.remove(Player.COMMAND_SET_REPEAT_MODE)
.build()
}
override fun play() {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.play() casting=${cast.isCasting.value}")
// Maintainer, 2026-10-29: while following the group (observing),
// Play from OUTSIDE the app's own screens — notification, lock
// screen, headset, car — goes to the observed session, not the
// local player: local audio under the group's music was the
// "two songs at once" bug (D6). The app's own controller (its
// screens, the UPnP renderer service) still drives the local
// player as before. A headset PLAY_PAUSE arrives as play()
// (the local player is paused), so it toggles the group.
if (routeToObservedSession()) {
scope.launch {
runCatching {
if (rawCast.playbackState.value.isPlaying) rawCast.pause() else rawCast.play()
}
}
return
}
if (cast.isCasting.value) cast.play() else super.play()
}
override fun pause() {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.pause() casting=${cast.isCasting.value}")
// Maintainer, 2026-10-29: same routing as play() while observing (D6).
if (routeToObservedSession()) {
scope.launch { runCatching { rawCast.pause() } }
return
}
if (cast.isCasting.value) cast.pause() else super.pause()
}
/** Maintainer, 2026-10-29 (D6): true while observing when the current
* request comes from outside the app's own screens (media
* notification / headset — Media3 attributes both to its
* notification controller — or another package such as the
* car). The app's own MediaController (same package, not the
* notification controller) is never rerouted, so the app's
* "pause local when observing starts" and the UPnP renderer
* service keep acting on the local player. */
private fun routeToObservedSession(): Boolean {
if (!rawCast.observing.value) return false
val s = session ?: return false
val caller = s.controllerForCurrentRequest ?: return false
return caller.packageName != packageName || s.isMediaNotificationController(caller)
}
override fun seekTo(positionMs: Long) {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekTo($positionMs)")
if (cast.isCasting.value) cast.seekTo(positionMs) else super.seekTo(positionMs)
}
// Lockscreen / headset / notification Next and Previous fork to
// the receiver like every other transport here (review N.24,
// design decision 2026-09-02): before, only the hidden local
// index moved and the speaker kept playing.
override fun seekToNextMediaItem() {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekToNextMediaItem() casting=${cast.isCasting.value}")
super.seekToNextMediaItem()
if (cast.isCasting.value) cast.queueNext()
}
override fun seekToPreviousMediaItem() {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekToPreviousMediaItem() casting=${cast.isCasting.value}")
super.seekToPreviousMediaItem()
if (cast.isCasting.value) cast.queuePrev()
}
override fun stop() {
android.util.Log.d("EarhornAuto", "ForwardingPlayer.stop() casting=${cast.isCasting.value}")
if (cast.isCasting.value) {
cast.stop(); cast.seekTo(0)
} else super.stop()
}
override fun getDeviceInfo(): androidx.media3.common.DeviceInfo {
if (cast.isCasting.value) {
return androidx.media3.common.DeviceInfo.Builder(
androidx.media3.common.DeviceInfo.PLAYBACK_TYPE_REMOTE
).setMaxVolume(20).build()
}
return super.getDeviceInfo()
}
override fun getDeviceVolume(): Int {
if (cast.isCasting.value) {
return (cast.getDeviceVolume() * 20).toInt().coerceIn(0, 20)
}
return super.getDeviceVolume()
}
override fun isDeviceMuted(): Boolean {
if (cast.isCasting.value) return cast.getDeviceVolume() <= 0.0
return super.isDeviceMuted()
}
override fun setDeviceVolume(volume: Int, flags: Int) {
if (cast.isCasting.value) {
cast.setDeviceVolume(volume / 20.0)
} else super.setDeviceVolume(volume, flags)
}
override fun increaseDeviceVolume(flags: Int) {
if (cast.isCasting.value) {
cast.setDeviceVolume(cast.getDeviceVolume() + 0.05)
} else super.increaseDeviceVolume(flags)
}
override fun decreaseDeviceVolume(flags: Int) {
if (cast.isCasting.value) {
cast.setDeviceVolume(cast.getDeviceVolume() - 0.05)
} else super.decreaseDeviceVolume(flags)
}
// Synthetic play-state during cast. While casting, exo is paused
// (volume=0, playWhenReady=false) so the phone doesn't decode
// the stream alongside the receiver — but the MediaSession
// reads playback state directly from these getters, and a
// paused exo would make the system notification show paused
// even though the receiver is playing. Override the trio of
// getters Media3's notification template reads (isPlaying,
// playWhenReady, playbackState) so the lockscreen icon
// matches what the user hears. Listener fan-out via
// notifyPlayStateChanged() keeps these in sync when cast
// state actually changes.
override fun isPlaying(): Boolean {
return if (cast.isCasting.value) cast.castIsPlaying.value
else super.isPlaying()
}
override fun getPlayWhenReady(): Boolean {
return if (cast.isCasting.value) cast.castIsPlaying.value
else super.getPlayWhenReady()
}
override fun getPlaybackState(): Int {
// Always report READY during cast — STATE_IDLE/STATE_ENDED
// would suppress the notification, and STATE_BUFFERING
// would show a spinner. The receiver handles its own
// buffering UI on-screen; we just say "ready" so the
// session reports a stable playing/paused indicator
// driven by playWhenReady.
return if (cast.isCasting.value) Player.STATE_READY
else super.getPlaybackState()
}
// Synthetic current-item index/metadata during cast. exo's
// local timeline still holds every track we loaded into the
// queue, but exo doesn't advance through it (paused for
// heat reasons). The notification reads title/artist/art
// from the player's current media item — without these
// overrides it'd be stuck on whichever track was current
// when the cast session started while the receiver moves
// on to track 2, 3, … and the lockscreen never catches up.
// We return the receiver's authoritative index, then let
// the base Player path resolve that to the right MediaItem
// out of the loaded timeline. Listener fan-out via
// notifyMediaItemChanged() (below) fires on every receiver
// queue advance so the session re-reads.
override fun getCurrentMediaItemIndex(): Int {
if (cast.isCasting.value) {
val idx = cast.castCurrentItemIndex.value
if (idx >= 0 && idx < mediaItemCount) return idx
}
return super.getCurrentMediaItemIndex()
}
override fun getCurrentMediaItem(): androidx.media3.common.MediaItem? {
if (cast.isCasting.value) {
val idx = cast.castCurrentItemIndex.value
if (idx >= 0 && idx < mediaItemCount) return getMediaItemAt(idx)
}
return super.getCurrentMediaItem()
}
override fun getMediaMetadata(): androidx.media3.common.MediaMetadata {
if (cast.isCasting.value) {
val item = getCurrentMediaItem()
if (item != null) return item.mediaMetadata
}
return super.getMediaMetadata()
}
}
// Cast audio-focus manager. When a Cast session starts, disable
// ExoPlayer's audio focus handling so we can keep playWhenReady=true
// (for the notification icon) without ExoPlayer losing focus to the
// Cast SDK and auto-pausing. Volume→0 silences local output. We
// ALSO pause exo entirely while casting — previously a separate
// mirror flipped exo.playWhenReady to match castIsPlaying, which
// meant the local player kept decoding the same UPnP stream the
// receiver was already decoding. Same bits over the network
// twice, same CPU spent on FLAC/MP3 demux, audible heat in the
// user's hand. exo.pause() releases the renderers; when cast
// ends we let the existing restore path bring it back up.
// On session end, restore normal focus handling and unmute.
scope.launch {
cast.isCasting.collect { casting ->
if (casting) {
exo.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
/* handleAudioFocus = */ false,
)
exo.volume = 0f
// Stop active decoding. Queue is preserved (no stop()
// call), so re-toggling cast off resumes from the
// current track at position 0.
exo.playWhenReady = false
} else {
// Pause before unmuting — playWhenReady may still be
// true from the castIsPlaying mirror. Without this,
// disconnecting from cast instantly resumes playback
// through the phone speaker at full volume.
exo.playWhenReady = false
exo.volume = 1f
exo.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
/* handleAudioFocus = */ true,
)
}
// Poke the session's listeners so it re-reads DeviceInfo
// (PLAYBACK_TYPE_REMOTE when casting, LOCAL when not).
// Without this, the session caches the DeviceInfo from
// creation time and background volume keys always hit the
// phone stream instead of the Cast device.
sessionPlayer.notifyDeviceInfoChanged()
// Also fire play-state listeners — our overrides return
// cast state when casting and exo state otherwise, so
// every casting toggle is a play-state transition the
// session needs to be told about.
sessionPlayer.notifyPlayStateChanged()
// And media-item — current track shifts between exo's
// pinned index and the receiver's at every cast
// toggle, so the notification needs to re-read.
sessionPlayer.notifyMediaItemChanged()
}
}
// Cast play-state listener-only mirror. The session reads its
// play/pause state from the player getters (isPlaying et al.)
// which we've overridden to return cast values during cast.
// Listeners need to be told when those values change or the
// session won't re-read. CRITICAL: this only fans out events,
// never sets exo.playWhenReady — that's what the old mirror
// did, and it caused the phone to overheat (exo silently
// decoding alongside the receiver, same stream, twice the
// work). Notification accuracy without the heat penalty.
scope.launch {
cast.castIsPlaying.collect {
if (cast.isCasting.value) {
sessionPlayer.notifyPlayStateChanged()
}
}
}
// Same pattern for the current-track index. When the receiver
// advances to a new queue item, fire onMediaItemTransition +
// onMediaMetadataChanged so the session re-reads the player's
// (now cast-aware) current MediaItem and the lockscreen art /
// title / artist follow along. Without this collector the
// overridden getters return correct values but no one knows
// to call them — the notification stays stuck on the track
// that was current when the cast session started.
scope.launch {
cast.castCurrentItemIndex.collect {
if (cast.isCasting.value) {
sessionPlayer.notifyMediaItemChanged()
}
}
}
// Two PendingIntents:
// - sessionActivityPi: fires when the user taps the body of
// the system media notification (album art, title area).
// Per UX spec, this opens the Queue screen — the album art
// IS the queue button.
// - openAppPi: fallback for when no specific deep-link is
// appropriate; just brings the app to foreground.
val openAppIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val openAppPi = openAppIntent?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
val sessionActivityPi = openAppIntent?.let {
val queueDeepLink = Intent(it).apply {
putExtra(EXTRA_NAV_ROUTE, NAV_QUEUE)
}
PendingIntent.getActivity(
this, REQUEST_CODE_QUEUE, queueDeepLink,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
} ?: openAppPi
val sessionBuilder = MediaLibrarySession.Builder(this, sessionPlayer, LibraryCallback())
.setCustomLayout(buildNotificationLayout(exo))
// Custom BitmapLoader: forces 1024x1024 fetches via Coil so
// Auto's now-playing card and the system notification get a
// big image regardless of where the URI points. Default
// DataSourceBitmapLoader decodes whatever the URI returns,
// which for content://media/external/audio/albumart can be
// a 256-px thumbnail. Wrapped in CacheBitmapLoader so
// repeated requests for the same URI hit cache.
.setBitmapLoader(
androidx.media3.session.CacheBitmapLoader(CoilBitmapLoader(this))
)
if (sessionActivityPi != null) {
sessionBuilder.setSessionActivity(sessionActivityPi)
}
session = sessionBuilder.build()
// Expose the session-player ref for the wake receiver. See class-level
// doc on sessionPlayerRef for why.
sessionPlayerRef = sessionPlayer
// Register the wake-on-screen receiver. RECEIVER_NOT_EXPORTED on R+ —
// these are system broadcasts so the flag is required but they're
// exempt from the broadcast-export protection (sourced by the OS).
if (!wakeReceiverRegistered) {
val filter = android.content.IntentFilter().apply {
addAction(android.content.Intent.ACTION_SCREEN_ON)
addAction(android.content.Intent.ACTION_USER_PRESENT)
}
runCatching {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
registerReceiver(wakeReceiver, filter, android.content.Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(wakeReceiver, filter)
}
wakeReceiverRegistered = true
}
}
// Expose the compat token for the Car App Library.
sessionCompatToken = getCompatToken(session!!)
// Initial layout push via Media3 1.4+ API
@Suppress("NewApi")
runCatching { session!!.setMediaButtonPreferences(buildNotificationLayout(exo)) }
setMediaNotificationProvider(
// Subclass that overrides the button selection so the
// notification action row surfaces our custom layout (one
// button: Stop) instead of Pixel's auto-prepended
// prev/play-pause/next.
//
// Pixel's MediaStyle template renders a separate
// primary-action button (the orange play/pause in the
// upper-right of the notification card) sourced from the
// media session's PlaybackState — that path is unaffected
// by getMediaButtons. So returning only customLayout from
// here drops the redundant prev/play-pause/next from the
// bottom action row and leaves the central play/pause +
// skip controls intact via the system path.
object : DefaultMediaNotificationProvider(
this,
NOTIFICATION_ID_PROVIDER,
"earhorn_playback",
R.string.notification_channel_name,
) {
@OptIn(UnstableApi::class)
override fun getMediaButtons(
session: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean,
): ImmutableList<CommandButton> {
// If the custom layout hasn't been built yet
// (rare, but possible during very-early init),
// fall through to the default which gives the
// user *something* (prev/play-pause/next) rather
// than an empty action row.
if (customLayout.isEmpty()) {
return super.getMediaButtons(
session, playerCommands, customLayout, showPauseButton,
)
}
return customLayout
}
}.apply {
setSmallIcon(R.drawable.ic_notification)
}
)
}
/** The notification's custom layout: one button, Stop. Standard
* prev/play-pause/next come from the media template's built-in
* slots. */
private fun buildNotificationLayout(p: Player): List<CommandButton> {
return listOf(
CommandButton.Builder()
.setDisplayName("Stop")
.setIconResId(R.drawable.ic_transport_stop)
.setSessionCommand(SessionCommand(CMD_STOP_ALL, Bundle.EMPTY))
.build(),
)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Let MediaSessionService process media-button intents first.
val result = super.onStartCommand(intent, flags, startId)
if (intent?.action == ACTION_OBSERVE_KEEPALIVE) {
// Started via startForegroundService — we MUST promote to
// foreground promptly or the system kills us with a
// ForegroundServiceDidNotStartInTimeException. The observer
// sends this only after its connect succeeded, so entering
// keep-alive is correct; if observing has already flipped off
// in a race, the observing collector's next emission calls
// exitObservingKeepAlive and cleans up.
enterObservingKeepAlive()
}
return result
}
override fun onTaskRemoved(rootIntent: Intent?) {
// Swipe-away must NOT kill the service while it has remote duties:
// observing (the TLS follow socket lives in this process — stopSelf
// here was the "observer mode drops every time I exit the app" bug)
// or casting (the stream proxy serves the receiver's bytes). The
// user's chosen model: the connection lives with the service, and
// relaunching the app reattaches to whatever is still playing.
val observing = runCatching { rawCast.observing.value }.getOrDefault(false)
val casting = runCatching { cast.isCasting.value }.getOrDefault(false)
if (player?.playWhenReady == false && !observing && !casting) stopSelf()
}
// ── Wake-on-screen notification refresh ─────────────────────────
// When the phone goes into deep doze with a cast session active, the
// local exo is paused (volume=0, playWhenReady=false) so it produces
// no listener events. The notification template renders from the last
// event, which can be hours old by the time the user wakes the phone
// — they see whichever track was playing when they fell asleep, not
// what's currently on the cast speaker.
//
// Listening for ACTION_SCREEN_ON (and USER_PRESENT for keyguard
// dismissal) lets us poke the session's listeners so the notification
// re-reads our cast-aware getters and shows the current track.
private val wakeReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
val action = intent?.action ?: return
if (action != android.content.Intent.ACTION_SCREEN_ON &&
action != android.content.Intent.ACTION_USER_PRESENT
) return
android.util.Log.d("EarhornWake", "wakeReceiver: $action")
// Force a Cast state refresh first — in doze the sender's
// RemoteMediaClient callbacks may have stopped firing, leaving
// castCurrentItemIndex / castIsPlaying stale. requestStatus()
// round-trips the receiver and triggers onStatusUpdated, which
// updates the StateFlows; our existing collectors in onCreate
// then call notify* and the session re-reads metadata.
runCatching {
val deps = EntryPointAccessors.fromApplication(
applicationContext, ServiceDeps::class.java
)
deps.castIntegration().requestStatusRefresh()
}
// Belt-and-suspenders: also poke the session listeners directly,
// in case the user wasn't casting at all (refresh is a no-op
// there) but the notification still went stale for some other
// reason (e.g. doze-suspended bitmap loader).
val p = sessionPlayerRef ?: return
runCatching {
// sessionPlayer is an anonymous-object subclass of ForwardingPlayer
// defined inline in onCreate(). Its notify* methods can't be
// typed-named from out here, so dispatch reflectively. Reflection
// cost is irrelevant on a once-per-wake call.
val cls = p.javaClass
listOf("notifyMediaItemChanged", "notifyPlayStateChanged").forEach { name ->
runCatching {
cls.getDeclaredMethod(name).apply { isAccessible = true }.invoke(p)
}
}
// (The layout is static — one Stop button — so there is
// nothing to rebuild here; the seven-button rebuild was
// removed 2026-09-02.)
}
}
}
private var wakeReceiverRegistered = false
override fun onDestroy() {
exitCastingKeepAlive()
runCatching { if (observingWifiLock?.isHeld == true) observingWifiLock?.release() }
if (wakeReceiverRegistered) {
runCatching { unregisterReceiver(wakeReceiver) }
wakeReceiverRegistered = false
}
val s = session
val p = player
session = null
player = null
sessionPlayerRef = null
s?.release()
p?.release()
if (upnpBound) {
runCatching { unbindService(upnpConnection) }
upnpBound = false
}
super.onDestroy()
}
// ── Browse callback — bridges UPnP to Android Auto media tree ────
/** Synthetic ID for the server-list node. When Auto arrives with no
* preferred server, the root's children are server entries instead of
* music containers. Selecting one sets the preference and refreshes. */
/** Real version for the HTTP user agent (the string was "Earhorn/0.0.1"
* since the first release; review N.30). */
private fun earhornUserAgent(): String = runCatching {
"Earhorn/" + packageManager.getPackageInfo(packageName, 0).versionName
}.getOrDefault("Earhorn")
companion object {
@OptIn(UnstableApi::class)
private fun getCompatToken(
session: MediaLibraryService.MediaLibrarySession
): android.support.v4.media.session.MediaSessionCompat.Token {
return session.sessionCompatToken
}
// Internal Auto/library identifiers
const val NOTIFICATION_ID = 2002
/** Explicit-start action sent by RawCastObserver right after an
* observe connect succeeds. Purely observing involves no local
* playback, so nothing else guarantees this service even EXISTS —
* a MediaController bind only happens around local play. Without
* this start, the keep-alive collector in onCreate never runs, the
* process is freezer-fair-game the moment the user switches apps,
* and observer mode silently dies in the background (field-
* reported). startForegroundService also obliges us to call
* startForeground promptly — onStartCommand handles that. */
const val ACTION_OBSERVE_KEEPALIVE = "earhorn.action.observe_keepalive"
const val BROWSE_ROOT = "earhorn_root"
const val SERVER_PREFIX = "earhorn_server:"
/** MediaSessionCompat.Token exposed for the Car App Library's
* MediaPlaybackManager.registerMediaPlaybackToken(). Set after
* the session is built in onCreate(); null before that. */
@Volatile
var sessionCompatToken: android.support.v4.media.session.MediaSessionCompat.Token? = null
private set
// NotificationIdProvider for the protected DefaultMediaNotificationProvider
// constructor (required for subclassing). Returns the same constant
// ID for every session — Earhorn only ever has one active session.
val NOTIFICATION_ID_PROVIDER =
androidx.media3.session.DefaultMediaNotificationProvider.NotificationIdProvider {
NOTIFICATION_ID
}
// PendingIntent request codes — must be distinct so
// FLAG_UPDATE_CURRENT updates the right one when extras change.
const val REQUEST_CODE_QUEUE = 1001
// Custom session commands. The notification is one button (Stop);
// the app's in-process controller uses the two setters below. A
// seven-button layout and six more commands lived here without a
// sender until 2026-09-02 (review N.25; removed on the
// design decision).
// Explicit-value setters for shuffle / repeat. Used by the app's
// UI (NowPlaying toggle, ShuffleAction) which knows the target
// value rather than just wanting to flip from the current one.
// These exist because ForwardingPlayer.getAvailableCommands()
// hides COMMAND_SET_SHUFFLE_MODE / COMMAND_SET_REPEAT_MODE to
// keep the system notification template from auto-adding shuffle
// and repeat icons — and MediaController.setShuffleModeEnabled
// silently drops the call when the command isn't advertised, so
// the in-app shuffle button never reached the player. Routing
// via a SessionCommand bypasses that gate.
const val CMD_SET_SHUFFLE = "earhorn.cmd.set_shuffle"
const val CMD_SET_REPEAT_MODE = "earhorn.cmd.set_repeat_mode"
const val ARG_SHUFFLE_ENABLED = "enabled"
const val ARG_REPEAT_MODE = "mode" // Player.REPEAT_MODE_* int
// Stop is a custom command (not Player.COMMAND_STOP) so we can
// fork to cast.stop() when a Cast session is active. Player.COMMAND_STOP
// would only stop the local controller, leaving the Cast device
// playing — confusing UX since the user just told the system
// "stop everything".
const val CMD_STOP_ALL = "earhorn.cmd.stop_all"
// Deep-link extras MainActivity reads on receiving an intent
// with the session activity or a notification command launch.
// Single string-valued route plus an optional argument (used
// by more-by-artist / more-from-album to carry the artist or
// album name). Kept simple so the intent payload survives
// Android's extras limitations.
const val EXTRA_NAV_ROUTE = "earhorn.extra.nav_route"
const val EXTRA_NAV_ARG = "earhorn.extra.nav_arg"
const val NAV_QUEUE = "queue"
const val NAV_LYRICS = "lyrics"
const val NAV_NOW_PLAYING = "now_playing"
const val NAV_MORE_ARTIST = "more_by_artist"
const val NAV_MORE_ALBUM = "more_from_album"
/** Land on Browse (root of the preferred server, or Servers when
* none is set). Used by the hand-back path when kiosk won't open:
* never a blank screen (design decision 2026-09-02). */
const val NAV_BROWSE = "browse"
}
private inner class LibraryCallback : MediaLibrarySession.Callback {
/** Cache of mediaId → playable URI, populated as we browse the library
* on Auto's behalf. Auto's IPC strips MediaItem.RequestMetadata when
* the controller round-trips items back to the session for
* setMediaItem / addMediaItem — so by the time onAddMediaItems sees
* the items the user tapped, the only stable identifier is the
* mediaId; the URI we put in requestMetadata.mediaUri at toMediaItem
* time is gone. Without this cache, taps on UPnP tracks resolve to
* itemless playback (silent no-op, controller stays on the prior
* track), which is the "tap a song, nothing happens, old track keeps
* playing" report. ConcurrentHashMap is safe for the many-readers /
* occasional-writer pattern between scope.launch coroutines and the
* synchronous onAddMediaItems callback. */
private val uriCache = java.util.concurrent.ConcurrentHashMap<String, android.net.Uri>()
/** Advertise our custom session commands to every connecting
* controller. Without this, the system notification's
* CommandButtons that use SessionCommand can't dispatch —
* the session rejects unknown command keys at the
* authorization layer before they reach onCustomCommand. */
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val available = MediaSession.ConnectionResult
.DEFAULT_SESSION_AND_LIBRARY_COMMANDS
.buildUpon()
.add(SessionCommand(CMD_SET_SHUFFLE, Bundle.EMPTY))
.add(SessionCommand(CMD_SET_REPEAT_MODE, Bundle.EMPTY))
.add(SessionCommand(CMD_STOP_ALL, Bundle.EMPTY))
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(available)
.build()
}
/** Route custom commands. Toggle commands modify the
* ExoPlayer directly — the Player.Listener registered in
* onCreate picks up the change and rebuilds the layout, so
* the icon updates without an explicit setCustomLayout call
* here. Nav commands launch MainActivity with deep-link
* extras the activity reads in onNewIntent. */
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val p = player
when (customCommand.customAction) {
CMD_SET_SHUFFLE -> {
// Args carry the explicit desired value. See the comment on
// CMD_SET_SHUFFLE for why the in-app shuffle button can't
// use MediaController.setShuffleModeEnabled directly.
val enabled = args.getBoolean(ARG_SHUFFLE_ENABLED)
if (p != null) p.shuffleModeEnabled = enabled
}
CMD_SET_REPEAT_MODE -> {
val mode = args.getInt(ARG_REPEAT_MODE, Player.REPEAT_MODE_OFF)
if (p != null) p.repeatMode = mode
}
CMD_STOP_ALL -> {
// Fork to cast first so the remote audio actually
// stops; cast.stop() is a no-op in libre flavor
// and harmless when no session is active.
if (cast.isCasting.value) {
runCatching { cast.stop() }
runCatching { cast.seekTo(0) }
}
// The observed house session too — the notification's
// Stop used to leave it running (I.19, design decision
// 2026-09-02): same routing as the on-screen Stop.
if (rawCast.observing.value) {
scope.launch { runCatching { rawCast.stop() } }
}
// Always pause+rewind local so the MediaSession
// transitions to STATE_READY (paused at 0). Uses
// pause+seek instead of player.stop() to preserve
// queue/track state — pressing Play afterward
// restarts the current track from the beginning,
// matching the "old-school stop button" semantics
// used elsewhere in LocalRenderer.stop().
if (p != null) {
p.pause()
p.seekTo(0)
}
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
// params may carry hints — `params.isRecent`, `params.isSuggested`,
// `params.isOffline` — that Auto uses to populate "For You" /
// "Recommendations" / "Recents" tabs. Auto's "For You" panel that
// shows up in the launcher is populated from a SUGGESTED-rooted
// sub-tree if the app provides one. Earhorn doesn't currently
// build a suggestion graph (no listening-history derived
// recommendations), so we return the same generic root for all
// hint flavors. Auto falls back to the user's recently-played
// session items, which is why "For You" shows whatever was last
// playing rather than curated picks.
//
// To populate For You meaningfully we'd need to:
// 1. Track playback history (recently-played albums/tracks).
// 2. Return a hierarchy when params.isSuggested with mediaIds
// like "earhorn_recent:album:N" / "earhorn_recent:track:N".
// 3. Implement onGetChildren for those IDs.
// Punted as future scope.
val root = MediaItem.Builder()
.setMediaId(BROWSE_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Earhorn Music")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
)
.build()
return Futures.immediateFuture(LibraryResult.ofItem(root, params))
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val t0 = System.currentTimeMillis()
android.util.Log.i("EarhornAuto", "onGetChildren parentId=$parentId page=$page pageSize=$pageSize")
val future = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch(Dispatchers.IO) {
try {
// ── Local media branches ────────────────────────────
if (parentId.startsWith(dev.stave.local.LocalMediaScanner.LOCAL_PREFIX)) {
handleLocalBrowse(parentId, params, future)
return@launch
}
// ── Server-selection from the root list ─────────────
if (parentId.startsWith(SERVER_PREFIX)) {
val udn = parentId.removePrefix(SERVER_PREFIX)
// Maintainer, 2026-10-29: remember the car's choice in
// memory instead of overwriting the app's saved server (D7).
carBrowseUdn = udn
browseContainer(udn, "0", params, future)
return@launch
}
// ── Root-level children ─────────────────────────────
if (parentId == BROWSE_ROOT) {
// Source-level layout: Local Music sits next to the
// configured (or discovered) UPnP server(s) as
// siblings, NOT mixed with each server's root
// containers. Prior layout inlined the configured
// server's container-0 children at the top level
// alongside Local Music — when the server's root has
// ten entries (folder view, tag view, albums,
// artists, genres, …) this buried Local Music below
// a wall of server entries that looked like the only
// thing available, and tapping any of them entered
// server browse rather than a clear "I want my phone
// music" / "I want my home server" choice.
//
// New: top level always shows source picks. Tap Local
// Music → local hierarchy. Tap a server → that
// server's root container.
val items = mutableListOf<MediaItem>()
items.add(
MediaItem.Builder()
.setMediaId(dev.stave.local.LocalMediaScanner.LOCAL_ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Local Music")
.setSubtitle("Music stored on this device")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS)
.build()
)
.build()
)
val configuredUdn = settings.preferredServerUdn.first()
val knownServers = upnpHolder.servers.value
// Don't block the browse tree waiting for SSDP discovery.
// Use whatever servers are already known. If empty, the
// user sees only "Local Music" — they can retry after
// discovery completes.
val servers = knownServers
if (knownServers.isEmpty()) {
// Kick a rescan in the background for next time
upnpHolder.rescan()
}
// Always include the configured server (even if not
// currently in the live discovery list — it might
// come back). Then append any other discovered
// servers. Dedup by UDN.
val seen = mutableSetOf<String>()
configuredUdn?.let { udn ->
val match = servers.firstOrNull { it.udn == udn }
// Maintainer, 2026-10-29: say plainly when the saved
// server isn't on this network (D7). No name is
// stored for it, so the title falls back to "Saved server".
val title = match?.friendlyName ?: "Saved server"
val subtitle = if (match == null) "Not found on this network"
else match.modelName ?: "Configured server"
items.add(
MediaItem.Builder()
.setMediaId("$SERVER_PREFIX$udn")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
)
.build()
)
seen.add(udn)
}
servers.forEach { server ->
if (server.udn in seen) return@forEach
items.add(
MediaItem.Builder()
.setMediaId("$SERVER_PREFIX${server.udn}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(server.friendlyName)
.setSubtitle(server.modelName ?: "UPnP Server")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
)
.build()
)
seen.add(server.udn)
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
return@launch
}
// ── UPnP container browse ───────────────────────────
// Maintainer, 2026-10-29: browse the server the car opened,
// falling back to the app's saved server (D7).
val udn = carBrowseUdn ?: settings.preferredServerUdn.first()
if (udn != null) {
browseContainer(udn, parentId, params, future)
} else {
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_NOT_SUPPORTED))
}
} catch (e: Exception) {
android.util.Log.e("EarhornAuto", "onGetChildren failed in ${System.currentTimeMillis() - t0}ms", e)
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
}
}
future.addListener({
android.util.Log.i("EarhornAuto", "onGetChildren $parentId completed in ${System.currentTimeMillis() - t0}ms")
}, MoreExecutors.directExecutor())
return paged(future, page, pageSize) // Maintainer, 2026-10-29: see paged()
}
/** Maintainer, 2026-10-29: every browse/search path above sets the FULL
* list, but Media3 throws IllegalStateException on its main handler
* (process death) when a result holds more than pageSize items — the
* car asks in pages. Return a future carrying only the requested page
* of whatever [source] resolves to. Only [source] is ever completed
* (set(), never failed or cancelled), so get() here doesn't throw. */
private fun paged(
source: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
page: Int,
pageSize: Int,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val out = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
source.addListener({ out.set(pageOf(source.get(), page, pageSize)) }, MoreExecutors.directExecutor())
return out
}
/** Maintainer, 2026-10-29: items.drop(page * pageSize).take(pageSize) of a
* successful item list; anything else (errors, pageSize Int.MAX_VALUE =
* "all", invalid paging) passes through untouched. */
private fun pageOf(
result: LibraryResult<ImmutableList<MediaItem>>,
page: Int,
pageSize: Int,
): LibraryResult<ImmutableList<MediaItem>> {
val items = result.value
if (result.resultCode != LibraryResult.RESULT_SUCCESS || items == null) return result
if (pageSize <= 0 || page < 0 || pageSize == Int.MAX_VALUE) return result
val from = page.toLong() * pageSize
if (from == 0L && items.size <= pageSize) return result
val slice = if (from >= items.size) emptyList() else items.drop(from.toInt()).take(pageSize)
return LibraryResult.ofItemList(slice, result.params)
}
/** Handle all local media browse paths. */
private suspend fun handleLocalBrowse(
parentId: String,
params: LibraryParams?,
future: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
) {
val lms = dev.stave.local.LocalMediaScanner
// Permission gate. MediaStore.Audio queries return zero rows
// (cleanly, no exception) when the app has not been granted
// READ_MEDIA_AUDIO (API 33+) / READ_EXTERNAL_STORAGE (API <33).
// In Earhorn, the runtime grant flow lives in the phone-side
// LocalHubScreen — if the user has never visited that screen,
// permission has never been requested and Auto's local browse
// returns silent empty lists at every level (Albums → "No
// items", Artists → "No items"). User reads this as "the app
// doesn't have my files." Surface the actual cause: a
// single browsable-but-not-actionable hint item at the root
// that tells them where to go.
val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
android.Manifest.permission.READ_MEDIA_AUDIO
else
android.Manifest.permission.READ_EXTERNAL_STORAGE
val hasPerm = androidx.core.content.ContextCompat.checkSelfPermission(
applicationContext, perm,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
future.set(LibraryResult.ofItemList(
ImmutableList.of(
MediaItem.Builder()
.setMediaId("earhorn_local_no_permission")
.setMediaMetadata(
MediaMetadata.Builder()
// Maintainer, 2026-10-29: clearer hint wording (D7).
.setTitle("Allow Music access on your phone")
.setSubtitle("Open Earhorn → Choose server → On this device")
.setIsBrowsable(false)
.setIsPlayable(false)
.build()
)
.build()
),
params,
))
return
}
when {
parentId == lms.LOCAL_ROOT -> {
val items = listOf(
MediaItem.Builder()
.setMediaId(lms.LOCAL_ALBUMS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Albums")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS)
.build()
)
.build(),
MediaItem.Builder()
.setMediaId(lms.LOCAL_ARTISTS)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Artists")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS)
.build()
)
.build(),
)
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
parentId == lms.LOCAL_ALBUMS -> {
val albums = localScanner.scanAlbums()
val items = albums.map { album ->
MediaItem.Builder()
.setMediaId("${lms.LOCAL_ALBUM}${album.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(album.title)
.setArtist(album.artist)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.apply {
album.artUri?.let { setArtworkUri(it) }
}
.build()
)
.build()
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
parentId == lms.LOCAL_ARTISTS -> {
val artists = localScanner.scanArtists()
val items = artists.map { (id, name) ->
MediaItem.Builder()
.setMediaId("${lms.LOCAL_ARTIST}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.build()
)
.build()
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
parentId.startsWith(lms.LOCAL_ALBUM) -> {
val albumId = parentId.removePrefix(lms.LOCAL_ALBUM).toLongOrNull()
if (albumId == null) {
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
return
}
val tracks = localScanner.tracksForAlbum(albumId)
val items = tracks.map { track ->
val mediaId = "${lms.LOCAL_PREFIX}track:${track.id}"
// Populate URI cache — Auto's IPC strips the
// RequestMetadata between onGetChildren and the
// tap-to-play setMediaItem callback. Cache by
// mediaId so onAddMediaItems can resolve.
uriCache[mediaId] = track.contentUri
MediaItem.Builder()
.setMediaId(mediaId)
.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setMediaUri(track.contentUri)
.build()
)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artist ?: track.albumArtist)
.setAlbumTitle(track.album)
.setTrackNumber(track.trackNumber)
.setDiscNumber(track.discNumber)
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.apply {
track.artUri?.let { setArtworkUri(it) }
setExtras(Bundle().apply {
putLong("duration_ms", track.duration)
})
}
.build()
)
.build()
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
parentId.startsWith(lms.LOCAL_ARTIST) -> {
// Show albums for this artist. We query all albums and filter
// by artist name match since MediaStore doesn't have a clean
// artist→album join.
val artistId = parentId.removePrefix(lms.LOCAL_ARTIST).toLongOrNull()
if (artistId == null) {
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
return
}
val artists = localScanner.scanArtists()
val artistName = artists.firstOrNull { it.first == artistId }?.second
if (artistName == null) {
future.set(LibraryResult.ofItemList(ImmutableList.of(), params))
return
}
val albums = localScanner.scanAlbums()
.filter { it.artist.equals(artistName, ignoreCase = true) }
val items = albums.map { album ->
MediaItem.Builder()
.setMediaId("${lms.LOCAL_ALBUM}${album.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(album.title)
.setArtist(album.artist)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.apply {
album.artUri?.let { setArtworkUri(it) }
}
.build()
)
.build()
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
else -> {
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
}
}
}
/** Shared helper — browses a UPnP container and resolves the future.
* Caches each playable result's URI by mediaId so onAddMediaItems
* can resolve when Auto strips RequestMetadata over IPC. */
private suspend fun browseContainer(
udn: String,
containerId: String,
params: LibraryParams?,
future: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
) {
when (val result = cdClient.browse(udn, containerId)) {
is BrowseResult.Success -> {
result.items.forEach { didl ->
if (didl is DidlItem.Track) {
runCatching {
uriCache[didl.id] = android.net.Uri.parse(didl.resourceUrl)
}
}
}
val items = result.items.map { it.toMediaItem() }
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
}
is BrowseResult.Failure -> {
future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_IO))
}
}
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String,
): ListenableFuture<LibraryResult<MediaItem>> {
return Futures.immediateFuture(
LibraryResult.ofError(LibraryResult.RESULT_ERROR_NOT_SUPPORTED)
)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> {
// Acknowledge the search — Auto calls onGetSearchResult for results.
return Futures.immediateFuture(LibraryResult.ofVoid())
}
override fun onGetSearchResult(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch(Dispatchers.IO) {
try {
val items = mutableListOf<MediaItem>()
val lms = dev.stave.local.LocalMediaScanner
// Local albums — browsable results so the user can tap
// to see the album's track list.
val localAlbums = localScanner.searchAlbums(query)
localAlbums.take(10).forEach { album ->
items.add(
MediaItem.Builder()
.setMediaId("${lms.LOCAL_ALBUM}${album.id}")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(album.title)
.setArtist(album.artist)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.apply { album.artUri?.let { setArtworkUri(it) } }
.build()
)
.build()
)
}
// Local files first — always available, no network round-trip.
val localTracks = localScanner.searchTracks(query)
localTracks.take(20).forEach { track ->
val mediaId = "${dev.stave.local.LocalMediaScanner.LOCAL_PREFIX}track:${track.id}"
uriCache[mediaId] = track.contentUri
items.add(
MediaItem.Builder()
.setMediaId(mediaId)
.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setMediaUri(track.contentUri)
.build()
)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artist ?: track.albumArtist)
.setAlbumTitle(track.album)
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.apply { track.artUri?.let { setArtworkUri(it) } }
.build()
)
.build()
)
}
// UPnP server search if connected. Prior implementation
// used `searchBucketAlbumsByTitle` (album titles
// containing the query — rarely matches a person's name)
// + `searchTracksByArtist` (artist EXACTLY equal to the
// query — never matches partial names like "dave").
// Both returned 0 results for typical user queries,
// producing the "search never finds anything" behavior.
// Replaced with parallel `contains`-based bucket searches
// that mirror the phone UI's search semantics. Fired in
// parallel so total latency is the slowest single leg,
// not the sum.
val udn = carBrowseUdn ?: settings.preferredServerUdn.first() // Maintainer, 2026-10-29: search the server the car is browsing
if (udn != null) {
val results = coroutineScope {
val a = async { cdClient.searchBucketAlbumsByTitle(udn, query) }
val b = async { cdClient.searchBucketAlbumsByArtist(udn, query) }
val c = async { cdClient.searchBucketArtists(udn, query) }
val d = async { cdClient.searchBucketTracksByTitle(udn, query) }
val e = async { cdClient.searchBucketTracksByArtist(udn, query) }
listOf(a.await(), b.await(), c.await(), d.await(), e.await())
}
// Merge with dedup by mediaId. Cap each leg at 20 and
// total at 100 so a wide search doesn't ship megabytes
// back to Auto.
val seen = mutableSetOf<String>()
results.forEach { result ->
if (result is BrowseResult.Success) {
result.items.take(20).forEach { didl ->
if (didl is DidlItem.Track) {
runCatching {
uriCache[didl.id] = android.net.Uri.parse(didl.resourceUrl)
}
}
if (didl.id !in seen && items.size < 100) {
items.add(didl.toMediaItem())
seen.add(didl.id)
}
}
}
}
}
future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
} catch (e: Exception) {
android.util.Log.e("EarhornAuto", "onGetSearchResult failed", e)
// Return empty results so Auto stops spinning rather than
// showing a permanent "searching" state.
future.set(LibraryResult.ofItemList(ImmutableList.of(), params))
}
}
// Maintainer, 2026-10-29: up to ~130 items came back regardless of
// pageSize — same Media3 crash as onGetChildren; see paged().
return paged(future, page, pageSize)
}
/**
* Playback resumption — called by Auto (or the system) when it wants
* to restore the last session after the service was killed (e.g. car
* turned off then back on). Loads the persisted queue snapshot so Auto
* shows the Now Playing screen immediately instead of falling back to
* raw Bluetooth audio.
*/
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
scope.launch(Dispatchers.IO) {
try {
val saved = queueStore.load()
// Maintainer, 2026-10-29: Media3 hands these items straight to
// player.setMediaItems, so each one must carry a URI —
// toMediaItem now sets it, and tracks with a blank
// resourceUrl are skipped (they crashed ExoPlayer's
// DefaultMediaSourceFactory on the main thread). The start
// index moves to the first kept track at or after the saved
// one; the saved position applies only if that track survived (D1).
val tracks = saved?.tracks.orEmpty().map { it.toDomain() }
val keptIndices = tracks.indices.filter { tracks[it].resourceUrl.isNotBlank() }
if (saved != null && keptIndices.isNotEmpty()) {
val mediaItems = keptIndices.map { tracks[it].toMediaItem() }
val startIndex = keptIndices.indexOfFirst { it >= saved.currentIndex }
.let { if (it < 0) mediaItems.lastIndex else it }
.coerceIn(0, mediaItems.lastIndex)
val startPositionMs =
if (keptIndices[startIndex] == saved.currentIndex) saved.positionMs.coerceAtLeast(0L)
else 0L
future.set(
MediaSession.MediaItemsWithStartPosition(
mediaItems, startIndex, startPositionMs,
)
)
} else {
future.set(MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L))
}
} catch (e: Exception) {
android.util.Log.e("EarhornAuto", "Playback resumption failed", e)
future.set(MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L))
}
}
return future
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> {
// Resolve playback URIs. Decision tree per item:
// 1. requestMetadata.mediaUri set (own session call)? Use it.
// 2. uriCache contains mediaId (we browsed it earlier)? Use it.
// 3. mediaId looks like local-track? Reconstruct content URI.
// 4. Else: pass through unchanged (best effort).
//
// Step 2 is the critical one for Auto. When Auto's controller
// round-trips items back to the session via setMediaItem /
// addMediaItem, the IPC marshalling drops RequestMetadata. The
// mediaId is the only field reliably preserved. Without the
// cache, UPnP tracks from Auto's browse tree resolve to a null
// URI and the player silently no-ops, leaving the previously
// playing track in place — the "tap a song, nothing happens,
// old track keeps playing" report.
//
// Maintainer, 2026-10-29 (D2): step 4 is no longer "pass through".
// An item with no URI reached ExoPlayer and crashed it
// (checkNotNull(localConfiguration) on the main thread), e.g.
// "play X on Earhorn", which arrives as an empty mediaId plus
// requestMetadata.searchQuery. Now:
// 4. Item already carries a URI (in-process controller)? Keep it.
// 5. searchQuery set? Search (local files first, then the
// preferred UPnP server) — asynchronous, on IO.
// 6. Else: DROPPED, never handed to the player.
return Futures.transform<List<List<MediaItem>>, List<MediaItem>>(
resolvePerItem(mediaItems),
{ perItem -> perItem.flatten() },
MoreExecutors.directExecutor(),
)
}
/** Maintainer, 2026-10-29 (D2): same resolution as [onAddMediaItems]
* (the default implementation delegates there too), plus the start
* index is mapped onto the resolved list — a dropped item would
* otherwise shift it past the end and ExoPlayer throws
* IllegalSeekPositionException. When nothing at all is playable the
* request fails (Media3 reports it to the controller) instead of
* replacing the current queue with an empty one. */
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
return Futures.transformAsync<List<List<MediaItem>>, MediaSession.MediaItemsWithStartPosition>(
resolvePerItem(mediaItems),
{ perItem ->
val flat = perItem.flatten()
if (flat.isEmpty() && mediaItems.isNotEmpty()) {
Futures.immediateFailedFuture<MediaSession.MediaItemsWithStartPosition>(
UnsupportedOperationException("No playable item in the request")
)
} else {
val startDropped = startIndex != C.INDEX_UNSET &&
perItem.getOrNull(startIndex).isNullOrEmpty()
val newStart = if (startIndex == C.INDEX_UNSET || flat.isEmpty()) startIndex
else perItem.take(startIndex).sumOf { it.size }.coerceAtMost(flat.lastIndex)
Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(
flat, newStart,
if (startDropped) C.TIME_UNSET else startPositionMs,
)
)
}
},
MoreExecutors.directExecutor(),
)
}
/** Maintainer, 2026-10-29 (D2): resolve each requested item to the
* playable items it stands for — one for a URI/cache/local-id hit,
* several for a search query, none when unresolvable. Synchronous
* (immediate future) unless a search is needed; searches run on IO
* and complete the future from the service scope, like onGetChildren. */
private fun resolvePerItem(mediaItems: List<MediaItem>): ListenableFuture<List<List<MediaItem>>> {
val direct = mediaItems.map { resolveDirect(it) }
val queries = mediaItems.map { it.requestMetadata.searchQuery?.trim().orEmpty() }
val needsSearch = mediaItems.indices.any { direct[it] == null && queries[it].isNotEmpty() }
if (!needsSearch) {
direct.forEachIndexed { i, d ->
if (d == null) android.util.Log.w("EarhornAuto", "onAddMediaItems: no URI for ${mediaItems[i].mediaId} — dropped")
}
return Futures.immediateFuture(direct.map { listOfNotNull(it) })
}
val future = SettableFuture.create<List<List<MediaItem>>>()
scope.launch(Dispatchers.IO) {
try {
val perItem = mediaItems.indices.map { i ->
val d = direct[i]
when {
d != null -> listOf(d)
queries[i].isNotEmpty() -> searchPlayable(queries[i])
else -> emptyList()
}
}
future.set(perItem)
} catch (e: Exception) {
android.util.Log.e("EarhornAuto", "search-to-play failed", e)
future.setException(e)
}
}
return future
}
/** Steps 1–4 of the [onAddMediaItems] decision tree; null = unresolved. */
private fun resolveDirect(item: MediaItem): MediaItem? {
val direct = item.requestMetadata.mediaUri
if (direct != null) {
return item.buildUpon().setUri(direct).build()
}
val cached = uriCache[item.mediaId]
if (cached != null) {
return item.buildUpon().setUri(cached).build()
}
val mid = item.mediaId
val lms = dev.stave.local.LocalMediaScanner
if (mid.startsWith("${lms.LOCAL_PREFIX}track:")) {
val trackId = mid.removePrefix("${lms.LOCAL_PREFIX}track:").toLongOrNull()
if (trackId != null) {
val contentUri = android.content.ContentUris.withAppendedId(
android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
trackId,
)
return item.buildUpon().setUri(contentUri).build()
}
}
if (item.localConfiguration != null) return item
return null
}
/** Tracks matching [query] as playable items (URI set): local files
* first; if none, the preferred UPnP server's track search (title
* and artist), bounded so a silent server can't stall the request. */
private suspend fun searchPlayable(query: String): List<MediaItem> {
val limit = 50
val lms = dev.stave.local.LocalMediaScanner
val local = localScanner.searchTracks(query).take(limit).map { track ->
MediaItem.Builder()
.setMediaId("${lms.LOCAL_PREFIX}track:${track.id}")
.setUri(track.contentUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artist ?: track.albumArtist)
.setAlbumTitle(track.album)
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.apply { track.artUri?.let { setArtworkUri(it) } }
.build()
)
.build()
}
if (local.isNotEmpty()) return local
val udn = carBrowseUdn ?: settings.preferredServerUdn.first() ?: return emptyList() // Maintainer, 2026-10-29: same server as car browsing
val results = kotlinx.coroutines.withTimeoutOrNull(15_000L) {
coroutineScope {
val byTitle = async { cdClient.searchBucketTracksByTitle(udn, query) }
val byArtist = async { cdClient.searchBucketTracksByArtist(udn, query) }
listOf(byTitle.await(), byArtist.await())
}
}.orEmpty()
return results
.flatMap { (it as? BrowseResult.Success)?.items.orEmpty() }
.filterIsInstance<DidlItem.Track>()
.filter { it.resourceUrl.isNotBlank() }
.distinctBy { it.id }
.take(limit)
.map { it.toMediaItem() }
}
}
}
/** Convert a UPnP DidlItem to a Media3 MediaItem for the Auto browse tree. */
private fun DidlItem.toMediaItem(): MediaItem {
return when (this) {
is DidlItem.Container -> {
MediaItem.Builder()
.setMediaId(id)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(
if (isAlbum) MediaMetadata.MEDIA_TYPE_ALBUM
else MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
.apply {
albumArtUri?.let {
setArtworkUri(android.net.Uri.parse(it))
}
}
.build()
)
.build()
}
is DidlItem.Track -> {
MediaItem.Builder()
.setMediaId(id)
// Maintainer, 2026-10-29: a real playback URI, not only
// RequestMetadata.mediaUri — items that reach the player
// directly (playback resumption, search-resolved plays) need
// localConfiguration or ExoPlayer throws (D1).
.apply { if (resourceUrl.isNotBlank()) setUri(resourceUrl) }
.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setMediaUri(android.net.Uri.parse(resourceUrl))
.build()
)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist ?: albumArtist)
.setAlbumTitle(album)
.setTrackNumber(trackNumber)
.setDiscNumber(discNumber)
.setGenre(genre)
.setRecordingYear(
date?.let { Regex("""\d{4}""").find(it)?.value?.toIntOrNull() }
)
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.apply {
albumArtUri?.let {
setArtworkUri(android.net.Uri.parse(it))
}
duration?.inWholeMilliseconds?.let {
setExtras(Bundle().apply { putLong("duration_ms", it) })
}
}
.build()
)
.build()
}
}
}