package dev.stave.cartemplate import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.Action import androidx.car.app.model.ItemList import androidx.car.app.model.ListTemplate import androidx.car.app.model.MessageTemplate import androidx.car.app.model.Row import androidx.car.app.model.Template import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import dagger.hilt.EntryPoint import dev.stave.local.toDidlTrack import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import dev.stave.upnp.BrowseResult import dev.stave.upnp.ContentDirectoryClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * Drilled-into-album view opened from the "Album" header action on * [NowPlayingCarScreen]. Renders a [ListTemplate] of every track * on the seed item's album. Tap a track → bridge.playFromList replaces * the queue with the album and starts at the tapped track. Pop back to * NowPlaying so the giant art swaps to the just-tapped track. * * Track resolution branches on the seed item's mediaId: * * - **Local** (mediaId starts with earhorn_local:track:): the scanner's * tracksForTrackAlbum(trackId) returns the seed track's own album group * (decision, 2026-10-29). It reads the scanner's in-memory cache after * the first scan; cost is sub-millisecond in the common case. * - **UPnP** (everything else): ContentDirectoryClient.searchTracksByAlbum * against the configured server's UDN, keyed by the seed track's * albumTitle metadata. Same query the phone UI uses for "More from * this album." If multiple albums share a title across artists, * this will return both — acceptable for a car-eyes-off-road UI. * * Tracks come back with [setUri] pre-populated so the bridge's * playFromList can hand them straight to ExoPlayer without going * through PlaybackService's onAddMediaItems URI-resolution path. This * keeps the playback start latency minimal — no extra IPC round-trip * on the play action. * * Failure modes: * - Server unreachable / no preferred server set → empty list → * "No tracks found" MessageTemplate. * - Album metadata missing on the seed → same fallback. * - Scanner cache empty (permission ungranted on phone) → same. * * No Hilt @AndroidEntryPoint here — Screen is not an Android lifecycle * component. We reach into the singleton DI graph via * EntryPointAccessors, the same pattern PlaybackService uses. */ class AlbumDetailCarScreen( carContext: CarContext, private val bridge: CarSessionMediaBridge, private val seedItem: MediaItem, ) : Screen(carContext) { @EntryPoint @InstallIn(SingletonComponent::class) interface ScreenDeps { fun cdClient(): ContentDirectoryClient fun localScanner(): dev.stave.local.LocalMediaScanner fun settings(): dev.stave.data.SettingsStore fun renderer(): dev.stave.domain.renderer.Renderer fun gate(): dev.stave.playback.LocalPlaybackGate fun rawCast(): dev.stave.cast.raw.RawCastObserver } private val deps: ScreenDeps = EntryPointAccessors.fromApplication(carContext.applicationContext, ScreenDeps::class.java) @Volatile private var tracks: List = emptyList() /** The same tracks as the app's own model, so a car tap plays through * the app's queue and rule, not straight into the player (review * N.88, the maintainer's order 2026-09-02). */ @Volatile private var didlTracks: List = emptyList() @Volatile private var loading: Boolean = true @Volatile private var errorMessage: String? = null init { lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { lifecycleScope.launch { runCatching { resolveTracks() } .onSuccess { tracks = it loading = false errorMessage = if (it.isEmpty()) "No tracks found for this album." else null } .onFailure { loading = false tracks = emptyList() errorMessage = "Couldn't load album: ${it.message ?: "unknown error"}" } invalidate() } } }) } override fun onGetTemplate(): Template { if (loading) { return MessageTemplate.Builder("Loading album…") .setTitle(albumTitleFromSeed()) .setHeaderAction(Action.BACK) .build() } val err = errorMessage if (err != null && tracks.isEmpty()) { return MessageTemplate.Builder(err) .setTitle(albumTitleFromSeed()) .setHeaderAction(Action.BACK) .build() } val list = ItemList.Builder() tracks.forEachIndexed { idx, mi -> val md = mi.mediaMetadata // Maintainer, 2026-10-29: a blank title ("") made Row.Builder.build() // throw (the Car App Library rejects empty titles); fall back to the // track's position like a missing title does. val rowTitle = md.title?.toString()?.takeIf { it.isNotBlank() } ?: "Track ${idx + 1}" val artist = md.artist?.toString() ?: md.albumArtist?.toString() ?: "" val row = Row.Builder() .setTitle(rowTitle) .apply { if (artist.isNotBlank()) addText(artist) } .setOnClickListener { // Through the app: same queue, same rule. A car has no // dialog to answer, so if the phone is watching the group // it stops watching and plays here (the disarm-and- // return-later rule applies on a fixed screen). val list = didlTracks if (list.isNotEmpty()) { val start = idx.coerceIn(0, list.lastIndex) kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { if (deps.rawCast().observing.value) deps.gate().leaveObservingForLocal() runCatching { deps.renderer().load(list, start) } } } else { bridge.playFromList(tracks, idx) } screenManager.pop() } .build() list.addItem(row) } return ListTemplate.Builder() .setTitle(albumTitleFromSeed()) .setHeaderAction(Action.BACK) .setSingleList(list.build()) .build() } private fun albumTitleFromSeed(): String = seedItem.mediaMetadata.albumTitle?.toString() ?: seedItem.mediaMetadata.title?.toString() ?: "Album" private suspend fun resolveTracks(): List = withContext(Dispatchers.IO) { val mid = seedItem.mediaId val lms = dev.stave.local.LocalMediaScanner if (mid.startsWith("${lms.LOCAL_PREFIX}track:")) { // Local files path. Track id → album id via cached allTracks(). val trackId = mid.removePrefix("${lms.LOCAL_PREFIX}track:").toLongOrNull() ?: return@withContext emptyList() // Maintainer, 2026-10-29: resolve the album by the seed track's own // group (exact), not by its MediaStore album id (can name a sibling // group when one album id is split by album-artist tags). val albumTracks = deps.localScanner().tracksForTrackAlbum(trackId) if (albumTracks.isEmpty()) return@withContext emptyList() didlTracks = albumTracks.map { it.toDidlTrack() } return@withContext albumTracks.map { t -> MediaItem.Builder() .setMediaId("${lms.LOCAL_PREFIX}track:${t.id}") .setUri(t.contentUri) .setMediaMetadata( MediaMetadata.Builder() .setTitle(t.title) .setArtist(t.artist ?: t.albumArtist) .setAlbumTitle(t.album) .setTrackNumber(t.trackNumber) .setDiscNumber(t.discNumber) .setIsBrowsable(false) .setIsPlayable(true) .apply { t.artUri?.let { setArtworkUri(it) } } .build() ) .build() } } // UPnP path. val albumTitle = seedItem.mediaMetadata.albumTitle?.toString() ?: return@withContext emptyList() val udn = deps.settings().preferredServerUdn.first() ?: return@withContext emptyList() val result = deps.cdClient().searchTracksByAlbum(udn, albumTitle) if (result !is BrowseResult.Success) return@withContext emptyList() // Two albums with the same title ("Greatest Hits") came back as one // list (review N.90). Keep only tracks whose album artist (or // artist) matches the seed's, when the seed names one; otherwise // fall back to the whole title match as before. val seedArtist = (seedItem.mediaMetadata.albumArtist ?: seedItem.mediaMetadata.artist) ?.toString()?.trim()?.ifBlank { null } val all = result.items.filterIsInstance() val narrowed = if (seedArtist == null) all else all.filter { t -> (t.albumArtist ?: t.artist)?.trim()?.equals(seedArtist, ignoreCase = true) == true }.ifEmpty { all } val sorted = narrowed .sortedWith( compareBy( { it.discNumber ?: 0 }, { it.trackNumber ?: 0 }, ) ) didlTracks = sorted return@withContext sorted .map { t -> MediaItem.Builder() .setMediaId(t.id) .setUri(android.net.Uri.parse(t.resourceUrl)) .setMediaMetadata( MediaMetadata.Builder() .setTitle(t.title) .setArtist(t.artist ?: t.albumArtist) .setAlbumTitle(t.album) .setTrackNumber(t.trackNumber) .setDiscNumber(t.discNumber) .setIsBrowsable(false) .setIsPlayable(true) .apply { t.albumArtUri?.let { setArtworkUri(android.net.Uri.parse(it)) } } .build() ) .build() } } }