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

AlbumDetailCarScreen.kt

247 lines · 11.4 KB

Changed in: 0.5.0

1package dev.stave.cartemplate
2
3import androidx.car.app.CarContext
4import androidx.car.app.Screen
5import androidx.car.app.model.Action
6import androidx.car.app.model.ItemList
7import androidx.car.app.model.ListTemplate
8import androidx.car.app.model.MessageTemplate
9import androidx.car.app.model.Row
10import androidx.car.app.model.Template
11import androidx.lifecycle.DefaultLifecycleObserver
12import androidx.lifecycle.LifecycleOwner
13import androidx.lifecycle.lifecycleScope
14import androidx.media3.common.MediaItem
15import androidx.media3.common.MediaMetadata
16import dagger.hilt.EntryPoint
17import dev.stave.local.toDidlTrack
18import dagger.hilt.InstallIn
19import dagger.hilt.android.EntryPointAccessors
20import dagger.hilt.components.SingletonComponent
21import dev.stave.upnp.BrowseResult
22import dev.stave.upnp.ContentDirectoryClient
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.flow.first
25import kotlinx.coroutines.launch
26import kotlinx.coroutines.withContext
27
28/**
29 * Drilled-into-album view opened from the "Album" header action on
30 * [NowPlayingCarScreen]. Renders a [ListTemplate] of every track
31 * on the seed item's album. Tap a track → bridge.playFromList replaces
32 * the queue with the album and starts at the tapped track. Pop back to
33 * NowPlaying so the giant art swaps to the just-tapped track.
34 *
35 * Track resolution branches on the seed item's mediaId:
36 *
37 * - **Local** (mediaId starts with earhorn_local:track:): the scanner's
38 * tracksForTrackAlbum(trackId) returns the seed track's own album group
39 * (decision, 2026-10-29). It reads the scanner's in-memory cache after
40 * the first scan; cost is sub-millisecond in the common case.
41 * - **UPnP** (everything else): ContentDirectoryClient.searchTracksByAlbum
42 * against the configured server's UDN, keyed by the seed track's
43 * albumTitle metadata. Same query the phone UI uses for "More from
44 * this album." If multiple albums share a title across artists,
45 * this will return both — acceptable for a car-eyes-off-road UI.
46 *
47 * Tracks come back with [setUri] pre-populated so the bridge's
48 * playFromList can hand them straight to ExoPlayer without going
49 * through PlaybackService's onAddMediaItems URI-resolution path. This
50 * keeps the playback start latency minimal — no extra IPC round-trip
51 * on the play action.
52 *
53 * Failure modes:
54 * - Server unreachable / no preferred server set → empty list →
55 * "No tracks found" MessageTemplate.
56 * - Album metadata missing on the seed → same fallback.
57 * - Scanner cache empty (permission ungranted on phone) → same.
58 *
59 * No Hilt @AndroidEntryPoint here — Screen is not an Android lifecycle
60 * component. We reach into the singleton DI graph via
61 * EntryPointAccessors, the same pattern PlaybackService uses.
62 */
63class AlbumDetailCarScreen(
64 carContext: CarContext,
65 private val bridge: CarSessionMediaBridge,
66 private val seedItem: MediaItem,
67) : Screen(carContext) {
68
69 @EntryPoint
70 @InstallIn(SingletonComponent::class)
71 interface ScreenDeps {
72 fun cdClient(): ContentDirectoryClient
73 fun localScanner(): dev.stave.local.LocalMediaScanner
74 fun settings(): dev.stave.data.SettingsStore
75 fun renderer(): dev.stave.domain.renderer.Renderer
76 fun gate(): dev.stave.playback.LocalPlaybackGate
77 fun rawCast(): dev.stave.cast.raw.RawCastObserver
78 }
79
80 private val deps: ScreenDeps =
81 EntryPointAccessors.fromApplication(carContext.applicationContext, ScreenDeps::class.java)
82
83 @Volatile private var tracks: List<MediaItem> = emptyList()
84 /** The same tracks as the app's own model, so a car tap plays through
85 * the app's queue and rule, not straight into the player (review
86 * N.88, the maintainer's order 2026-09-02). */
87 @Volatile private var didlTracks: List<dev.stave.domain.model.DidlItem.Track> = emptyList()
88 @Volatile private var loading: Boolean = true
89 @Volatile private var errorMessage: String? = null
90
91 init {
92 lifecycle.addObserver(object : DefaultLifecycleObserver {
93 override fun onCreate(owner: LifecycleOwner) {
94 lifecycleScope.launch {
95 runCatching { resolveTracks() }
96 .onSuccess {
97 tracks = it
98 loading = false
99 errorMessage = if (it.isEmpty()) "No tracks found for this album." else null
100 }
101 .onFailure {
102 loading = false
103 tracks = emptyList()
104 errorMessage = "Couldn't load album: ${it.message ?: "unknown error"}"
105 }
106 invalidate()
107 }
108 }
109 })
110 }
111
112 override fun onGetTemplate(): Template {
113 if (loading) {
114 return MessageTemplate.Builder("Loading album…")
115 .setTitle(albumTitleFromSeed())
116 .setHeaderAction(Action.BACK)
117 .build()
118 }
119 val err = errorMessage
120 if (err != null && tracks.isEmpty()) {
121 return MessageTemplate.Builder(err)
122 .setTitle(albumTitleFromSeed())
123 .setHeaderAction(Action.BACK)
124 .build()
125 }
126 val list = ItemList.Builder()
127 tracks.forEachIndexed { idx, mi ->
128 val md = mi.mediaMetadata
129 // Maintainer, 2026-10-29: a blank title ("") made Row.Builder.build()
130 // throw (the Car App Library rejects empty titles); fall back to the
131 // track's position like a missing title does.
132 val rowTitle = md.title?.toString()?.takeIf { it.isNotBlank() } ?: "Track ${idx + 1}"
133 val artist = md.artist?.toString() ?: md.albumArtist?.toString() ?: ""
134 val row = Row.Builder()
135 .setTitle(rowTitle)
136 .apply { if (artist.isNotBlank()) addText(artist) }
137 .setOnClickListener {
138 // Through the app: same queue, same rule. A car has no
139 // dialog to answer, so if the phone is watching the group
140 // it stops watching and plays here (the disarm-and-
141 // return-later rule applies on a fixed screen).
142 val list = didlTracks
143 if (list.isNotEmpty()) {
144 val start = idx.coerceIn(0, list.lastIndex)
145 kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
146 if (deps.rawCast().observing.value) deps.gate().leaveObservingForLocal()
147 runCatching { deps.renderer().load(list, start) }
148 }
149 } else {
150 bridge.playFromList(tracks, idx)
151 }
152 screenManager.pop()
153 }
154 .build()
155 list.addItem(row)
156 }
157 return ListTemplate.Builder()
158 .setTitle(albumTitleFromSeed())
159 .setHeaderAction(Action.BACK)
160 .setSingleList(list.build())
161 .build()
162 }
163
164 private fun albumTitleFromSeed(): String =
165 seedItem.mediaMetadata.albumTitle?.toString()
166 ?: seedItem.mediaMetadata.title?.toString()
167 ?: "Album"
168
169 private suspend fun resolveTracks(): List<MediaItem> = withContext(Dispatchers.IO) {
170 val mid = seedItem.mediaId
171 val lms = dev.stave.local.LocalMediaScanner
172 if (mid.startsWith("${lms.LOCAL_PREFIX}track:")) {
173 // Local files path. Track id → album id via cached allTracks().
174 val trackId = mid.removePrefix("${lms.LOCAL_PREFIX}track:").toLongOrNull()
175 ?: return@withContext emptyList()
176 // Maintainer, 2026-10-29: resolve the album by the seed track's own
177 // group (exact), not by its MediaStore album id (can name a sibling
178 // group when one album id is split by album-artist tags).
179 val albumTracks = deps.localScanner().tracksForTrackAlbum(trackId)
180 if (albumTracks.isEmpty()) return@withContext emptyList()
181 didlTracks = albumTracks.map { it.toDidlTrack() }
182 return@withContext albumTracks.map { t ->
183 MediaItem.Builder()
184 .setMediaId("${lms.LOCAL_PREFIX}track:${t.id}")
185 .setUri(t.contentUri)
186 .setMediaMetadata(
187 MediaMetadata.Builder()
188 .setTitle(t.title)
189 .setArtist(t.artist ?: t.albumArtist)
190 .setAlbumTitle(t.album)
191 .setTrackNumber(t.trackNumber)
192 .setDiscNumber(t.discNumber)
193 .setIsBrowsable(false)
194 .setIsPlayable(true)
195 .apply { t.artUri?.let { setArtworkUri(it) } }
196 .build()
197 )
198 .build()
199 }
200 }
201
202 // UPnP path.
203 val albumTitle = seedItem.mediaMetadata.albumTitle?.toString()
204 ?: return@withContext emptyList()
205 val udn = deps.settings().preferredServerUdn.first()
206 ?: return@withContext emptyList()
207 val result = deps.cdClient().searchTracksByAlbum(udn, albumTitle)
208 if (result !is BrowseResult.Success) return@withContext emptyList()
209 // Two albums with the same title ("Greatest Hits") came back as one
210 // list (review N.90). Keep only tracks whose album artist (or
211 // artist) matches the seed's, when the seed names one; otherwise
212 // fall back to the whole title match as before.
213 val seedArtist = (seedItem.mediaMetadata.albumArtist ?: seedItem.mediaMetadata.artist)
214 ?.toString()?.trim()?.ifBlank { null }
215 val all = result.items.filterIsInstance<dev.stave.domain.model.DidlItem.Track>()
216 val narrowed = if (seedArtist == null) all else all.filter { t ->
217 (t.albumArtist ?: t.artist)?.trim()?.equals(seedArtist, ignoreCase = true) == true
218 }.ifEmpty { all }
219 val sorted = narrowed
220 .sortedWith(
221 compareBy<dev.stave.domain.model.DidlItem.Track>(
222 { it.discNumber ?: 0 },
223 { it.trackNumber ?: 0 },
224 )
225 )
226 didlTracks = sorted
227 return@withContext sorted
228 .map { t ->
229 MediaItem.Builder()
230 .setMediaId(t.id)
231 .setUri(android.net.Uri.parse(t.resourceUrl))
232 .setMediaMetadata(
233 MediaMetadata.Builder()
234 .setTitle(t.title)
235 .setArtist(t.artist ?: t.albumArtist)
236 .setAlbumTitle(t.album)
237 .setTrackNumber(t.trackNumber)
238 .setDiscNumber(t.discNumber)
239 .setIsBrowsable(false)
240 .setIsPlayable(true)
241 .apply { t.albumArtUri?.let { setArtworkUri(android.net.Uri.parse(it)) } }
242 .build()
243 )
244 .build()
245 }
246 }
247}