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

LocalArtistsAndSongsScreens.kt

416 lines · 18.5 KB

Changed in: 0.5.0

1package dev.stave.ui.local
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.lazy.itemsIndexed
20import androidx.compose.foundation.rememberScrollState
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.LaunchedEffect
23import androidx.compose.runtime.collectAsState
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.remember
27import androidx.compose.runtime.saveable.rememberSaveable
28import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.unit.dp
32import androidx.hilt.navigation.compose.hiltViewModel
33import androidx.tv.material3.MaterialTheme
34import androidx.tv.material3.Text
35import coil3.compose.AsyncImage
36import dev.stave.local.LocalMediaScanner
37import dev.stave.ui.components.staveFocusable
38import dev.stave.ui.theme.OnMuted
39import dev.stave.ui.theme.staveInterFamily
40import dev.stave.ui.theme.withInterDigits
41import dev.stave.ui.theme.OnPrimary
42import dev.stave.ui.theme.OnQuiet
43import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
44import dev.stave.ui.theme.adaptiveScreenPaddingVertical
45import dev.stave.ui.theme.SurfaceRaised
46import kotlin.time.Duration.Companion.milliseconds
47
48// ─── ARTISTS LIST ────────────────────────────────────────────────────
49
50/**
51 * List of all artists with at least one track on this device. Tapping an artist
52 * row opens [LocalArtistScreen] which shows their albums + a "play everything"
53 * action.
54 */
55@Composable
56fun LocalArtistsScreen(
57 onArtistClick: (Long, String) -> Unit,
58 onBack: () -> Unit,
59 viewModel: LocalLibraryViewModel = hiltViewModel(),
60) {
61 val state by viewModel.artistsState.collectAsState()
62 LaunchedEffect(Unit) { viewModel.loadArtists() }
63
64 Column(
65 Modifier
66 .fillMaxSize()
67 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
68 ) {
69 Header("Artists", "Tap an artist to see their albums", onBack)
70 Spacer(Modifier.height(8.dp))
71
72 when (val s = state) {
73 is LocalLibraryViewModel.ArtistsState.Loading ->
74 Text("Scanning…", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
75 // Maintainer, 2026-10-29: was a bare "Permission required." with no way to grant.
76 is LocalLibraryViewModel.ArtistsState.PermissionRequired ->
77 LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadArtists() })
78 is LocalLibraryViewModel.ArtistsState.Ready -> {
79 if (s.artists.isEmpty()) {
80 Text("No artists found.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
81 } else {
82 LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
83 // Maintainer, 2026-10-29: index-qualified key — artist ids are name
84 // hashCodes and can collide (duplicate-key crash).
85 itemsIndexed(s.artists, key = { i, a -> "${i}_${a.first}" }) { _, (id, name) ->
86 ArtistRow(name = name, onClick = { onArtistClick(id, name) })
87 }
88 }
89 }
90 }
91 }
92 }
93}
94
95@Composable
96private fun ArtistRow(name: String, onClick: () -> Unit) {
97 val interaction = remember { MutableInteractionSource() }
98 Box(
99 Modifier
100 .fillMaxWidth()
101 .background(SurfaceRaised)
102 .staveFocusable(interaction, cornerRadiusDp = 4)
103 .clickable(interaction, indication = null) { onClick() }
104 .padding(horizontal = 20.dp, vertical = 14.dp)
105 ) {
106 Row(verticalAlignment = Alignment.CenterVertically) {
107 Column(Modifier.weight(1f)) {
108 Text(withInterDigits(name), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
109 }
110 Text("▸", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
111 }
112 }
113}
114
115// ─── ONE ARTIST'S ALBUMS + ALL-TRACKS ACTIONS ────────────────────────
116
117@Composable
118fun LocalArtistScreen(
119 artistName: String,
120 onAlbumClick: (Long) -> Unit,
121 onBack: () -> Unit,
122 onTrackPlaying: () -> Unit,
123 viewModel: LocalLibraryViewModel = hiltViewModel(),
124) {
125 val state by viewModel.artistDetailState.collectAsState()
126 var gridView by rememberSaveable { mutableStateOf(true) }
127 LaunchedEffect(artistName) { viewModel.loadArtist(artistName) }
128
129 Column(
130 Modifier
131 .fillMaxSize()
132 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
133 ) {
134 when (val s = state) {
135 is LocalLibraryViewModel.ArtistDetailState.Loading ->
136 Header("Loading…", null, onBack)
137 // Maintainer, 2026-10-29: add the grant-access prompt under the header (was a dead end).
138 is LocalLibraryViewModel.ArtistDetailState.PermissionRequired -> {
139 Header("Permission required", null, onBack)
140 Spacer(Modifier.height(8.dp))
141 LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadArtist(artistName) })
142 }
143 is LocalLibraryViewModel.ArtistDetailState.Ready -> {
144 val plural = if (s.albums.size == 1) "album" else "albums"
145 Header(
146 s.artistName, "${s.albums.size} $plural", onBack,
147 gridView = gridView,
148 onToggleGrid = { gridView = !gridView },
149 )
150 Spacer(Modifier.height(8.dp))
151
152 if (s.allTracks.isNotEmpty()) {
153 Row(
154 Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
155 horizontalArrangement = Arrangement.spacedBy(12.dp),
156 ) {
157 ActionPill("▶ Play All", onClick = {
158 viewModel.playTracks(s.allTracks, 0, onPlayed = onTrackPlaying)
159 })
160 ActionPill("⇅ Shuffle", onClick = {
161 viewModel.shuffleTracks(s.allTracks, onPlayed = onTrackPlaying)
162 })
163 ActionPill("+ Queue", onClick = { viewModel.enqueueTracks(s.allTracks) })
164 }
165 Spacer(Modifier.height(8.dp))
166 }
167
168 if (s.albums.isEmpty()) {
169 Text("No albums by this artist.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
170 } else if (gridView) {
171 LocalAlbumGrid(albums = s.albums, onAlbumClick = { onAlbumClick(it.id) })
172 } else {
173 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
174 // Maintainer, 2026-10-29: index-qualified key — albums can share an id.
175 itemsIndexed(s.albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
176 ArtistAlbumRow(album = album, onClick = { onAlbumClick(album.id) })
177 }
178 }
179 }
180 }
181 }
182 }
183}
184
185@Composable
186private fun ArtistAlbumRow(album: LocalMediaScanner.LocalAlbum, onClick: () -> Unit) {
187 val interaction = remember { MutableInteractionSource() }
188 Box(
189 Modifier
190 .fillMaxWidth()
191 .background(SurfaceRaised)
192 .staveFocusable(interaction, cornerRadiusDp = 4)
193 .clickable(interaction, indication = null) { onClick() }
194 .padding(horizontal = 16.dp, vertical = 12.dp)
195 ) {
196 Row(verticalAlignment = Alignment.CenterVertically) {
197 AsyncImage(
198 model = album.artUri,
199 contentDescription = null,
200 modifier = Modifier.size(56.dp),
201 )
202 Spacer(Modifier.size(16.dp))
203 Column(Modifier.weight(1f)) {
204 Text(withInterDigits(album.title), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
205 Text(
206 withInterDigits("${album.trackCount} track${if (album.trackCount == 1) "" else "s"}"),
207 style = MaterialTheme.typography.bodySmall,
208 color = OnMuted,
209 )
210 }
211 Text("\u25b8", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
212 }
213 }
214}
215
216// ─── FLAT SONGS LIST ─────────────────────────────────────────────────
217
218@Composable
219fun LocalSongsScreen(
220 onBack: () -> Unit,
221 onTrackPlaying: () -> Unit,
222 viewModel: LocalLibraryViewModel = hiltViewModel(),
223) {
224 val state by viewModel.songsState.collectAsState()
225 LaunchedEffect(Unit) { viewModel.loadSongs() }
226
227 Column(
228 Modifier
229 .fillMaxSize()
230 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
231 ) {
232 when (val s = state) {
233 is LocalLibraryViewModel.SongsState.Loading ->
234 Header("Loading…", null, onBack)
235 // Maintainer, 2026-10-29: add the grant-access prompt under the header (was a dead end).
236 is LocalLibraryViewModel.SongsState.PermissionRequired -> {
237 Header("Permission required", null, onBack)
238 Spacer(Modifier.height(8.dp))
239 LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadSongs() })
240 }
241 is LocalLibraryViewModel.SongsState.Ready -> {
242 val plural = if (s.tracks.size == 1) "song" else "songs"
243 Header("Songs", "${s.tracks.size} $plural", onBack)
244 Spacer(Modifier.height(8.dp))
245
246 if (s.tracks.isNotEmpty()) {
247 Row(
248 Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
249 horizontalArrangement = Arrangement.spacedBy(12.dp),
250 ) {
251 ActionPill("▶ Play All", onClick = {
252 viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
253 })
254 ActionPill("⇅ Shuffle", onClick = {
255 viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
256 })
257 }
258 Spacer(Modifier.height(8.dp))
259 }
260
261 if (s.tracks.isEmpty()) {
262 Text("No songs found.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
263 } else {
264 LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
265 items(s.tracks, key = { it.id }) { track ->
266 val index = s.tracks.indexOf(track)
267 SongFlatRow(
268 track = track,
269 onClick = {
270 viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
271 },
272 )
273 }
274 }
275 }
276 }
277 }
278 }
279}
280
281@Composable
282private fun SongFlatRow(
283 track: LocalMediaScanner.LocalTrack,
284 onClick: () -> Unit,
285) {
286 val interaction = remember { MutableInteractionSource() }
287 Box(
288 Modifier
289 .fillMaxWidth()
290 .background(SurfaceRaised)
291 .staveFocusable(interaction, cornerRadiusDp = 4)
292 .clickable(interaction, indication = null) { onClick() }
293 .padding(horizontal = 16.dp, vertical = 10.dp)
294 ) {
295 Row(verticalAlignment = Alignment.CenterVertically) {
296 AsyncImage(
297 model = track.artUri,
298 contentDescription = null,
299 modifier = Modifier.size(40.dp),
300 )
301 Spacer(Modifier.size(12.dp))
302 Column(Modifier.weight(1f)) {
303 Text(withInterDigits(track.title), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
304 val sub = listOfNotNull(track.artist, track.album).joinToString(" \u00b7 ")
305 if (sub.isNotBlank()) {
306 Text(withInterDigits(sub), style = MaterialTheme.typography.bodySmall, color = OnMuted)
307 }
308 }
309 val mins = track.duration.milliseconds.inWholeMinutes
310 val secs = (track.duration.milliseconds.inWholeSeconds % 60)
311 Text(
312 "%d:%02d".format(mins, secs),
313 style = MaterialTheme.typography.bodySmall.copy(fontFamily = staveInterFamily()),
314 color = OnQuiet,
315 )
316 }
317 }
318}
319
320// ─── GENRE DETAIL ───────────────────────────────────────────────────
321
322/** Tracks matching a specific genre, with Play All / Shuffle actions.
323 * Same layout as [LocalSongsScreen] but filtered by genre via
324 * [LocalMediaScanner.tracksByGenre]. */
325@Composable
326fun LocalGenreScreen(
327 genreName: String,
328 onBack: () -> Unit,
329 onTrackPlaying: () -> Unit,
330 onAlbumClick: (Long) -> Unit = {},
331 viewModel: LocalLibraryViewModel = hiltViewModel(),
332) {
333 val state by viewModel.genreState.collectAsState()
334 LaunchedEffect(genreName) { viewModel.loadGenre(genreName) }
335
336 Column(
337 Modifier
338 .fillMaxSize()
339 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
340 ) {
341 when (val s = state) {
342 is LocalLibraryViewModel.GenreState.Loading ->
343 Header("Loading…", null, onBack)
344 is LocalLibraryViewModel.GenreState.Ready -> {
345 val plural = if (s.tracks.size == 1) "track" else "tracks"
346 val albumPlural = if (s.albums.size == 1) "album" else "albums"
347 Header(genreName, "${s.albums.size} $albumPlural · ${s.tracks.size} $plural", onBack)
348 Spacer(Modifier.height(8.dp))
349
350 if (s.tracks.isNotEmpty()) {
351 Row(
352 Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
353 horizontalArrangement = Arrangement.spacedBy(12.dp),
354 ) {
355 ActionPill("▶ Play All", onClick = {
356 viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
357 })
358 ActionPill("⇅ Shuffle", onClick = {
359 viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
360 })
361 ActionPill("+ Queue", onClick = { viewModel.enqueueTracks(s.tracks) })
362 }
363 Spacer(Modifier.height(8.dp))
364 }
365
366 if (s.albums.isEmpty() && s.tracks.isEmpty()) {
367 Text("No music in this genre.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
368 } else {
369 // Albums section first, then tracks
370 LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
371 if (s.albums.isNotEmpty()) {
372 item {
373 Text("Albums", style = MaterialTheme.typography.titleMedium, color = OnPrimary)
374 Spacer(Modifier.height(8.dp))
375 }
376 // Maintainer, 2026-10-29: index-qualified key — albums can share an id.
377 itemsIndexed(s.albums, key = { i, a -> "album_${i}_${a.id}" }) { _, album ->
378 AlbumRow(album = album, onClick = { onAlbumClick(album.id) })
379 }
380 if (s.tracks.isNotEmpty()) {
381 item {
382 Spacer(Modifier.height(16.dp))
383 Text("Tracks", style = MaterialTheme.typography.titleMedium, color = OnPrimary)
384 Spacer(Modifier.height(8.dp))
385 }
386 }
387 }
388 items(s.tracks, key = { "track_${it.id}" }) { track ->
389 val index = s.tracks.indexOf(track)
390 SongFlatRow(
391 track = track,
392 onClick = {
393 viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
394 },
395 )
396 }
397 }
398 }
399 }
400 }
401 }
402}
403
404@Composable
405private fun ActionPill(label: String, onClick: () -> Unit) {
406 val interaction = remember { MutableInteractionSource() }
407 Box(
408 Modifier
409 .background(SurfaceRaised)
410 .staveFocusable(interaction, cornerRadiusDp = 6)
411 .clickable(interaction, indication = null) { onClick() }
412 .padding(horizontal = 18.dp, vertical = 10.dp)
413 ) {
414 Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
415 }
416}