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<MediaItem> = 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<dev.stave.domain.model.DidlItem.Track> = 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<MediaItem> = 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<dev.stave.domain.model.DidlItem.Track>()
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<dev.stave.domain.model.DidlItem.Track>(
{ 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()
}
}
}