EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

PlaybackService.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.playback
2
3import android.app.PendingIntent
4import android.content.Context
5import android.content.Intent
6import android.net.Uri
7import android.os.Build
8import android.os.Bundle
9import androidx.annotation.OptIn
10import androidx.media3.common.AudioAttributes
11import androidx.media3.common.C
12import androidx.media3.common.ForwardingPlayer
13import androidx.media3.common.MediaItem
14import androidx.media3.common.MediaMetadata
15import androidx.media3.common.Player
16import androidx.media3.common.util.UnstableApi
17import androidx.media3.datasource.DefaultDataSource
18import androidx.media3.datasource.okhttp.OkHttpDataSource
19import androidx.media3.exoplayer.ExoPlayer
20import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
21import androidx.media3.session.CommandButton
22import androidx.media3.session.DefaultMediaNotificationProvider
23import androidx.media3.session.LibraryResult
24import androidx.media3.session.MediaLibraryService
25import androidx.media3.session.MediaSession
26import androidx.media3.session.SessionCommand
27import androidx.media3.session.SessionCommands
28import androidx.media3.session.SessionResult
29import com.google.common.collect.ImmutableList
30import com.google.common.util.concurrent.Futures
31import com.google.common.util.concurrent.ListenableFuture
32import com.google.common.util.concurrent.MoreExecutors
33import com.google.common.util.concurrent.SettableFuture
34import dagger.hilt.EntryPoint
35import dagger.hilt.InstallIn
36import dagger.hilt.android.EntryPointAccessors
37import dagger.hilt.components.SingletonComponent
38import dev.stave.R
39import dev.stave.data.SettingsStore
40import dev.stave.data.toDomain
41import dev.stave.domain.model.DidlItem
42import dev.stave.upnp.BrowseResult
43import dev.stave.upnp.ContentDirectoryClient
44import dev.stave.upnp.UpnpServiceHolder
45import kotlinx.coroutines.CoroutineScope
46import kotlinx.coroutines.Dispatchers
47import kotlinx.coroutines.SupervisorJob
48import kotlinx.coroutines.async
49import kotlinx.coroutines.coroutineScope
50import kotlinx.coroutines.flow.distinctUntilChanged
51import kotlinx.coroutines.flow.first
52import kotlinx.coroutines.flow.map
53import kotlinx.coroutines.launch
54import kotlinx.coroutines.withContext
55import okhttp3.OkHttpClient
56import java.util.concurrent.TimeUnit
57
58/**
59 * Holds the ExoPlayer and a MediaLibrarySession. Extends [MediaLibraryService]
60 * (which IS a MediaSessionService) so that:
61 *
62 * 1. The TV launcher and notification get the same transport buttons as before.
63 * 2. Android Auto can browse the UPnP library tree and play tracks by
64 * discovering this service through the manifest's media-browser intent filter.
65 *
66 * The browse callback bridges the UPnP ContentDirectory (via [ContentDirectoryClient])
67 * to the MediaItem tree that Auto's built-in media UI expects. Each container
68 * becomes a browsable MediaItem; each track becomes a playable MediaItem whose
69 * mediaId holds the UPnP object ID and whose requestMetadata holds the HTTP URL
70 * for playback.
71 *
72 * Android Auto integration notes:
73 * - When no server is preferred, the browse tree shows available servers as top-level
74 * items. Selecting one sets it as preferred and refreshes the tree.
75 * - onSearch delegates to the UPnP ContentDirectory's Search action.
76 * - The service starts UPnP discovery independently of the Activity so Auto can
77 * browse even when the TV UI isn't running.
78 */
79/** Notification id for the casting keep-alive foreground notification.
80 * Top-level (not in the class companion) because PlaybackService's
81 * companion object holds the nav/CMD constants referenced app-wide —
82 * a second companion is illegal and broke every one of those refs. */
83private const val CASTING_NOTIFICATION_ID = 41042
84
85/** Notification id for the OBSERVING keep-alive foreground notification —
86 * raw-cast follow mode. Distinct from the casting id so transitions between
87 * the two modes replace rather than leak notifications. */
88private const val OBSERVING_NOTIFICATION_ID = 41043
89
90class PlaybackService : MediaLibraryService() {
91
92 private var player: ExoPlayer? = null
93
94 // ── Casting keep-alive state ──────────────────────────────────────
95 private var castingWifiLock: android.net.wifi.WifiManager.WifiLock? = null
96 private var castingWakeLock: android.os.PowerManager.WakeLock? = null
97
98 private fun enterCastingKeepAlive() {
99 // Foreground promotion — a minimal, silent, low-priority
100 // notification. Media3 isn't providing one because the local
101 // player is paused during cast.
102 val channelId = "earhorn_casting"
103 val nm = getSystemService(android.app.NotificationManager::class.java)
104 if (android.os.Build.VERSION.SDK_INT >= 26 &&
105 nm.getNotificationChannel(channelId) == null
106 ) {
107 nm.createNotificationChannel(
108 android.app.NotificationChannel(
109 channelId, "Casting",
110 android.app.NotificationManager.IMPORTANCE_LOW,
111 ).apply { setShowBadge(false) }
112 )
113 }
114 val notif = if (android.os.Build.VERSION.SDK_INT >= 26) {
115 android.app.Notification.Builder(this, channelId)
116 .setSmallIcon(R.drawable.ic_notif_cast_connected)
117 .setContentTitle("Casting")
118 .setContentText("Keeping the stream alive for your speakers")
119 .setOngoing(true)
120 .build()
121 } else {
122 @Suppress("DEPRECATION")
123 android.app.Notification.Builder(this)
124 .setSmallIcon(R.drawable.ic_notif_cast_connected)
125 .setContentTitle("Casting")
126 .setContentText("Keeping the stream alive for your speakers")
127 .setOngoing(true)
128 .setPriority(android.app.Notification.PRIORITY_LOW)
129 .build()
130 }
131 runCatching {
132 if (android.os.Build.VERSION.SDK_INT >= 29) {
133 startForeground(
134 CASTING_NOTIFICATION_ID, notif,
135 android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
136 )
137 } else {
138 startForeground(CASTING_NOTIFICATION_ID, notif)
139 }
140 }
141
142 // Network + CPU locks: the proxy must keep serving bytes with the
143 // screen off and Wi-Fi power-saving active.
144 if (castingWifiLock == null) {
145 val wm = applicationContext.getSystemService(android.net.wifi.WifiManager::class.java)
146 castingWifiLock = wm?.createWifiLock(
147 android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF, "earhorn:castProxy",
148 )
149 }
150 if (castingWakeLock == null) {
151 val pm = getSystemService(android.os.PowerManager::class.java)
152 castingWakeLock = pm?.newWakeLock(
153 android.os.PowerManager.PARTIAL_WAKE_LOCK, "earhorn:castProxy",
154 )
155 }
156 runCatching { if (castingWifiLock?.isHeld != true) castingWifiLock?.acquire() }
157 runCatching { if (castingWakeLock?.isHeld != true) castingWakeLock?.acquire() }
158 }
159
160 private fun exitCastingKeepAlive() {
161 runCatching { if (castingWifiLock?.isHeld == true) castingWifiLock?.release() }
162 runCatching { if (castingWakeLock?.isHeld == true) castingWakeLock?.release() }
163 // Drop our foreground claim only if local playback isn't running —
164 // if the session handed back to local play, Media3's own media
165 // notification takes over foreground duty. Same courtesy for
166 // observing: if the user hopped from casting to following, the
167 // observing keep-alive now owns foreground and we must not yank it.
168 val locallyPlaying = runCatching { player?.isPlaying == true }.getOrDefault(false)
169 val observing = runCatching { rawCast.observing.value }.getOrDefault(false)
170 if (!locallyPlaying && !observing) {
171 runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
172 }
173 }
174
175 // ── Observing keep-alive ──────────────────────────────────────────
176 // Raw-cast follow mode ("observer mode") holds a TLS socket to the CCA
177 // and nothing else — the local player is idle, so Media3 provides no
178 // foreground notification, and on Android 12+ the cached-app freezer
179 // freezes the process the moment the user leaves the app. Frozen
180 // process = dead socket = observer mode silently gone when the user
181 // comes back, with the stale local queue still on screen (the exact
182 // confusing UX reported from the field). Mirror of the casting
183 // keep-alive: foreground promotion + Wi-Fi lock while observing. No
184 // wake lock — unlike casting we serve no audio bytes, so if the CPU
185 // dozes off the observer's own reconnect supervisor and the persisted
186 // reattach path recover state on wake instead of burning battery
187 // holding the CPU up all night.
188 private var observingWifiLock: android.net.wifi.WifiManager.WifiLock? = null
189
190 private fun buildObservingNotification(
191 deviceName: String?,
192 trackLine: String?,
193 ): android.app.Notification {
194 val channelId = "earhorn_observing"
195 val nm = getSystemService(android.app.NotificationManager::class.java)
196 if (android.os.Build.VERSION.SDK_INT >= 26 &&
197 nm.getNotificationChannel(channelId) == null
198 ) {
199 nm.createNotificationChannel(
200 android.app.NotificationChannel(
201 channelId, "Following a Cast session",
202 android.app.NotificationManager.IMPORTANCE_LOW,
203 ).apply { setShowBadge(false) }
204 )
205 }
206 val title = deviceName?.let { "Following $it" } ?: "Following Cast playback"
207 val text = trackLine ?: "Keeping the connection alive"
208 // Tap → open the app on Now Playing. The launcher activity restores
209 // where the user was; with observing alive the NowPlaying screen
210 // renders the observed session, which is where a notification tap
211 // should land.
212 val contentIntent = packageManager.getLaunchIntentForPackage(packageName)?.let {
213 android.app.PendingIntent.getActivity(
214 this, 0, it,
215 android.app.PendingIntent.FLAG_UPDATE_CURRENT or
216 android.app.PendingIntent.FLAG_IMMUTABLE,
217 )
218 }
219 return if (android.os.Build.VERSION.SDK_INT >= 26) {
220 android.app.Notification.Builder(this, channelId)
221 .setSmallIcon(R.drawable.ic_notif_cast_connected)
222 .setContentTitle(title)
223 .setContentText(text)
224 .setOngoing(true)
225 .apply { contentIntent?.let { setContentIntent(it) } }
226 .build()
227 } else {
228 @Suppress("DEPRECATION")
229 android.app.Notification.Builder(this)
230 .setSmallIcon(R.drawable.ic_notif_cast_connected)
231 .setContentTitle(title)
232 .setContentText(text)
233 .setOngoing(true)
234 .setPriority(android.app.Notification.PRIORITY_LOW)
235 .apply { contentIntent?.let { setContentIntent(it) } }
236 .build()
237 }
238 }
239
240 private fun enterObservingKeepAlive() {
241 val notif = buildObservingNotification(
242 deviceName = runCatching { rawCast.connectedDeviceName.value }.getOrNull(),
243 trackLine = null,
244 )
245 runCatching {
246 if (android.os.Build.VERSION.SDK_INT >= 29) {
247 startForeground(
248 OBSERVING_NOTIFICATION_ID, notif,
249 android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
250 )
251 } else {
252 startForeground(OBSERVING_NOTIFICATION_ID, notif)
253 }
254 }
255 if (observingWifiLock == null) {
256 val wm = applicationContext.getSystemService(android.net.wifi.WifiManager::class.java)
257 // WIFI_MODE_FULL keeps the connection up exactly as before; it
258 // stops forbidding the chip's own power saving on every wall
259 // screen all day (I.21, design decision 2026-09-02).
260 observingWifiLock = wm?.createWifiLock(
261 android.net.wifi.WifiManager.WIFI_MODE_FULL, "earhorn:observe",
262 )
263 }
264 runCatching { if (observingWifiLock?.isHeld != true) observingWifiLock?.acquire() }
265 }
266
267 private fun exitObservingKeepAlive() {
268 runCatching { if (observingWifiLock?.isHeld == true) observingWifiLock?.release() }
269 val locallyPlaying = runCatching { player?.isPlaying == true }.getOrDefault(false)
270 val casting = runCatching { cast.isCasting.value }.getOrDefault(false)
271 if (!locallyPlaying && !casting) {
272 runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
273 } else {
274 // Foreground duty belongs to local play / casting; just retire
275 // our notification.
276 runCatching {
277 getSystemService(android.app.NotificationManager::class.java)
278 .cancel(OBSERVING_NOTIFICATION_ID)
279 }
280 }
281 }
282 private var session: MediaLibrarySession? = null
283 // The ForwardingPlayer wrapper that the MediaSession actually reads from —
284 // its cast-aware getters return CCA state when casting, exo state otherwise.
285 // Kept as a field so the wake receiver can reflectively call its notify*
286 // methods to force a notification metadata refresh after deep sleep.
287 private var sessionPlayerRef: androidx.media3.common.Player? = null
288 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
289
290 // Maintainer, 2026-10-29: the server the car is currently browsing, kept in
291 // memory only. Opening a server row in the car used to call
292 // settings.setPreferredServer and silently change the phone/TV app's
293 // saved server (D7). Written/read from the browse coroutines on IO.
294 @Volatile
295 private var carBrowseUdn: String? = null
296
297 @EntryPoint
298 @InstallIn(SingletonComponent::class)
299 interface ServiceDeps {
300 fun contentDirectoryClient(): ContentDirectoryClient
301 fun settingsStore(): SettingsStore
302 fun upnpServiceHolder(): UpnpServiceHolder
303 fun localMediaScanner(): dev.stave.local.LocalMediaScanner
304 fun castIntegration(): dev.stave.cast.CastIntegration
305 fun queuePersistenceStore(): dev.stave.data.QueuePersistenceStore
306 fun rawCastObserver(): dev.stave.cast.raw.RawCastObserver
307 }
308
309 private lateinit var cdClient: ContentDirectoryClient
310 private lateinit var settings: SettingsStore
311 private lateinit var upnpHolder: UpnpServiceHolder
312 private lateinit var localScanner: dev.stave.local.LocalMediaScanner
313 private lateinit var cast: dev.stave.cast.CastIntegration
314 private lateinit var queueStore: dev.stave.data.QueuePersistenceStore
315 private lateinit var rawCast: dev.stave.cast.raw.RawCastObserver
316
317 // ServiceConnection for UPnP — the PlaybackService binds independently of
318 // MainActivity so Android Auto can browse even when the TV Activity isn't alive.
319 private val upnpConnection = object : android.content.ServiceConnection {
320 override fun onServiceConnected(name: android.content.ComponentName?, binder: android.os.IBinder?) {
321 (binder as? org.jupnp.android.AndroidUpnpService)?.let {
322 upnpHolder.attach(it)
323 }
324 }
325 override fun onServiceDisconnected(name: android.content.ComponentName?) {
326 upnpHolder.detach()
327 }
328 }
329 private var upnpBound = false
330
331 override fun onCreate() {
332 super.onCreate()
333
334 val deps = EntryPointAccessors.fromApplication(applicationContext, ServiceDeps::class.java)
335 cdClient = deps.contentDirectoryClient()
336 settings = deps.settingsStore()
337 upnpHolder = deps.upnpServiceHolder()
338 localScanner = deps.localMediaScanner()
339 cast = deps.castIntegration()
340 queueStore = deps.queuePersistenceStore()
341 rawCast = deps.rawCastObserver()
342
343 // ── Observing keep-alive + reattach ────────────────────────────
344 // The observer socket lives in the process; this service's job is
345 // to keep the process alive while observing (foreground promotion,
346 // mirroring the casting keep-alive below) and to resurrect a
347 // session the process died with (reattachIfPersisted). See
348 // enterObservingKeepAlive for the full failure-mode writeup.
349 scope.launch {
350 rawCast.observing.collect { observing ->
351 if (observing) enterObservingKeepAlive() else exitObservingKeepAlive()
352 }
353 }
354 // Keep the keep-alive notification's content current: show the
355 // observed track (title — artist) instead of a static line, updated
356 // on track change only (not position ticks).
357 scope.launch {
358 kotlinx.coroutines.flow.combine(
359 rawCast.observing,
360 rawCast.playbackState
361 .map { it.current?.let { t -> t.title to t.artist } }
362 .distinctUntilChanged(),
363 ) { observing, titleArtist -> observing to titleArtist }
364 .collect { (observing, titleArtist) ->
365 if (!observing || titleArtist == null) return@collect
366 val (title, artist) = titleArtist
367 val line = if (artist.isNullOrBlank()) title else "$title — $artist"
368 runCatching {
369 getSystemService(android.app.NotificationManager::class.java)
370 .notify(
371 OBSERVING_NOTIFICATION_ID,
372 buildObservingNotification(
373 rawCast.connectedDeviceName.value, line,
374 ),
375 )
376 }
377 }
378 }
379 // Reattach: if the process died mid-observation, reconnect and — only
380 // if the CCA session is still live — resume following. Runs off-main;
381 // failure modes are all handled inside (see RawCastObserver docs).
382 scope.launch(Dispatchers.IO) {
383 runCatching { rawCast.reattachIfPersisted() }
384 .onFailure { android.util.Log.w("EarhornCast", "reattach failed", it) }
385 }
386
387 // ── Casting keep-alive (background-stall fix) ──────────────────
388 // While casting, the CCA streams through this phone's
389 // CastStreamProxy, but the LOCAL player is paused — so Media3
390 // drops the service out of foreground, and on Android 12+ the
391 // cached-app freezer then freezes the whole process the moment
392 // the user switches apps. Frozen process = dead proxy = the
393 // speaker group's buffer runs dry and playback stops until the
394 // user reopens the app (at which point the receiver, having hit
395 // a stream error, skips to the next track). Keep the service
396 // foreground with a lightweight "Casting" notification and hold
397 // Wifi/partial-wake locks for the duration of the session.
398 // Libre flavor: isCasting is constantly false → this is inert.
399 scope.launch {
400 cast.isCasting.collect { casting ->
401 if (casting) enterCastingKeepAlive() else exitCastingKeepAlive()
402 }
403 }
404
405 // Bind UPnP service from the PlaybackService itself — this ensures Auto
406 // can discover servers even when the main Activity hasn't been opened.
407 // Set the wifi/ethernet-only interface restriction BEFORE bind so jUPnP
408 // doesn't bind SSDP multicast to a VPN tun interface (NetGuard etc.) or
409 // cellular. See [NetworkInterfaceResolver] for rationale.
410 // Unconditional on every device (design decision 2026-09-02,
411 // reverting the automation-switch gating): UPnP and kiosk together.
412 dev.stave.upnp.NetworkInterfaceResolver.applyToSystemProperties(applicationContext)
413 runCatching {
414 bindService(
415 android.content.Intent(this, org.jupnp.android.AndroidUpnpServiceImpl::class.java),
416 upnpConnection,
417 android.content.Context.BIND_AUTO_CREATE,
418 )
419 upnpBound = true
420 }.onFailure {
421 android.util.Log.e("EarhornAuto", "Failed to bind UPnP from PlaybackService", it)
422 }
423
424 val okhttp = OkHttpClient.Builder()
425 .connectTimeout(10, TimeUnit.SECONDS)
426 .readTimeout(60, TimeUnit.SECONDS)
427 .build()
428
429 // OkHttp for HTTP fetches — preserves Earhorn user-agent on UPnP requests.
430 val httpDataSourceFactory = OkHttpDataSource.Factory(okhttp)
431 .setUserAgent(earhornUserAgent())
432
433 // DefaultDataSource.Factory dispatches by URI scheme:
434 // - http:// https:// → delegated to httpDataSourceFactory (OkHttp + Earhorn UA)
435 // - content:// → ContentDataSource (MediaStore audio for on-device files)
436 // - file:// → FileDataSource
437 // - asset://, rawresource://, android.resource:// → built-in handlers
438 // Without this wrapper, OkHttpDataSource is the *only* data source the player
439 // knows about, so any content:// URI from the local-files browser fails to open
440 // — player stays in IDLE/ERROR while the MediaController still holds the queue
441 // items, producing the silent-playback-with-pause-icon state. TV isn't affected
442 // because TV plays UPnP HTTP URLs and the http branch of DefaultDataSource still
443 // routes through the OkHttp factory exactly as before.
444 val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory)
445
446 val mediaSourceFactory = DefaultMediaSourceFactory(this)
447 .setDataSourceFactory(dataSourceFactory)
448
449 val exo = ExoPlayer.Builder(this)
450 .setMediaSourceFactory(mediaSourceFactory)
451 .setAudioAttributes(
452 AudioAttributes.Builder()
453 .setUsage(C.USAGE_MEDIA)
454 .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
455 .build(),
456 /* handleAudioFocus = */ true
457 )
458 .setHandleAudioBecomingNoisy(true)
459 .build()
460
461 player = exo
462
463 // Maintainer, 2026-10-29: skip unplayable tracks instead of stopping
464 // silently (D3). ExoPlayer halts the whole queue at the first item it
465 // can't play (no <res>, 404, unsupported format) with no message.
466 // Skip to the next item keeping playWhenReady, tell the user, and give
467 // up after 5 errors in a row (counter resets once something actually
468 // plays). Never while casting: the local player is muted/paused then.
469 exo.addListener(object : Player.Listener {
470 private var consecutiveErrors = 0
471
472 override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
473 if (cast.isCasting.value) return
474 val title = exo.currentMediaItem?.mediaMetadata?.title?.toString()
475 ?.takeIf { it.isNotBlank() } ?: "track"
476 android.util.Log.w("EarhornPlayback", "playback error on '$title'", error)
477 consecutiveErrors++
478 if (consecutiveErrors >= 5) {
479 consecutiveErrors = 0
480 android.widget.Toast.makeText(
481 applicationContext,
482 "Playback stopped — several tracks couldn't be played",
483 android.widget.Toast.LENGTH_LONG,
484 ).show()
485 return
486 }
487 if (!exo.hasNextMediaItem()) {
488 consecutiveErrors = 0
489 android.widget.Toast.makeText(
490 applicationContext,
491 "Couldn't play $title",
492 android.widget.Toast.LENGTH_SHORT,
493 ).show()
494 return
495 }
496 exo.seekToNextMediaItem()
497 exo.prepare()
498 android.widget.Toast.makeText(
499 applicationContext,
500 "Couldn't play $title — skipped",
501 android.widget.Toast.LENGTH_SHORT,
502 ).show()
503 }
504
505 override fun onIsPlayingChanged(isPlaying: Boolean) {
506 if (isPlaying) consecutiveErrors = 0
507 }
508 })
509
510 // Pre-warm the local media scanner cache on a background thread.
511 // This prevents the first Auto browse from blocking for seconds
512 // while MediaStore scans thousands of files. After this completes,
513 // all scanAlbums/scanArtists/allTracks calls return instantly from
514 // the in-memory cache.
515 scope.launch(Dispatchers.IO) {
516 runCatching {
517 android.util.Log.i("EarhornAuto", "Pre-warming local media cache…")
518 val t0 = System.currentTimeMillis()
519 localScanner.allTracks() // populates the cache
520 val ms = System.currentTimeMillis() - t0
521 android.util.Log.i("EarhornAuto", "Cache warm: ${localScanner.allTracks().size} tracks in ${ms}ms")
522 }
523 }
524
525 // ForwardingPlayer wraps ExoPlayer so the MediaSession (and its
526 // notification) routes play/pause/seek/volume commands to the Cast
527 // device when a Cast session is active.
528 //
529 // Listener tracking: we intercept addListener/removeListener to
530 // maintain our own set. When cast state changes, we fire
531 // onDeviceInfoChanged on all listeners so the MediaSession picks
532 // up the PLAYBACK_TYPE_REMOTE → PLAYBACK_TYPE_LOCAL transition
533 // (or vice versa) and the system routes background/lockscreen
534 // volume keys accordingly. Without this, the session caches the
535 // DeviceInfo it read at creation time and never re-checks.
536 val sessionPlayer = @OptIn(UnstableApi::class) object : ForwardingPlayer(exo) {
537 private val trackedListeners = mutableSetOf<Player.Listener>()
538
539 override fun addListener(listener: Player.Listener) {
540 super.addListener(listener)
541 trackedListeners.add(listener)
542 }
543 override fun removeListener(listener: Player.Listener) {
544 super.removeListener(listener)
545 trackedListeners.remove(listener)
546 }
547 fun notifyDeviceInfoChanged() {
548 val info = getDeviceInfo()
549 trackedListeners.forEach { runCatching { it.onDeviceInfoChanged(info) } }
550 }
551
552 /** Fan out the synthetic play-state to all tracked listeners.
553 * Called whenever cast play-state or session-active state
554 * changes — without this the MediaSession (and therefore
555 * the system notification) keeps reporting exo's frozen
556 * paused state while the receiver is actually playing.
557 * The session reads the player getters when it receives
558 * these events; our overrides below return the cast-aware
559 * values so the session picks up the right state. */
560 fun notifyPlayStateChanged() {
561 val playing = isPlaying
562 val pwr = getPlayWhenReady()
563 val state = getPlaybackState()
564 trackedListeners.forEach { l ->
565 runCatching {
566 l.onIsPlayingChanged(playing)
567 l.onPlayWhenReadyChanged(
568 pwr,
569 Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE,
570 )
571 l.onPlaybackStateChanged(state)
572 }
573 }
574 }
575
576 /** Fan out current-item-changed events to all listeners. The
577 * notification reads track metadata (title/artist/art) from
578 * the player's current MediaItem — when the receiver
579 * advances to a new track our cast-aware getters above
580 * return the new index/item/metadata, but the session
581 * doesn't know to re-read until a listener event fires.
582 * Without this, the lockscreen stays pinned to the track
583 * that was current when the cast session started. */
584 fun notifyMediaItemChanged() {
585 val item = getCurrentMediaItem()
586 val metadata = getMediaMetadata()
587 trackedListeners.forEach { l ->
588 runCatching {
589 l.onMediaItemTransition(
590 item,
591 Player.MEDIA_ITEM_TRANSITION_REASON_AUTO,
592 )
593 l.onMediaMetadataChanged(metadata)
594 }
595 }
596 }
597
598 // Hide shuffle/repeat from the notification template.
599 // Pixel's media notification automatically adds shuffle and
600 // repeat buttons when the Player advertises these commands.
601 // The user wants "More by artist" and "Stop" instead —
602 // those are in the custom layout. Removing the commands
603 // from the Player's advertisement prevents the system from
604 // adding its own shuffle/repeat icons.
605 override fun getAvailableCommands(): Player.Commands {
606 return super.getAvailableCommands().buildUpon()
607 .remove(Player.COMMAND_SET_SHUFFLE_MODE)
608 .remove(Player.COMMAND_SET_REPEAT_MODE)
609 .build()
610 }
611
612 override fun play() {
613 android.util.Log.d("EarhornAuto", "ForwardingPlayer.play() casting=${cast.isCasting.value}")
614 // Maintainer, 2026-10-29: while following the group (observing),
615 // Play from OUTSIDE the app's own screens — notification, lock
616 // screen, headset, car — goes to the observed session, not the
617 // local player: local audio under the group's music was the
618 // "two songs at once" bug (D6). The app's own controller (its
619 // screens, the UPnP renderer service) still drives the local
620 // player as before. A headset PLAY_PAUSE arrives as play()
621 // (the local player is paused), so it toggles the group.
622 if (routeToObservedSession()) {
623 scope.launch {
624 runCatching {
625 if (rawCast.playbackState.value.isPlaying) rawCast.pause() else rawCast.play()
626 }
627 }
628 return
629 }
630 if (cast.isCasting.value) cast.play() else super.play()
631 }
632 override fun pause() {
633 android.util.Log.d("EarhornAuto", "ForwardingPlayer.pause() casting=${cast.isCasting.value}")
634 // Maintainer, 2026-10-29: same routing as play() while observing (D6).
635 if (routeToObservedSession()) {
636 scope.launch { runCatching { rawCast.pause() } }
637 return
638 }
639 if (cast.isCasting.value) cast.pause() else super.pause()
640 }
641 /** Maintainer, 2026-10-29 (D6): true while observing when the current
642 * request comes from outside the app's own screens (media
643 * notification / headset — Media3 attributes both to its
644 * notification controller — or another package such as the
645 * car). The app's own MediaController (same package, not the
646 * notification controller) is never rerouted, so the app's
647 * "pause local when observing starts" and the UPnP renderer
648 * service keep acting on the local player. */
649 private fun routeToObservedSession(): Boolean {
650 if (!rawCast.observing.value) return false
651 val s = session ?: return false
652 val caller = s.controllerForCurrentRequest ?: return false
653 return caller.packageName != packageName || s.isMediaNotificationController(caller)
654 }
655 override fun seekTo(positionMs: Long) {
656 android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekTo($positionMs)")
657 if (cast.isCasting.value) cast.seekTo(positionMs) else super.seekTo(positionMs)
658 }
659 // Lockscreen / headset / notification Next and Previous fork to
660 // the receiver like every other transport here (review N.24,
661 // design decision 2026-09-02): before, only the hidden local
662 // index moved and the speaker kept playing.
663 override fun seekToNextMediaItem() {
664 android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekToNextMediaItem() casting=${cast.isCasting.value}")
665 super.seekToNextMediaItem()
666 if (cast.isCasting.value) cast.queueNext()
667 }
668 override fun seekToPreviousMediaItem() {
669 android.util.Log.d("EarhornAuto", "ForwardingPlayer.seekToPreviousMediaItem() casting=${cast.isCasting.value}")
670 super.seekToPreviousMediaItem()
671 if (cast.isCasting.value) cast.queuePrev()
672 }
673 override fun stop() {
674 android.util.Log.d("EarhornAuto", "ForwardingPlayer.stop() casting=${cast.isCasting.value}")
675 if (cast.isCasting.value) {
676 cast.stop(); cast.seekTo(0)
677 } else super.stop()
678 }
679 override fun getDeviceInfo(): androidx.media3.common.DeviceInfo {
680 if (cast.isCasting.value) {
681 return androidx.media3.common.DeviceInfo.Builder(
682 androidx.media3.common.DeviceInfo.PLAYBACK_TYPE_REMOTE
683 ).setMaxVolume(20).build()
684 }
685 return super.getDeviceInfo()
686 }
687 override fun getDeviceVolume(): Int {
688 if (cast.isCasting.value) {
689 return (cast.getDeviceVolume() * 20).toInt().coerceIn(0, 20)
690 }
691 return super.getDeviceVolume()
692 }
693 override fun isDeviceMuted(): Boolean {
694 if (cast.isCasting.value) return cast.getDeviceVolume() <= 0.0
695 return super.isDeviceMuted()
696 }
697 override fun setDeviceVolume(volume: Int, flags: Int) {
698 if (cast.isCasting.value) {
699 cast.setDeviceVolume(volume / 20.0)
700 } else super.setDeviceVolume(volume, flags)
701 }
702 override fun increaseDeviceVolume(flags: Int) {
703 if (cast.isCasting.value) {
704 cast.setDeviceVolume(cast.getDeviceVolume() + 0.05)
705 } else super.increaseDeviceVolume(flags)
706 }
707 override fun decreaseDeviceVolume(flags: Int) {
708 if (cast.isCasting.value) {
709 cast.setDeviceVolume(cast.getDeviceVolume() - 0.05)
710 } else super.decreaseDeviceVolume(flags)
711 }
712
713 // Synthetic play-state during cast. While casting, exo is paused
714 // (volume=0, playWhenReady=false) so the phone doesn't decode
715 // the stream alongside the receiver — but the MediaSession
716 // reads playback state directly from these getters, and a
717 // paused exo would make the system notification show paused
718 // even though the receiver is playing. Override the trio of
719 // getters Media3's notification template reads (isPlaying,
720 // playWhenReady, playbackState) so the lockscreen icon
721 // matches what the user hears. Listener fan-out via
722 // notifyPlayStateChanged() keeps these in sync when cast
723 // state actually changes.
724 override fun isPlaying(): Boolean {
725 return if (cast.isCasting.value) cast.castIsPlaying.value
726 else super.isPlaying()
727 }
728 override fun getPlayWhenReady(): Boolean {
729 return if (cast.isCasting.value) cast.castIsPlaying.value
730 else super.getPlayWhenReady()
731 }
732 override fun getPlaybackState(): Int {
733 // Always report READY during cast — STATE_IDLE/STATE_ENDED
734 // would suppress the notification, and STATE_BUFFERING
735 // would show a spinner. The receiver handles its own
736 // buffering UI on-screen; we just say "ready" so the
737 // session reports a stable playing/paused indicator
738 // driven by playWhenReady.
739 return if (cast.isCasting.value) Player.STATE_READY
740 else super.getPlaybackState()
741 }
742
743 // Synthetic current-item index/metadata during cast. exo's
744 // local timeline still holds every track we loaded into the
745 // queue, but exo doesn't advance through it (paused for
746 // heat reasons). The notification reads title/artist/art
747 // from the player's current media item — without these
748 // overrides it'd be stuck on whichever track was current
749 // when the cast session started while the receiver moves
750 // on to track 2, 3, … and the lockscreen never catches up.
751 // We return the receiver's authoritative index, then let
752 // the base Player path resolve that to the right MediaItem
753 // out of the loaded timeline. Listener fan-out via
754 // notifyMediaItemChanged() (below) fires on every receiver
755 // queue advance so the session re-reads.
756 override fun getCurrentMediaItemIndex(): Int {
757 if (cast.isCasting.value) {
758 val idx = cast.castCurrentItemIndex.value
759 if (idx >= 0 && idx < mediaItemCount) return idx
760 }
761 return super.getCurrentMediaItemIndex()
762 }
763 override fun getCurrentMediaItem(): androidx.media3.common.MediaItem? {
764 if (cast.isCasting.value) {
765 val idx = cast.castCurrentItemIndex.value
766 if (idx >= 0 && idx < mediaItemCount) return getMediaItemAt(idx)
767 }
768 return super.getCurrentMediaItem()
769 }
770 override fun getMediaMetadata(): androidx.media3.common.MediaMetadata {
771 if (cast.isCasting.value) {
772 val item = getCurrentMediaItem()
773 if (item != null) return item.mediaMetadata
774 }
775 return super.getMediaMetadata()
776 }
777 }
778
779 // Cast audio-focus manager. When a Cast session starts, disable
780 // ExoPlayer's audio focus handling so we can keep playWhenReady=true
781 // (for the notification icon) without ExoPlayer losing focus to the
782 // Cast SDK and auto-pausing. Volume→0 silences local output. We
783 // ALSO pause exo entirely while casting — previously a separate
784 // mirror flipped exo.playWhenReady to match castIsPlaying, which
785 // meant the local player kept decoding the same UPnP stream the
786 // receiver was already decoding. Same bits over the network
787 // twice, same CPU spent on FLAC/MP3 demux, audible heat in the
788 // user's hand. exo.pause() releases the renderers; when cast
789 // ends we let the existing restore path bring it back up.
790 // On session end, restore normal focus handling and unmute.
791 scope.launch {
792 cast.isCasting.collect { casting ->
793 if (casting) {
794 exo.setAudioAttributes(
795 AudioAttributes.Builder()
796 .setUsage(C.USAGE_MEDIA)
797 .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
798 .build(),
799 /* handleAudioFocus = */ false,
800 )
801 exo.volume = 0f
802 // Stop active decoding. Queue is preserved (no stop()
803 // call), so re-toggling cast off resumes from the
804 // current track at position 0.
805 exo.playWhenReady = false
806 } else {
807 // Pause before unmuting — playWhenReady may still be
808 // true from the castIsPlaying mirror. Without this,
809 // disconnecting from cast instantly resumes playback
810 // through the phone speaker at full volume.
811 exo.playWhenReady = false
812 exo.volume = 1f
813 exo.setAudioAttributes(
814 AudioAttributes.Builder()
815 .setUsage(C.USAGE_MEDIA)
816 .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
817 .build(),
818 /* handleAudioFocus = */ true,
819 )
820 }
821 // Poke the session's listeners so it re-reads DeviceInfo
822 // (PLAYBACK_TYPE_REMOTE when casting, LOCAL when not).
823 // Without this, the session caches the DeviceInfo from
824 // creation time and background volume keys always hit the
825 // phone stream instead of the Cast device.
826 sessionPlayer.notifyDeviceInfoChanged()
827 // Also fire play-state listeners — our overrides return
828 // cast state when casting and exo state otherwise, so
829 // every casting toggle is a play-state transition the
830 // session needs to be told about.
831 sessionPlayer.notifyPlayStateChanged()
832 // And media-item — current track shifts between exo's
833 // pinned index and the receiver's at every cast
834 // toggle, so the notification needs to re-read.
835 sessionPlayer.notifyMediaItemChanged()
836 }
837 }
838
839 // Cast play-state listener-only mirror. The session reads its
840 // play/pause state from the player getters (isPlaying et al.)
841 // which we've overridden to return cast values during cast.
842 // Listeners need to be told when those values change or the
843 // session won't re-read. CRITICAL: this only fans out events,
844 // never sets exo.playWhenReady — that's what the old mirror
845 // did, and it caused the phone to overheat (exo silently
846 // decoding alongside the receiver, same stream, twice the
847 // work). Notification accuracy without the heat penalty.
848 scope.launch {
849 cast.castIsPlaying.collect {
850 if (cast.isCasting.value) {
851 sessionPlayer.notifyPlayStateChanged()
852 }
853 }
854 }
855
856 // Same pattern for the current-track index. When the receiver
857 // advances to a new queue item, fire onMediaItemTransition +
858 // onMediaMetadataChanged so the session re-reads the player's
859 // (now cast-aware) current MediaItem and the lockscreen art /
860 // title / artist follow along. Without this collector the
861 // overridden getters return correct values but no one knows
862 // to call them — the notification stays stuck on the track
863 // that was current when the cast session started.
864 scope.launch {
865 cast.castCurrentItemIndex.collect {
866 if (cast.isCasting.value) {
867 sessionPlayer.notifyMediaItemChanged()
868 }
869 }
870 }
871
872 // Two PendingIntents:
873 // - sessionActivityPi: fires when the user taps the body of
874 // the system media notification (album art, title area).
875 // Per UX spec, this opens the Queue screen — the album art
876 // IS the queue button.
877 // - openAppPi: fallback for when no specific deep-link is
878 // appropriate; just brings the app to foreground.
879 val openAppIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
880 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
881 }
882 val openAppPi = openAppIntent?.let {
883 PendingIntent.getActivity(
884 this, 0, it,
885 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
886 )
887 }
888 val sessionActivityPi = openAppIntent?.let {
889 val queueDeepLink = Intent(it).apply {
890 putExtra(EXTRA_NAV_ROUTE, NAV_QUEUE)
891 }
892 PendingIntent.getActivity(
893 this, REQUEST_CODE_QUEUE, queueDeepLink,
894 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
895 )
896 } ?: openAppPi
897
898 val sessionBuilder = MediaLibrarySession.Builder(this, sessionPlayer, LibraryCallback())
899 .setCustomLayout(buildNotificationLayout(exo))
900 // Custom BitmapLoader: forces 1024x1024 fetches via Coil so
901 // Auto's now-playing card and the system notification get a
902 // big image regardless of where the URI points. Default
903 // DataSourceBitmapLoader decodes whatever the URI returns,
904 // which for content://media/external/audio/albumart can be
905 // a 256-px thumbnail. Wrapped in CacheBitmapLoader so
906 // repeated requests for the same URI hit cache.
907 .setBitmapLoader(
908 androidx.media3.session.CacheBitmapLoader(CoilBitmapLoader(this))
909 )
910 if (sessionActivityPi != null) {
911 sessionBuilder.setSessionActivity(sessionActivityPi)
912 }
913 session = sessionBuilder.build()
914 // Expose the session-player ref for the wake receiver. See class-level
915 // doc on sessionPlayerRef for why.
916 sessionPlayerRef = sessionPlayer
917 // Register the wake-on-screen receiver. RECEIVER_NOT_EXPORTED on R+ —
918 // these are system broadcasts so the flag is required but they're
919 // exempt from the broadcast-export protection (sourced by the OS).
920 if (!wakeReceiverRegistered) {
921 val filter = android.content.IntentFilter().apply {
922 addAction(android.content.Intent.ACTION_SCREEN_ON)
923 addAction(android.content.Intent.ACTION_USER_PRESENT)
924 }
925 runCatching {
926 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
927 registerReceiver(wakeReceiver, filter, android.content.Context.RECEIVER_NOT_EXPORTED)
928 } else {
929 @Suppress("UnspecifiedRegisterReceiverFlag")
930 registerReceiver(wakeReceiver, filter)
931 }
932 wakeReceiverRegistered = true
933 }
934 }
935 // Expose the compat token for the Car App Library.
936 sessionCompatToken = getCompatToken(session!!)
937 // Initial layout push via Media3 1.4+ API
938 @Suppress("NewApi")
939 runCatching { session!!.setMediaButtonPreferences(buildNotificationLayout(exo)) }
940
941 setMediaNotificationProvider(
942 // Subclass that overrides the button selection so the
943 // notification action row surfaces our custom layout (one
944 // button: Stop) instead of Pixel's auto-prepended
945 // prev/play-pause/next.
946 //
947 // Pixel's MediaStyle template renders a separate
948 // primary-action button (the orange play/pause in the
949 // upper-right of the notification card) sourced from the
950 // media session's PlaybackState — that path is unaffected
951 // by getMediaButtons. So returning only customLayout from
952 // here drops the redundant prev/play-pause/next from the
953 // bottom action row and leaves the central play/pause +
954 // skip controls intact via the system path.
955 object : DefaultMediaNotificationProvider(
956 this,
957 NOTIFICATION_ID_PROVIDER,
958 "earhorn_playback",
959 R.string.notification_channel_name,
960 ) {
961 @OptIn(UnstableApi::class)
962 override fun getMediaButtons(
963 session: MediaSession,
964 playerCommands: Player.Commands,
965 customLayout: ImmutableList<CommandButton>,
966 showPauseButton: Boolean,
967 ): ImmutableList<CommandButton> {
968 // If the custom layout hasn't been built yet
969 // (rare, but possible during very-early init),
970 // fall through to the default which gives the
971 // user *something* (prev/play-pause/next) rather
972 // than an empty action row.
973 if (customLayout.isEmpty()) {
974 return super.getMediaButtons(
975 session, playerCommands, customLayout, showPauseButton,
976 )
977 }
978 return customLayout
979 }
980 }.apply {
981 setSmallIcon(R.drawable.ic_notification)
982 }
983 )
984 }
985
986 /** The notification's custom layout: one button, Stop. Standard
987 * prev/play-pause/next come from the media template's built-in
988 * slots. */
989 private fun buildNotificationLayout(p: Player): List<CommandButton> {
990 return listOf(
991 CommandButton.Builder()
992 .setDisplayName("Stop")
993 .setIconResId(R.drawable.ic_transport_stop)
994 .setSessionCommand(SessionCommand(CMD_STOP_ALL, Bundle.EMPTY))
995 .build(),
996 )
997 }
998
999 override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
1000
1001 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
1002 // Let MediaSessionService process media-button intents first.
1003 val result = super.onStartCommand(intent, flags, startId)
1004 if (intent?.action == ACTION_OBSERVE_KEEPALIVE) {
1005 // Started via startForegroundService — we MUST promote to
1006 // foreground promptly or the system kills us with a
1007 // ForegroundServiceDidNotStartInTimeException. The observer
1008 // sends this only after its connect succeeded, so entering
1009 // keep-alive is correct; if observing has already flipped off
1010 // in a race, the observing collector's next emission calls
1011 // exitObservingKeepAlive and cleans up.
1012 enterObservingKeepAlive()
1013 }
1014 return result
1015 }
1016
1017 override fun onTaskRemoved(rootIntent: Intent?) {
1018 // Swipe-away must NOT kill the service while it has remote duties:
1019 // observing (the TLS follow socket lives in this process — stopSelf
1020 // here was the "observer mode drops every time I exit the app" bug)
1021 // or casting (the stream proxy serves the receiver's bytes). The
1022 // user's chosen model: the connection lives with the service, and
1023 // relaunching the app reattaches to whatever is still playing.
1024 val observing = runCatching { rawCast.observing.value }.getOrDefault(false)
1025 val casting = runCatching { cast.isCasting.value }.getOrDefault(false)
1026 if (player?.playWhenReady == false && !observing && !casting) stopSelf()
1027 }
1028
1029 // ── Wake-on-screen notification refresh ─────────────────────────
1030 // When the phone goes into deep doze with a cast session active, the
1031 // local exo is paused (volume=0, playWhenReady=false) so it produces
1032 // no listener events. The notification template renders from the last
1033 // event, which can be hours old by the time the user wakes the phone
1034 // — they see whichever track was playing when they fell asleep, not
1035 // what's currently on the cast speaker.
1036 //
1037 // Listening for ACTION_SCREEN_ON (and USER_PRESENT for keyguard
1038 // dismissal) lets us poke the session's listeners so the notification
1039 // re-reads our cast-aware getters and shows the current track.
1040 private val wakeReceiver = object : android.content.BroadcastReceiver() {
1041 override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
1042 val action = intent?.action ?: return
1043 if (action != android.content.Intent.ACTION_SCREEN_ON &&
1044 action != android.content.Intent.ACTION_USER_PRESENT
1045 ) return
1046 android.util.Log.d("EarhornWake", "wakeReceiver: $action")
1047 // Force a Cast state refresh first — in doze the sender's
1048 // RemoteMediaClient callbacks may have stopped firing, leaving
1049 // castCurrentItemIndex / castIsPlaying stale. requestStatus()
1050 // round-trips the receiver and triggers onStatusUpdated, which
1051 // updates the StateFlows; our existing collectors in onCreate
1052 // then call notify* and the session re-reads metadata.
1053 runCatching {
1054 val deps = EntryPointAccessors.fromApplication(
1055 applicationContext, ServiceDeps::class.java
1056 )
1057 deps.castIntegration().requestStatusRefresh()
1058 }
1059 // Belt-and-suspenders: also poke the session listeners directly,
1060 // in case the user wasn't casting at all (refresh is a no-op
1061 // there) but the notification still went stale for some other
1062 // reason (e.g. doze-suspended bitmap loader).
1063 val p = sessionPlayerRef ?: return
1064 runCatching {
1065 // sessionPlayer is an anonymous-object subclass of ForwardingPlayer
1066 // defined inline in onCreate(). Its notify* methods can't be
1067 // typed-named from out here, so dispatch reflectively. Reflection
1068 // cost is irrelevant on a once-per-wake call.
1069 val cls = p.javaClass
1070 listOf("notifyMediaItemChanged", "notifyPlayStateChanged").forEach { name ->
1071 runCatching {
1072 cls.getDeclaredMethod(name).apply { isAccessible = true }.invoke(p)
1073 }
1074 }
1075 // (The layout is static — one Stop button — so there is
1076 // nothing to rebuild here; the seven-button rebuild was
1077 // removed 2026-09-02.)
1078 }
1079 }
1080 }
1081 private var wakeReceiverRegistered = false
1082
1083 override fun onDestroy() {
1084 exitCastingKeepAlive()
1085 runCatching { if (observingWifiLock?.isHeld == true) observingWifiLock?.release() }
1086 if (wakeReceiverRegistered) {
1087 runCatching { unregisterReceiver(wakeReceiver) }
1088 wakeReceiverRegistered = false
1089 }
1090 val s = session
1091 val p = player
1092 session = null
1093 player = null
1094 sessionPlayerRef = null
1095 s?.release()
1096 p?.release()
1097 if (upnpBound) {
1098 runCatching { unbindService(upnpConnection) }
1099 upnpBound = false
1100 }
1101 super.onDestroy()
1102 }
1103
1104 // ── Browse callback — bridges UPnP to Android Auto media tree ────
1105
1106 /** Synthetic ID for the server-list node. When Auto arrives with no
1107 * preferred server, the root's children are server entries instead of
1108 * music containers. Selecting one sets the preference and refreshes. */
1109 /** Real version for the HTTP user agent (the string was "Earhorn/0.0.1"
1110 * since the first release; review N.30). */
1111 private fun earhornUserAgent(): String = runCatching {
1112 "Earhorn/" + packageManager.getPackageInfo(packageName, 0).versionName
1113 }.getOrDefault("Earhorn")
1114
1115 companion object {
1116 @OptIn(UnstableApi::class)
1117 private fun getCompatToken(
1118 session: MediaLibraryService.MediaLibrarySession
1119 ): android.support.v4.media.session.MediaSessionCompat.Token {
1120 return session.sessionCompatToken
1121 }
1122
1123 // Internal Auto/library identifiers
1124 const val NOTIFICATION_ID = 2002
1125 /** Explicit-start action sent by RawCastObserver right after an
1126 * observe connect succeeds. Purely observing involves no local
1127 * playback, so nothing else guarantees this service even EXISTS —
1128 * a MediaController bind only happens around local play. Without
1129 * this start, the keep-alive collector in onCreate never runs, the
1130 * process is freezer-fair-game the moment the user switches apps,
1131 * and observer mode silently dies in the background (field-
1132 * reported). startForegroundService also obliges us to call
1133 * startForeground promptly — onStartCommand handles that. */
1134 const val ACTION_OBSERVE_KEEPALIVE = "earhorn.action.observe_keepalive"
1135 const val BROWSE_ROOT = "earhorn_root"
1136 const val SERVER_PREFIX = "earhorn_server:"
1137
1138 /** MediaSessionCompat.Token exposed for the Car App Library's
1139 * MediaPlaybackManager.registerMediaPlaybackToken(). Set after
1140 * the session is built in onCreate(); null before that. */
1141 @Volatile
1142 var sessionCompatToken: android.support.v4.media.session.MediaSessionCompat.Token? = null
1143 private set
1144
1145 // NotificationIdProvider for the protected DefaultMediaNotificationProvider
1146 // constructor (required for subclassing). Returns the same constant
1147 // ID for every session — Earhorn only ever has one active session.
1148 val NOTIFICATION_ID_PROVIDER =
1149 androidx.media3.session.DefaultMediaNotificationProvider.NotificationIdProvider {
1150 NOTIFICATION_ID
1151 }
1152
1153 // PendingIntent request codes — must be distinct so
1154 // FLAG_UPDATE_CURRENT updates the right one when extras change.
1155 const val REQUEST_CODE_QUEUE = 1001
1156
1157 // Custom session commands. The notification is one button (Stop);
1158 // the app's in-process controller uses the two setters below. A
1159 // seven-button layout and six more commands lived here without a
1160 // sender until 2026-09-02 (review N.25; removed on the
1161 // design decision).
1162 // Explicit-value setters for shuffle / repeat. Used by the app's
1163 // UI (NowPlaying toggle, ShuffleAction) which knows the target
1164 // value rather than just wanting to flip from the current one.
1165 // These exist because ForwardingPlayer.getAvailableCommands()
1166 // hides COMMAND_SET_SHUFFLE_MODE / COMMAND_SET_REPEAT_MODE to
1167 // keep the system notification template from auto-adding shuffle
1168 // and repeat icons — and MediaController.setShuffleModeEnabled
1169 // silently drops the call when the command isn't advertised, so
1170 // the in-app shuffle button never reached the player. Routing
1171 // via a SessionCommand bypasses that gate.
1172 const val CMD_SET_SHUFFLE = "earhorn.cmd.set_shuffle"
1173 const val CMD_SET_REPEAT_MODE = "earhorn.cmd.set_repeat_mode"
1174 const val ARG_SHUFFLE_ENABLED = "enabled"
1175 const val ARG_REPEAT_MODE = "mode" // Player.REPEAT_MODE_* int
1176 // Stop is a custom command (not Player.COMMAND_STOP) so we can
1177 // fork to cast.stop() when a Cast session is active. Player.COMMAND_STOP
1178 // would only stop the local controller, leaving the Cast device
1179 // playing — confusing UX since the user just told the system
1180 // "stop everything".
1181 const val CMD_STOP_ALL = "earhorn.cmd.stop_all"
1182
1183 // Deep-link extras MainActivity reads on receiving an intent
1184 // with the session activity or a notification command launch.
1185 // Single string-valued route plus an optional argument (used
1186 // by more-by-artist / more-from-album to carry the artist or
1187 // album name). Kept simple so the intent payload survives
1188 // Android's extras limitations.
1189 const val EXTRA_NAV_ROUTE = "earhorn.extra.nav_route"
1190 const val EXTRA_NAV_ARG = "earhorn.extra.nav_arg"
1191 const val NAV_QUEUE = "queue"
1192 const val NAV_LYRICS = "lyrics"
1193 const val NAV_NOW_PLAYING = "now_playing"
1194 const val NAV_MORE_ARTIST = "more_by_artist"
1195 const val NAV_MORE_ALBUM = "more_from_album"
1196 /** Land on Browse (root of the preferred server, or Servers when
1197 * none is set). Used by the hand-back path when kiosk won't open:
1198 * never a blank screen (design decision 2026-09-02). */
1199 const val NAV_BROWSE = "browse"
1200 }
1201
1202 private inner class LibraryCallback : MediaLibrarySession.Callback {
1203
1204 /** Cache of mediaId → playable URI, populated as we browse the library
1205 * on Auto's behalf. Auto's IPC strips MediaItem.RequestMetadata when
1206 * the controller round-trips items back to the session for
1207 * setMediaItem / addMediaItem — so by the time onAddMediaItems sees
1208 * the items the user tapped, the only stable identifier is the
1209 * mediaId; the URI we put in requestMetadata.mediaUri at toMediaItem
1210 * time is gone. Without this cache, taps on UPnP tracks resolve to
1211 * itemless playback (silent no-op, controller stays on the prior
1212 * track), which is the "tap a song, nothing happens, old track keeps
1213 * playing" report. ConcurrentHashMap is safe for the many-readers /
1214 * occasional-writer pattern between scope.launch coroutines and the
1215 * synchronous onAddMediaItems callback. */
1216 private val uriCache = java.util.concurrent.ConcurrentHashMap<String, android.net.Uri>()
1217
1218 /** Advertise our custom session commands to every connecting
1219 * controller. Without this, the system notification's
1220 * CommandButtons that use SessionCommand can't dispatch —
1221 * the session rejects unknown command keys at the
1222 * authorization layer before they reach onCustomCommand. */
1223 override fun onConnect(
1224 session: MediaSession,
1225 controller: MediaSession.ControllerInfo,
1226 ): MediaSession.ConnectionResult {
1227 val available = MediaSession.ConnectionResult
1228 .DEFAULT_SESSION_AND_LIBRARY_COMMANDS
1229 .buildUpon()
1230 .add(SessionCommand(CMD_SET_SHUFFLE, Bundle.EMPTY))
1231 .add(SessionCommand(CMD_SET_REPEAT_MODE, Bundle.EMPTY))
1232 .add(SessionCommand(CMD_STOP_ALL, Bundle.EMPTY))
1233 .build()
1234 return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
1235 .setAvailableSessionCommands(available)
1236 .build()
1237 }
1238
1239 /** Route custom commands. Toggle commands modify the
1240 * ExoPlayer directly — the Player.Listener registered in
1241 * onCreate picks up the change and rebuilds the layout, so
1242 * the icon updates without an explicit setCustomLayout call
1243 * here. Nav commands launch MainActivity with deep-link
1244 * extras the activity reads in onNewIntent. */
1245 override fun onCustomCommand(
1246 session: MediaSession,
1247 controller: MediaSession.ControllerInfo,
1248 customCommand: SessionCommand,
1249 args: Bundle,
1250 ): ListenableFuture<SessionResult> {
1251 val p = player
1252 when (customCommand.customAction) {
1253 CMD_SET_SHUFFLE -> {
1254 // Args carry the explicit desired value. See the comment on
1255 // CMD_SET_SHUFFLE for why the in-app shuffle button can't
1256 // use MediaController.setShuffleModeEnabled directly.
1257 val enabled = args.getBoolean(ARG_SHUFFLE_ENABLED)
1258 if (p != null) p.shuffleModeEnabled = enabled
1259 }
1260 CMD_SET_REPEAT_MODE -> {
1261 val mode = args.getInt(ARG_REPEAT_MODE, Player.REPEAT_MODE_OFF)
1262 if (p != null) p.repeatMode = mode
1263 }
1264 CMD_STOP_ALL -> {
1265 // Fork to cast first so the remote audio actually
1266 // stops; cast.stop() is a no-op in libre flavor
1267 // and harmless when no session is active.
1268 if (cast.isCasting.value) {
1269 runCatching { cast.stop() }
1270 runCatching { cast.seekTo(0) }
1271 }
1272 // The observed house session too — the notification's
1273 // Stop used to leave it running (I.19, design decision
1274 // 2026-09-02): same routing as the on-screen Stop.
1275 if (rawCast.observing.value) {
1276 scope.launch { runCatching { rawCast.stop() } }
1277 }
1278 // Always pause+rewind local so the MediaSession
1279 // transitions to STATE_READY (paused at 0). Uses
1280 // pause+seek instead of player.stop() to preserve
1281 // queue/track state — pressing Play afterward
1282 // restarts the current track from the beginning,
1283 // matching the "old-school stop button" semantics
1284 // used elsewhere in LocalRenderer.stop().
1285 if (p != null) {
1286 p.pause()
1287 p.seekTo(0)
1288 }
1289 }
1290 }
1291 return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
1292 }
1293
1294 override fun onGetLibraryRoot(
1295 session: MediaLibrarySession,
1296 browser: MediaSession.ControllerInfo,
1297 params: LibraryParams?,
1298 ): ListenableFuture<LibraryResult<MediaItem>> {
1299 // params may carry hints — `params.isRecent`, `params.isSuggested`,
1300 // `params.isOffline` — that Auto uses to populate "For You" /
1301 // "Recommendations" / "Recents" tabs. Auto's "For You" panel that
1302 // shows up in the launcher is populated from a SUGGESTED-rooted
1303 // sub-tree if the app provides one. Earhorn doesn't currently
1304 // build a suggestion graph (no listening-history derived
1305 // recommendations), so we return the same generic root for all
1306 // hint flavors. Auto falls back to the user's recently-played
1307 // session items, which is why "For You" shows whatever was last
1308 // playing rather than curated picks.
1309 //
1310 // To populate For You meaningfully we'd need to:
1311 // 1. Track playback history (recently-played albums/tracks).
1312 // 2. Return a hierarchy when params.isSuggested with mediaIds
1313 // like "earhorn_recent:album:N" / "earhorn_recent:track:N".
1314 // 3. Implement onGetChildren for those IDs.
1315 // Punted as future scope.
1316 val root = MediaItem.Builder()
1317 .setMediaId(BROWSE_ROOT)
1318 .setMediaMetadata(
1319 MediaMetadata.Builder()
1320 .setTitle("Earhorn Music")
1321 .setIsBrowsable(true)
1322 .setIsPlayable(false)
1323 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
1324 .build()
1325 )
1326 .build()
1327 return Futures.immediateFuture(LibraryResult.ofItem(root, params))
1328 }
1329
1330 override fun onGetChildren(
1331 session: MediaLibrarySession,
1332 browser: MediaSession.ControllerInfo,
1333 parentId: String,
1334 page: Int,
1335 pageSize: Int,
1336 params: LibraryParams?,
1337 ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
1338 val t0 = System.currentTimeMillis()
1339 android.util.Log.i("EarhornAuto", "onGetChildren parentId=$parentId page=$page pageSize=$pageSize")
1340 val future = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
1341 scope.launch(Dispatchers.IO) {
1342 try {
1343 // ── Local media branches ────────────────────────────
1344 if (parentId.startsWith(dev.stave.local.LocalMediaScanner.LOCAL_PREFIX)) {
1345 handleLocalBrowse(parentId, params, future)
1346 return@launch
1347 }
1348
1349 // ── Server-selection from the root list ─────────────
1350 if (parentId.startsWith(SERVER_PREFIX)) {
1351 val udn = parentId.removePrefix(SERVER_PREFIX)
1352 // Maintainer, 2026-10-29: remember the car's choice in
1353 // memory instead of overwriting the app's saved server (D7).
1354 carBrowseUdn = udn
1355 browseContainer(udn, "0", params, future)
1356 return@launch
1357 }
1358
1359 // ── Root-level children ─────────────────────────────
1360 if (parentId == BROWSE_ROOT) {
1361 // Source-level layout: Local Music sits next to the
1362 // configured (or discovered) UPnP server(s) as
1363 // siblings, NOT mixed with each server's root
1364 // containers. Prior layout inlined the configured
1365 // server's container-0 children at the top level
1366 // alongside Local Music — when the server's root has
1367 // ten entries (folder view, tag view, albums,
1368 // artists, genres, …) this buried Local Music below
1369 // a wall of server entries that looked like the only
1370 // thing available, and tapping any of them entered
1371 // server browse rather than a clear "I want my phone
1372 // music" / "I want my home server" choice.
1373 //
1374 // New: top level always shows source picks. Tap Local
1375 // Music → local hierarchy. Tap a server → that
1376 // server's root container.
1377 val items = mutableListOf<MediaItem>()
1378
1379 items.add(
1380 MediaItem.Builder()
1381 .setMediaId(dev.stave.local.LocalMediaScanner.LOCAL_ROOT)
1382 .setMediaMetadata(
1383 MediaMetadata.Builder()
1384 .setTitle("Local Music")
1385 .setSubtitle("Music stored on this device")
1386 .setIsBrowsable(true)
1387 .setIsPlayable(false)
1388 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS)
1389 .build()
1390 )
1391 .build()
1392 )
1393
1394 val configuredUdn = settings.preferredServerUdn.first()
1395 val knownServers = upnpHolder.servers.value
1396 // Don't block the browse tree waiting for SSDP discovery.
1397 // Use whatever servers are already known. If empty, the
1398 // user sees only "Local Music" — they can retry after
1399 // discovery completes.
1400 val servers = knownServers
1401 if (knownServers.isEmpty()) {
1402 // Kick a rescan in the background for next time
1403 upnpHolder.rescan()
1404 }
1405
1406 // Always include the configured server (even if not
1407 // currently in the live discovery list — it might
1408 // come back). Then append any other discovered
1409 // servers. Dedup by UDN.
1410 val seen = mutableSetOf<String>()
1411 configuredUdn?.let { udn ->
1412 val match = servers.firstOrNull { it.udn == udn }
1413 // Maintainer, 2026-10-29: say plainly when the saved
1414 // server isn't on this network (D7). No name is
1415 // stored for it, so the title falls back to "Saved server".
1416 val title = match?.friendlyName ?: "Saved server"
1417 val subtitle = if (match == null) "Not found on this network"
1418 else match.modelName ?: "Configured server"
1419 items.add(
1420 MediaItem.Builder()
1421 .setMediaId("$SERVER_PREFIX$udn")
1422 .setMediaMetadata(
1423 MediaMetadata.Builder()
1424 .setTitle(title)
1425 .setSubtitle(subtitle)
1426 .setIsBrowsable(true)
1427 .setIsPlayable(false)
1428 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
1429 .build()
1430 )
1431 .build()
1432 )
1433 seen.add(udn)
1434 }
1435 servers.forEach { server ->
1436 if (server.udn in seen) return@forEach
1437 items.add(
1438 MediaItem.Builder()
1439 .setMediaId("$SERVER_PREFIX${server.udn}")
1440 .setMediaMetadata(
1441 MediaMetadata.Builder()
1442 .setTitle(server.friendlyName)
1443 .setSubtitle(server.modelName ?: "UPnP Server")
1444 .setIsBrowsable(true)
1445 .setIsPlayable(false)
1446 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
1447 .build()
1448 )
1449 .build()
1450 )
1451 seen.add(server.udn)
1452 }
1453
1454 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1455 return@launch
1456 }
1457
1458 // ── UPnP container browse ───────────────────────────
1459 // Maintainer, 2026-10-29: browse the server the car opened,
1460 // falling back to the app's saved server (D7).
1461 val udn = carBrowseUdn ?: settings.preferredServerUdn.first()
1462 if (udn != null) {
1463 browseContainer(udn, parentId, params, future)
1464 } else {
1465 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_NOT_SUPPORTED))
1466 }
1467 } catch (e: Exception) {
1468 android.util.Log.e("EarhornAuto", "onGetChildren failed in ${System.currentTimeMillis() - t0}ms", e)
1469 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
1470 }
1471 }
1472 future.addListener({
1473 android.util.Log.i("EarhornAuto", "onGetChildren $parentId completed in ${System.currentTimeMillis() - t0}ms")
1474 }, MoreExecutors.directExecutor())
1475 return paged(future, page, pageSize) // Maintainer, 2026-10-29: see paged()
1476 }
1477
1478 /** Maintainer, 2026-10-29: every browse/search path above sets the FULL
1479 * list, but Media3 throws IllegalStateException on its main handler
1480 * (process death) when a result holds more than pageSize items — the
1481 * car asks in pages. Return a future carrying only the requested page
1482 * of whatever [source] resolves to. Only [source] is ever completed
1483 * (set(), never failed or cancelled), so get() here doesn't throw. */
1484 private fun paged(
1485 source: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
1486 page: Int,
1487 pageSize: Int,
1488 ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
1489 val out = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
1490 source.addListener({ out.set(pageOf(source.get(), page, pageSize)) }, MoreExecutors.directExecutor())
1491 return out
1492 }
1493
1494 /** Maintainer, 2026-10-29: items.drop(page * pageSize).take(pageSize) of a
1495 * successful item list; anything else (errors, pageSize Int.MAX_VALUE =
1496 * "all", invalid paging) passes through untouched. */
1497 private fun pageOf(
1498 result: LibraryResult<ImmutableList<MediaItem>>,
1499 page: Int,
1500 pageSize: Int,
1501 ): LibraryResult<ImmutableList<MediaItem>> {
1502 val items = result.value
1503 if (result.resultCode != LibraryResult.RESULT_SUCCESS || items == null) return result
1504 if (pageSize <= 0 || page < 0 || pageSize == Int.MAX_VALUE) return result
1505 val from = page.toLong() * pageSize
1506 if (from == 0L && items.size <= pageSize) return result
1507 val slice = if (from >= items.size) emptyList() else items.drop(from.toInt()).take(pageSize)
1508 return LibraryResult.ofItemList(slice, result.params)
1509 }
1510
1511 /** Handle all local media browse paths. */
1512 private suspend fun handleLocalBrowse(
1513 parentId: String,
1514 params: LibraryParams?,
1515 future: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
1516 ) {
1517 val lms = dev.stave.local.LocalMediaScanner
1518 // Permission gate. MediaStore.Audio queries return zero rows
1519 // (cleanly, no exception) when the app has not been granted
1520 // READ_MEDIA_AUDIO (API 33+) / READ_EXTERNAL_STORAGE (API <33).
1521 // In Earhorn, the runtime grant flow lives in the phone-side
1522 // LocalHubScreen — if the user has never visited that screen,
1523 // permission has never been requested and Auto's local browse
1524 // returns silent empty lists at every level (Albums → "No
1525 // items", Artists → "No items"). User reads this as "the app
1526 // doesn't have my files." Surface the actual cause: a
1527 // single browsable-but-not-actionable hint item at the root
1528 // that tells them where to go.
1529 val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
1530 android.Manifest.permission.READ_MEDIA_AUDIO
1531 else
1532 android.Manifest.permission.READ_EXTERNAL_STORAGE
1533 val hasPerm = androidx.core.content.ContextCompat.checkSelfPermission(
1534 applicationContext, perm,
1535 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
1536 if (!hasPerm) {
1537 future.set(LibraryResult.ofItemList(
1538 ImmutableList.of(
1539 MediaItem.Builder()
1540 .setMediaId("earhorn_local_no_permission")
1541 .setMediaMetadata(
1542 MediaMetadata.Builder()
1543 // Maintainer, 2026-10-29: clearer hint wording (D7).
1544 .setTitle("Allow Music access on your phone")
1545 .setSubtitle("Open Earhorn → Choose server → On this device")
1546 .setIsBrowsable(false)
1547 .setIsPlayable(false)
1548 .build()
1549 )
1550 .build()
1551 ),
1552 params,
1553 ))
1554 return
1555 }
1556 when {
1557 parentId == lms.LOCAL_ROOT -> {
1558 val items = listOf(
1559 MediaItem.Builder()
1560 .setMediaId(lms.LOCAL_ALBUMS)
1561 .setMediaMetadata(
1562 MediaMetadata.Builder()
1563 .setTitle("Albums")
1564 .setIsBrowsable(true)
1565 .setIsPlayable(false)
1566 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS)
1567 .build()
1568 )
1569 .build(),
1570 MediaItem.Builder()
1571 .setMediaId(lms.LOCAL_ARTISTS)
1572 .setMediaMetadata(
1573 MediaMetadata.Builder()
1574 .setTitle("Artists")
1575 .setIsBrowsable(true)
1576 .setIsPlayable(false)
1577 .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS)
1578 .build()
1579 )
1580 .build(),
1581 )
1582 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1583 }
1584
1585 parentId == lms.LOCAL_ALBUMS -> {
1586 val albums = localScanner.scanAlbums()
1587 val items = albums.map { album ->
1588 MediaItem.Builder()
1589 .setMediaId("${lms.LOCAL_ALBUM}${album.id}")
1590 .setMediaMetadata(
1591 MediaMetadata.Builder()
1592 .setTitle(album.title)
1593 .setArtist(album.artist)
1594 .setIsBrowsable(true)
1595 .setIsPlayable(false)
1596 .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
1597 .apply {
1598 album.artUri?.let { setArtworkUri(it) }
1599 }
1600 .build()
1601 )
1602 .build()
1603 }
1604 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1605 }
1606
1607 parentId == lms.LOCAL_ARTISTS -> {
1608 val artists = localScanner.scanArtists()
1609 val items = artists.map { (id, name) ->
1610 MediaItem.Builder()
1611 .setMediaId("${lms.LOCAL_ARTIST}$id")
1612 .setMediaMetadata(
1613 MediaMetadata.Builder()
1614 .setTitle(name)
1615 .setIsBrowsable(true)
1616 .setIsPlayable(false)
1617 .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
1618 .build()
1619 )
1620 .build()
1621 }
1622 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1623 }
1624
1625 parentId.startsWith(lms.LOCAL_ALBUM) -> {
1626 val albumId = parentId.removePrefix(lms.LOCAL_ALBUM).toLongOrNull()
1627 if (albumId == null) {
1628 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
1629 return
1630 }
1631 val tracks = localScanner.tracksForAlbum(albumId)
1632 val items = tracks.map { track ->
1633 val mediaId = "${lms.LOCAL_PREFIX}track:${track.id}"
1634 // Populate URI cache — Auto's IPC strips the
1635 // RequestMetadata between onGetChildren and the
1636 // tap-to-play setMediaItem callback. Cache by
1637 // mediaId so onAddMediaItems can resolve.
1638 uriCache[mediaId] = track.contentUri
1639 MediaItem.Builder()
1640 .setMediaId(mediaId)
1641 .setRequestMetadata(
1642 MediaItem.RequestMetadata.Builder()
1643 .setMediaUri(track.contentUri)
1644 .build()
1645 )
1646 .setMediaMetadata(
1647 MediaMetadata.Builder()
1648 .setTitle(track.title)
1649 .setArtist(track.artist ?: track.albumArtist)
1650 .setAlbumTitle(track.album)
1651 .setTrackNumber(track.trackNumber)
1652 .setDiscNumber(track.discNumber)
1653 .setIsBrowsable(false)
1654 .setIsPlayable(true)
1655 .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
1656 .apply {
1657 track.artUri?.let { setArtworkUri(it) }
1658 setExtras(Bundle().apply {
1659 putLong("duration_ms", track.duration)
1660 })
1661 }
1662 .build()
1663 )
1664 .build()
1665 }
1666 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1667 }
1668
1669 parentId.startsWith(lms.LOCAL_ARTIST) -> {
1670 // Show albums for this artist. We query all albums and filter
1671 // by artist name match since MediaStore doesn't have a clean
1672 // artist→album join.
1673 val artistId = parentId.removePrefix(lms.LOCAL_ARTIST).toLongOrNull()
1674 if (artistId == null) {
1675 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
1676 return
1677 }
1678 val artists = localScanner.scanArtists()
1679 val artistName = artists.firstOrNull { it.first == artistId }?.second
1680 if (artistName == null) {
1681 future.set(LibraryResult.ofItemList(ImmutableList.of(), params))
1682 return
1683 }
1684 val albums = localScanner.scanAlbums()
1685 .filter { it.artist.equals(artistName, ignoreCase = true) }
1686 val items = albums.map { album ->
1687 MediaItem.Builder()
1688 .setMediaId("${lms.LOCAL_ALBUM}${album.id}")
1689 .setMediaMetadata(
1690 MediaMetadata.Builder()
1691 .setTitle(album.title)
1692 .setArtist(album.artist)
1693 .setIsBrowsable(true)
1694 .setIsPlayable(false)
1695 .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
1696 .apply {
1697 album.artUri?.let { setArtworkUri(it) }
1698 }
1699 .build()
1700 )
1701 .build()
1702 }
1703 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1704 }
1705
1706 else -> {
1707 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
1708 }
1709 }
1710 }
1711
1712 /** Shared helper — browses a UPnP container and resolves the future.
1713 * Caches each playable result's URI by mediaId so onAddMediaItems
1714 * can resolve when Auto strips RequestMetadata over IPC. */
1715 private suspend fun browseContainer(
1716 udn: String,
1717 containerId: String,
1718 params: LibraryParams?,
1719 future: SettableFuture<LibraryResult<ImmutableList<MediaItem>>>,
1720 ) {
1721 when (val result = cdClient.browse(udn, containerId)) {
1722 is BrowseResult.Success -> {
1723 result.items.forEach { didl ->
1724 if (didl is DidlItem.Track) {
1725 runCatching {
1726 uriCache[didl.id] = android.net.Uri.parse(didl.resourceUrl)
1727 }
1728 }
1729 }
1730 val items = result.items.map { it.toMediaItem() }
1731 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1732 }
1733 is BrowseResult.Failure -> {
1734 future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_IO))
1735 }
1736 }
1737 }
1738
1739 override fun onGetItem(
1740 session: MediaLibrarySession,
1741 browser: MediaSession.ControllerInfo,
1742 mediaId: String,
1743 ): ListenableFuture<LibraryResult<MediaItem>> {
1744 return Futures.immediateFuture(
1745 LibraryResult.ofError(LibraryResult.RESULT_ERROR_NOT_SUPPORTED)
1746 )
1747 }
1748
1749 override fun onSearch(
1750 session: MediaLibrarySession,
1751 browser: MediaSession.ControllerInfo,
1752 query: String,
1753 params: LibraryParams?,
1754 ): ListenableFuture<LibraryResult<Void>> {
1755 // Acknowledge the search — Auto calls onGetSearchResult for results.
1756 return Futures.immediateFuture(LibraryResult.ofVoid())
1757 }
1758
1759 override fun onGetSearchResult(
1760 session: MediaLibrarySession,
1761 browser: MediaSession.ControllerInfo,
1762 query: String,
1763 page: Int,
1764 pageSize: Int,
1765 params: LibraryParams?,
1766 ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
1767 val future = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
1768 scope.launch(Dispatchers.IO) {
1769 try {
1770 val items = mutableListOf<MediaItem>()
1771 val lms = dev.stave.local.LocalMediaScanner
1772
1773 // Local albums — browsable results so the user can tap
1774 // to see the album's track list.
1775 val localAlbums = localScanner.searchAlbums(query)
1776 localAlbums.take(10).forEach { album ->
1777 items.add(
1778 MediaItem.Builder()
1779 .setMediaId("${lms.LOCAL_ALBUM}${album.id}")
1780 .setMediaMetadata(
1781 MediaMetadata.Builder()
1782 .setTitle(album.title)
1783 .setArtist(album.artist)
1784 .setIsBrowsable(true)
1785 .setIsPlayable(false)
1786 .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
1787 .apply { album.artUri?.let { setArtworkUri(it) } }
1788 .build()
1789 )
1790 .build()
1791 )
1792 }
1793
1794 // Local files first — always available, no network round-trip.
1795 val localTracks = localScanner.searchTracks(query)
1796 localTracks.take(20).forEach { track ->
1797 val mediaId = "${dev.stave.local.LocalMediaScanner.LOCAL_PREFIX}track:${track.id}"
1798 uriCache[mediaId] = track.contentUri
1799 items.add(
1800 MediaItem.Builder()
1801 .setMediaId(mediaId)
1802 .setRequestMetadata(
1803 MediaItem.RequestMetadata.Builder()
1804 .setMediaUri(track.contentUri)
1805 .build()
1806 )
1807 .setMediaMetadata(
1808 MediaMetadata.Builder()
1809 .setTitle(track.title)
1810 .setArtist(track.artist ?: track.albumArtist)
1811 .setAlbumTitle(track.album)
1812 .setIsBrowsable(false)
1813 .setIsPlayable(true)
1814 .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
1815 .apply { track.artUri?.let { setArtworkUri(it) } }
1816 .build()
1817 )
1818 .build()
1819 )
1820 }
1821
1822 // UPnP server search if connected. Prior implementation
1823 // used `searchBucketAlbumsByTitle` (album titles
1824 // containing the query — rarely matches a person's name)
1825 // + `searchTracksByArtist` (artist EXACTLY equal to the
1826 // query — never matches partial names like "dave").
1827 // Both returned 0 results for typical user queries,
1828 // producing the "search never finds anything" behavior.
1829 // Replaced with parallel `contains`-based bucket searches
1830 // that mirror the phone UI's search semantics. Fired in
1831 // parallel so total latency is the slowest single leg,
1832 // not the sum.
1833 val udn = carBrowseUdn ?: settings.preferredServerUdn.first() // Maintainer, 2026-10-29: search the server the car is browsing
1834 if (udn != null) {
1835 val results = coroutineScope {
1836 val a = async { cdClient.searchBucketAlbumsByTitle(udn, query) }
1837 val b = async { cdClient.searchBucketAlbumsByArtist(udn, query) }
1838 val c = async { cdClient.searchBucketArtists(udn, query) }
1839 val d = async { cdClient.searchBucketTracksByTitle(udn, query) }
1840 val e = async { cdClient.searchBucketTracksByArtist(udn, query) }
1841 listOf(a.await(), b.await(), c.await(), d.await(), e.await())
1842 }
1843 // Merge with dedup by mediaId. Cap each leg at 20 and
1844 // total at 100 so a wide search doesn't ship megabytes
1845 // back to Auto.
1846 val seen = mutableSetOf<String>()
1847 results.forEach { result ->
1848 if (result is BrowseResult.Success) {
1849 result.items.take(20).forEach { didl ->
1850 if (didl is DidlItem.Track) {
1851 runCatching {
1852 uriCache[didl.id] = android.net.Uri.parse(didl.resourceUrl)
1853 }
1854 }
1855 if (didl.id !in seen && items.size < 100) {
1856 items.add(didl.toMediaItem())
1857 seen.add(didl.id)
1858 }
1859 }
1860 }
1861 }
1862 }
1863
1864 future.set(LibraryResult.ofItemList(ImmutableList.copyOf(items), params))
1865 } catch (e: Exception) {
1866 android.util.Log.e("EarhornAuto", "onGetSearchResult failed", e)
1867 // Return empty results so Auto stops spinning rather than
1868 // showing a permanent "searching" state.
1869 future.set(LibraryResult.ofItemList(ImmutableList.of(), params))
1870 }
1871 }
1872 // Maintainer, 2026-10-29: up to ~130 items came back regardless of
1873 // pageSize — same Media3 crash as onGetChildren; see paged().
1874 return paged(future, page, pageSize)
1875 }
1876
1877 /**
1878 * Playback resumption — called by Auto (or the system) when it wants
1879 * to restore the last session after the service was killed (e.g. car
1880 * turned off then back on). Loads the persisted queue snapshot so Auto
1881 * shows the Now Playing screen immediately instead of falling back to
1882 * raw Bluetooth audio.
1883 */
1884 override fun onPlaybackResumption(
1885 mediaSession: MediaSession,
1886 controller: MediaSession.ControllerInfo,
1887 ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
1888 val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
1889 scope.launch(Dispatchers.IO) {
1890 try {
1891 val saved = queueStore.load()
1892 // Maintainer, 2026-10-29: Media3 hands these items straight to
1893 // player.setMediaItems, so each one must carry a URI —
1894 // toMediaItem now sets it, and tracks with a blank
1895 // resourceUrl are skipped (they crashed ExoPlayer's
1896 // DefaultMediaSourceFactory on the main thread). The start
1897 // index moves to the first kept track at or after the saved
1898 // one; the saved position applies only if that track survived (D1).
1899 val tracks = saved?.tracks.orEmpty().map { it.toDomain() }
1900 val keptIndices = tracks.indices.filter { tracks[it].resourceUrl.isNotBlank() }
1901 if (saved != null && keptIndices.isNotEmpty()) {
1902 val mediaItems = keptIndices.map { tracks[it].toMediaItem() }
1903 val startIndex = keptIndices.indexOfFirst { it >= saved.currentIndex }
1904 .let { if (it < 0) mediaItems.lastIndex else it }
1905 .coerceIn(0, mediaItems.lastIndex)
1906 val startPositionMs =
1907 if (keptIndices[startIndex] == saved.currentIndex) saved.positionMs.coerceAtLeast(0L)
1908 else 0L
1909 future.set(
1910 MediaSession.MediaItemsWithStartPosition(
1911 mediaItems, startIndex, startPositionMs,
1912 )
1913 )
1914 } else {
1915 future.set(MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L))
1916 }
1917 } catch (e: Exception) {
1918 android.util.Log.e("EarhornAuto", "Playback resumption failed", e)
1919 future.set(MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L))
1920 }
1921 }
1922 return future
1923 }
1924
1925 override fun onAddMediaItems(
1926 mediaSession: MediaSession,
1927 controller: MediaSession.ControllerInfo,
1928 mediaItems: List<MediaItem>,
1929 ): ListenableFuture<List<MediaItem>> {
1930 // Resolve playback URIs. Decision tree per item:
1931 // 1. requestMetadata.mediaUri set (own session call)? Use it.
1932 // 2. uriCache contains mediaId (we browsed it earlier)? Use it.
1933 // 3. mediaId looks like local-track? Reconstruct content URI.
1934 // 4. Else: pass through unchanged (best effort).
1935 //
1936 // Step 2 is the critical one for Auto. When Auto's controller
1937 // round-trips items back to the session via setMediaItem /
1938 // addMediaItem, the IPC marshalling drops RequestMetadata. The
1939 // mediaId is the only field reliably preserved. Without the
1940 // cache, UPnP tracks from Auto's browse tree resolve to a null
1941 // URI and the player silently no-ops, leaving the previously
1942 // playing track in place — the "tap a song, nothing happens,
1943 // old track keeps playing" report.
1944 //
1945 // Maintainer, 2026-10-29 (D2): step 4 is no longer "pass through".
1946 // An item with no URI reached ExoPlayer and crashed it
1947 // (checkNotNull(localConfiguration) on the main thread), e.g.
1948 // "play X on Earhorn", which arrives as an empty mediaId plus
1949 // requestMetadata.searchQuery. Now:
1950 // 4. Item already carries a URI (in-process controller)? Keep it.
1951 // 5. searchQuery set? Search (local files first, then the
1952 // preferred UPnP server) — asynchronous, on IO.
1953 // 6. Else: DROPPED, never handed to the player.
1954 return Futures.transform<List<List<MediaItem>>, List<MediaItem>>(
1955 resolvePerItem(mediaItems),
1956 { perItem -> perItem.flatten() },
1957 MoreExecutors.directExecutor(),
1958 )
1959 }
1960
1961 /** Maintainer, 2026-10-29 (D2): same resolution as [onAddMediaItems]
1962 * (the default implementation delegates there too), plus the start
1963 * index is mapped onto the resolved list — a dropped item would
1964 * otherwise shift it past the end and ExoPlayer throws
1965 * IllegalSeekPositionException. When nothing at all is playable the
1966 * request fails (Media3 reports it to the controller) instead of
1967 * replacing the current queue with an empty one. */
1968 override fun onSetMediaItems(
1969 mediaSession: MediaSession,
1970 controller: MediaSession.ControllerInfo,
1971 mediaItems: List<MediaItem>,
1972 startIndex: Int,
1973 startPositionMs: Long,
1974 ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
1975 return Futures.transformAsync<List<List<MediaItem>>, MediaSession.MediaItemsWithStartPosition>(
1976 resolvePerItem(mediaItems),
1977 { perItem ->
1978 val flat = perItem.flatten()
1979 if (flat.isEmpty() && mediaItems.isNotEmpty()) {
1980 Futures.immediateFailedFuture<MediaSession.MediaItemsWithStartPosition>(
1981 UnsupportedOperationException("No playable item in the request")
1982 )
1983 } else {
1984 val startDropped = startIndex != C.INDEX_UNSET &&
1985 perItem.getOrNull(startIndex).isNullOrEmpty()
1986 val newStart = if (startIndex == C.INDEX_UNSET || flat.isEmpty()) startIndex
1987 else perItem.take(startIndex).sumOf { it.size }.coerceAtMost(flat.lastIndex)
1988 Futures.immediateFuture(
1989 MediaSession.MediaItemsWithStartPosition(
1990 flat, newStart,
1991 if (startDropped) C.TIME_UNSET else startPositionMs,
1992 )
1993 )
1994 }
1995 },
1996 MoreExecutors.directExecutor(),
1997 )
1998 }
1999
2000 /** Maintainer, 2026-10-29 (D2): resolve each requested item to the
2001 * playable items it stands for — one for a URI/cache/local-id hit,
2002 * several for a search query, none when unresolvable. Synchronous
2003 * (immediate future) unless a search is needed; searches run on IO
2004 * and complete the future from the service scope, like onGetChildren. */
2005 private fun resolvePerItem(mediaItems: List<MediaItem>): ListenableFuture<List<List<MediaItem>>> {
2006 val direct = mediaItems.map { resolveDirect(it) }
2007 val queries = mediaItems.map { it.requestMetadata.searchQuery?.trim().orEmpty() }
2008 val needsSearch = mediaItems.indices.any { direct[it] == null && queries[it].isNotEmpty() }
2009 if (!needsSearch) {
2010 direct.forEachIndexed { i, d ->
2011 if (d == null) android.util.Log.w("EarhornAuto", "onAddMediaItems: no URI for ${mediaItems[i].mediaId} — dropped")
2012 }
2013 return Futures.immediateFuture(direct.map { listOfNotNull(it) })
2014 }
2015 val future = SettableFuture.create<List<List<MediaItem>>>()
2016 scope.launch(Dispatchers.IO) {
2017 try {
2018 val perItem = mediaItems.indices.map { i ->
2019 val d = direct[i]
2020 when {
2021 d != null -> listOf(d)
2022 queries[i].isNotEmpty() -> searchPlayable(queries[i])
2023 else -> emptyList()
2024 }
2025 }
2026 future.set(perItem)
2027 } catch (e: Exception) {
2028 android.util.Log.e("EarhornAuto", "search-to-play failed", e)
2029 future.setException(e)
2030 }
2031 }
2032 return future
2033 }
2034
2035 /** Steps 1–4 of the [onAddMediaItems] decision tree; null = unresolved. */
2036 private fun resolveDirect(item: MediaItem): MediaItem? {
2037 val direct = item.requestMetadata.mediaUri
2038 if (direct != null) {
2039 return item.buildUpon().setUri(direct).build()
2040 }
2041 val cached = uriCache[item.mediaId]
2042 if (cached != null) {
2043 return item.buildUpon().setUri(cached).build()
2044 }
2045 val mid = item.mediaId
2046 val lms = dev.stave.local.LocalMediaScanner
2047 if (mid.startsWith("${lms.LOCAL_PREFIX}track:")) {
2048 val trackId = mid.removePrefix("${lms.LOCAL_PREFIX}track:").toLongOrNull()
2049 if (trackId != null) {
2050 val contentUri = android.content.ContentUris.withAppendedId(
2051 android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
2052 trackId,
2053 )
2054 return item.buildUpon().setUri(contentUri).build()
2055 }
2056 }
2057 if (item.localConfiguration != null) return item
2058 return null
2059 }
2060
2061 /** Tracks matching [query] as playable items (URI set): local files
2062 * first; if none, the preferred UPnP server's track search (title
2063 * and artist), bounded so a silent server can't stall the request. */
2064 private suspend fun searchPlayable(query: String): List<MediaItem> {
2065 val limit = 50
2066 val lms = dev.stave.local.LocalMediaScanner
2067 val local = localScanner.searchTracks(query).take(limit).map { track ->
2068 MediaItem.Builder()
2069 .setMediaId("${lms.LOCAL_PREFIX}track:${track.id}")
2070 .setUri(track.contentUri)
2071 .setMediaMetadata(
2072 MediaMetadata.Builder()
2073 .setTitle(track.title)
2074 .setArtist(track.artist ?: track.albumArtist)
2075 .setAlbumTitle(track.album)
2076 .setIsBrowsable(false)
2077 .setIsPlayable(true)
2078 .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
2079 .apply { track.artUri?.let { setArtworkUri(it) } }
2080 .build()
2081 )
2082 .build()
2083 }
2084 if (local.isNotEmpty()) return local
2085 val udn = carBrowseUdn ?: settings.preferredServerUdn.first() ?: return emptyList() // Maintainer, 2026-10-29: same server as car browsing
2086 val results = kotlinx.coroutines.withTimeoutOrNull(15_000L) {
2087 coroutineScope {
2088 val byTitle = async { cdClient.searchBucketTracksByTitle(udn, query) }
2089 val byArtist = async { cdClient.searchBucketTracksByArtist(udn, query) }
2090 listOf(byTitle.await(), byArtist.await())
2091 }
2092 }.orEmpty()
2093 return results
2094 .flatMap { (it as? BrowseResult.Success)?.items.orEmpty() }
2095 .filterIsInstance<DidlItem.Track>()
2096 .filter { it.resourceUrl.isNotBlank() }
2097 .distinctBy { it.id }
2098 .take(limit)
2099 .map { it.toMediaItem() }
2100 }
2101 }
2102}
2103
2104/** Convert a UPnP DidlItem to a Media3 MediaItem for the Auto browse tree. */
2105private fun DidlItem.toMediaItem(): MediaItem {
2106 return when (this) {
2107 is DidlItem.Container -> {
2108 MediaItem.Builder()
2109 .setMediaId(id)
2110 .setMediaMetadata(
2111 MediaMetadata.Builder()
2112 .setTitle(title)
2113 .setArtist(artist)
2114 .setIsBrowsable(true)
2115 .setIsPlayable(false)
2116 .setMediaType(
2117 if (isAlbum) MediaMetadata.MEDIA_TYPE_ALBUM
2118 else MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
2119 )
2120 .apply {
2121 albumArtUri?.let {
2122 setArtworkUri(android.net.Uri.parse(it))
2123 }
2124 }
2125 .build()
2126 )
2127 .build()
2128 }
2129 is DidlItem.Track -> {
2130 MediaItem.Builder()
2131 .setMediaId(id)
2132 // Maintainer, 2026-10-29: a real playback URI, not only
2133 // RequestMetadata.mediaUri — items that reach the player
2134 // directly (playback resumption, search-resolved plays) need
2135 // localConfiguration or ExoPlayer throws (D1).
2136 .apply { if (resourceUrl.isNotBlank()) setUri(resourceUrl) }
2137 .setRequestMetadata(
2138 MediaItem.RequestMetadata.Builder()
2139 .setMediaUri(android.net.Uri.parse(resourceUrl))
2140 .build()
2141 )
2142 .setMediaMetadata(
2143 MediaMetadata.Builder()
2144 .setTitle(title)
2145 .setArtist(artist ?: albumArtist)
2146 .setAlbumTitle(album)
2147 .setTrackNumber(trackNumber)
2148 .setDiscNumber(discNumber)
2149 .setGenre(genre)
2150 .setRecordingYear(
2151 date?.let { Regex("""\d{4}""").find(it)?.value?.toIntOrNull() }
2152 )
2153 .setIsBrowsable(false)
2154 .setIsPlayable(true)
2155 .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
2156 .apply {
2157 albumArtUri?.let {
2158 setArtworkUri(android.net.Uri.parse(it))
2159 }
2160 duration?.inWholeMilliseconds?.let {
2161 setExtras(Bundle().apply { putLong("duration_ms", it) })
2162 }
2163 }
2164 .build()
2165 )
2166 .build()
2167 }
2168 }
2169}