PlaybackService.kt
2169 lines · 113.3 KB
Changed in: 0.5.0
| 1 | package dev.stave.playback |
| 2 | |
| 3 | import android.app.PendingIntent |
| 4 | import android.content.Context |
| 5 | import android.content.Intent |
| 6 | import android.net.Uri |
| 7 | import android.os.Build |
| 8 | import android.os.Bundle |
| 9 | import androidx.annotation.OptIn |
| 10 | import androidx.media3.common.AudioAttributes |
| 11 | import androidx.media3.common.C |
| 12 | import androidx.media3.common.ForwardingPlayer |
| 13 | import androidx.media3.common.MediaItem |
| 14 | import androidx.media3.common.MediaMetadata |
| 15 | import androidx.media3.common.Player |
| 16 | import androidx.media3.common.util.UnstableApi |
| 17 | import androidx.media3.datasource.DefaultDataSource |
| 18 | import androidx.media3.datasource.okhttp.OkHttpDataSource |
| 19 | import androidx.media3.exoplayer.ExoPlayer |
| 20 | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory |
| 21 | import androidx.media3.session.CommandButton |
| 22 | import androidx.media3.session.DefaultMediaNotificationProvider |
| 23 | import androidx.media3.session.LibraryResult |
| 24 | import androidx.media3.session.MediaLibraryService |
| 25 | import androidx.media3.session.MediaSession |
| 26 | import androidx.media3.session.SessionCommand |
| 27 | import androidx.media3.session.SessionCommands |
| 28 | import androidx.media3.session.SessionResult |
| 29 | import com.google.common.collect.ImmutableList |
| 30 | import com.google.common.util.concurrent.Futures |
| 31 | import com.google.common.util.concurrent.ListenableFuture |
| 32 | import com.google.common.util.concurrent.MoreExecutors |
| 33 | import com.google.common.util.concurrent.SettableFuture |
| 34 | import dagger.hilt.EntryPoint |
| 35 | import dagger.hilt.InstallIn |
| 36 | import dagger.hilt.android.EntryPointAccessors |
| 37 | import dagger.hilt.components.SingletonComponent |
| 38 | import dev.stave.R |
| 39 | import dev.stave.data.SettingsStore |
| 40 | import dev.stave.data.toDomain |
| 41 | import dev.stave.domain.model.DidlItem |
| 42 | import dev.stave.upnp.BrowseResult |
| 43 | import dev.stave.upnp.ContentDirectoryClient |
| 44 | import dev.stave.upnp.UpnpServiceHolder |
| 45 | import kotlinx.coroutines.CoroutineScope |
| 46 | import kotlinx.coroutines.Dispatchers |
| 47 | import kotlinx.coroutines.SupervisorJob |
| 48 | import kotlinx.coroutines.async |
| 49 | import kotlinx.coroutines.coroutineScope |
| 50 | import kotlinx.coroutines.flow.distinctUntilChanged |
| 51 | import kotlinx.coroutines.flow.first |
| 52 | import kotlinx.coroutines.flow.map |
| 53 | import kotlinx.coroutines.launch |
| 54 | import kotlinx.coroutines.withContext |
| 55 | import okhttp3.OkHttpClient |
| 56 | import 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. */ |
| 83 | private 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. */ |
| 88 | private const val OBSERVING_NOTIFICATION_ID = 41043 |
| 89 | |
| 90 | class 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. */ |
| 2105 | private 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 | } |


