LocalArtistsAndSongsScreens.kt
package dev.stave.ui.local
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import dev.stave.local.LocalMediaScanner
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.SurfaceRaised
import kotlin.time.Duration.Companion.milliseconds
// ─── ARTISTS LIST ────────────────────────────────────────────────────
/**
* List of all artists with at least one track on this device. Tapping an artist
* row opens [LocalArtistScreen] which shows their albums + a "play everything"
* action.
*/
@Composable
fun LocalArtistsScreen(
onArtistClick: (Long, String) -> Unit,
onBack: () -> Unit,
viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
val state by viewModel.artistsState.collectAsState()
LaunchedEffect(Unit) { viewModel.loadArtists() }
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
Header("Artists", "Tap an artist to see their albums", onBack)
Spacer(Modifier.height(8.dp))
when (val s = state) {
is LocalLibraryViewModel.ArtistsState.Loading ->
Text("Scanning…", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
// Maintainer, 2026-10-29: was a bare "Permission required." with no way to grant.
is LocalLibraryViewModel.ArtistsState.PermissionRequired ->
LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadArtists() })
is LocalLibraryViewModel.ArtistsState.Ready -> {
if (s.artists.isEmpty()) {
Text("No artists found.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Maintainer, 2026-10-29: index-qualified key — artist ids are name
// hashCodes and can collide (duplicate-key crash).
itemsIndexed(s.artists, key = { i, a -> "${i}_${a.first}" }) { _, (id, name) ->
ArtistRow(name = name, onClick = { onArtistClick(id, name) })
}
}
}
}
}
}
}
@Composable
private fun ArtistRow(name: String, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(withInterDigits(name), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
}
Text("▸", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
}
}
}
// ─── ONE ARTIST'S ALBUMS + ALL-TRACKS ACTIONS ────────────────────────
@Composable
fun LocalArtistScreen(
artistName: String,
onAlbumClick: (Long) -> Unit,
onBack: () -> Unit,
onTrackPlaying: () -> Unit,
viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
val state by viewModel.artistDetailState.collectAsState()
var gridView by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(artistName) { viewModel.loadArtist(artistName) }
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
when (val s = state) {
is LocalLibraryViewModel.ArtistDetailState.Loading ->
Header("Loading…", null, onBack)
// Maintainer, 2026-10-29: add the grant-access prompt under the header (was a dead end).
is LocalLibraryViewModel.ArtistDetailState.PermissionRequired -> {
Header("Permission required", null, onBack)
Spacer(Modifier.height(8.dp))
LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadArtist(artistName) })
}
is LocalLibraryViewModel.ArtistDetailState.Ready -> {
val plural = if (s.albums.size == 1) "album" else "albums"
Header(
s.artistName, "${s.albums.size} $plural", onBack,
gridView = gridView,
onToggleGrid = { gridView = !gridView },
)
Spacer(Modifier.height(8.dp))
if (s.allTracks.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ActionPill("▶ Play All", onClick = {
viewModel.playTracks(s.allTracks, 0, onPlayed = onTrackPlaying)
})
ActionPill("⇅ Shuffle", onClick = {
viewModel.shuffleTracks(s.allTracks, onPlayed = onTrackPlaying)
})
ActionPill("+ Queue", onClick = { viewModel.enqueueTracks(s.allTracks) })
}
Spacer(Modifier.height(8.dp))
}
if (s.albums.isEmpty()) {
Text("No albums by this artist.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
} else if (gridView) {
LocalAlbumGrid(albums = s.albums, onAlbumClick = { onAlbumClick(it.id) })
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Maintainer, 2026-10-29: index-qualified key — albums can share an id.
itemsIndexed(s.albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
ArtistAlbumRow(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
}
}
}
@Composable
private fun ArtistAlbumRow(album: LocalMediaScanner.LocalAlbum, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = album.artUri,
contentDescription = null,
modifier = Modifier.size(56.dp),
)
Spacer(Modifier.size(16.dp))
Column(Modifier.weight(1f)) {
Text(withInterDigits(album.title), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
Text(
withInterDigits("${album.trackCount} track${if (album.trackCount == 1) "" else "s"}"),
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
Text("\u25b8", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
}
}
}
// ─── FLAT SONGS LIST ─────────────────────────────────────────────────
@Composable
fun LocalSongsScreen(
onBack: () -> Unit,
onTrackPlaying: () -> Unit,
viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
val state by viewModel.songsState.collectAsState()
LaunchedEffect(Unit) { viewModel.loadSongs() }
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
when (val s = state) {
is LocalLibraryViewModel.SongsState.Loading ->
Header("Loading…", null, onBack)
// Maintainer, 2026-10-29: add the grant-access prompt under the header (was a dead end).
is LocalLibraryViewModel.SongsState.PermissionRequired -> {
Header("Permission required", null, onBack)
Spacer(Modifier.height(8.dp))
LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadSongs() })
}
is LocalLibraryViewModel.SongsState.Ready -> {
val plural = if (s.tracks.size == 1) "song" else "songs"
Header("Songs", "${s.tracks.size} $plural", onBack)
Spacer(Modifier.height(8.dp))
if (s.tracks.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ActionPill("▶ Play All", onClick = {
viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
})
ActionPill("⇅ Shuffle", onClick = {
viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
})
}
Spacer(Modifier.height(8.dp))
}
if (s.tracks.isEmpty()) {
Text("No songs found.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(s.tracks, key = { it.id }) { track ->
val index = s.tracks.indexOf(track)
SongFlatRow(
track = track,
onClick = {
viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
},
)
}
}
}
}
}
}
}
@Composable
private fun SongFlatRow(
track: LocalMediaScanner.LocalTrack,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = track.artUri,
contentDescription = null,
modifier = Modifier.size(40.dp),
)
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text(withInterDigits(track.title), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
val sub = listOfNotNull(track.artist, track.album).joinToString(" \u00b7 ")
if (sub.isNotBlank()) {
Text(withInterDigits(sub), style = MaterialTheme.typography.bodySmall, color = OnMuted)
}
}
val mins = track.duration.milliseconds.inWholeMinutes
val secs = (track.duration.milliseconds.inWholeSeconds % 60)
Text(
"%d:%02d".format(mins, secs),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = staveInterFamily()),
color = OnQuiet,
)
}
}
}
// ─── GENRE DETAIL ───────────────────────────────────────────────────
/** Tracks matching a specific genre, with Play All / Shuffle actions.
* Same layout as [LocalSongsScreen] but filtered by genre via
* [LocalMediaScanner.tracksByGenre]. */
@Composable
fun LocalGenreScreen(
genreName: String,
onBack: () -> Unit,
onTrackPlaying: () -> Unit,
onAlbumClick: (Long) -> Unit = {},
viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
val state by viewModel.genreState.collectAsState()
LaunchedEffect(genreName) { viewModel.loadGenre(genreName) }
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
when (val s = state) {
is LocalLibraryViewModel.GenreState.Loading ->
Header("Loading…", null, onBack)
is LocalLibraryViewModel.GenreState.Ready -> {
val plural = if (s.tracks.size == 1) "track" else "tracks"
val albumPlural = if (s.albums.size == 1) "album" else "albums"
Header(genreName, "${s.albums.size} $albumPlural · ${s.tracks.size} $plural", onBack)
Spacer(Modifier.height(8.dp))
if (s.tracks.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ActionPill("▶ Play All", onClick = {
viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
})
ActionPill("⇅ Shuffle", onClick = {
viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
})
ActionPill("+ Queue", onClick = { viewModel.enqueueTracks(s.tracks) })
}
Spacer(Modifier.height(8.dp))
}
if (s.albums.isEmpty() && s.tracks.isEmpty()) {
Text("No music in this genre.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
} else {
// Albums section first, then tracks
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (s.albums.isNotEmpty()) {
item {
Text("Albums", style = MaterialTheme.typography.titleMedium, color = OnPrimary)
Spacer(Modifier.height(8.dp))
}
// Maintainer, 2026-10-29: index-qualified key — albums can share an id.
itemsIndexed(s.albums, key = { i, a -> "album_${i}_${a.id}" }) { _, album ->
AlbumRow(album = album, onClick = { onAlbumClick(album.id) })
}
if (s.tracks.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text("Tracks", style = MaterialTheme.typography.titleMedium, color = OnPrimary)
Spacer(Modifier.height(8.dp))
}
}
}
items(s.tracks, key = { "track_${it.id}" }) { track ->
val index = s.tracks.indexOf(track)
SongFlatRow(
track = track,
onClick = {
viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
},
)
}
}
}
}
}
}
}
@Composable
private fun ActionPill(label: String, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 18.dp, vertical = 10.dp)
) {
Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
}
}