LocalAlbumsScreen.kt
package dev.stave.ui.local
// Maintainer, 2026-10-29: imports for LocalPermissionPrompt (permission request, app-settings
// fallback, ON_RESUME refresh, TV focus).
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.aspectRatio
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
// Maintainer, 2026-10-29: indexed variants for collision-proof (index-qualified) keys.
import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.AlbumArt
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.OnMuted
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
/**
* List of all albums on the device. Tap a row to descend into the album's tracks
* via [LocalAlbumScreen]. Used both directly (from the LocalHub "Albums" tile) and
* indirectly (for an artist's albums via [LocalArtistScreen]).
*/
@Composable
fun LocalAlbumsScreen(
title: String = "Albums",
onAlbumClick: (Long) -> Unit,
onBack: () -> Unit,
viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
val state by viewModel.albumsState.collectAsState()
var gridView by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { viewModel.loadAlbums() }
Column(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
Header(
title = title,
subtitle = "Tap an album to see its tracks",
onBack = onBack,
gridView = gridView,
onToggleGrid = { gridView = !gridView },
)
Spacer(Modifier.height(8.dp))
when (val s = state) {
is LocalLibraryViewModel.AlbumsState.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.AlbumsState.PermissionRequired ->
LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadAlbums() })
is LocalLibraryViewModel.AlbumsState.Ready -> {
if (s.albums.isEmpty()) {
Text("No albums on this device.", 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 — two albums sharing an id
// crashed the LazyColumn with a duplicate key.
itemsIndexed(s.albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
AlbumRow(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
}
}
}
@Composable
internal fun AlbumRow(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) {
// Album art thumbnail (56dp). Coil falls back gracefully when the
// album-art content URI returns nothing — we just get an empty box.
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,
)
val sub = listOfNotNull(
album.artist,
"${album.trackCount} track${if (album.trackCount == 1) "" else "s"}",
).joinToString(" \u00b7 ")
Text(withInterDigits(sub), style = MaterialTheme.typography.bodySmall, color = OnMuted)
}
Text("\u25b8", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
}
}
}
/** 2-column album art grid for LocalAlbum data. Same visual pattern as
* the BrowseScreen AlbumGrid but operates on LocalAlbum directly. */
@Composable
internal fun LocalAlbumGrid(
albums: List<LocalMediaScanner.LocalAlbum>,
onAlbumClick: (LocalMediaScanner.LocalAlbum) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Maintainer, 2026-10-29: index-qualified key — two albums sharing an id crashed
// the grid with a duplicate key.
gridItemsIndexed(albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
// Maintainer, 2026-10-29: staveFocusable sharing the clickable's interaction source
// (as list rows do) — plain .clickable gave no focus ring on TV. Covers every
// LocalAlbumGrid user (Albums, Artist, Random Albums).
val interaction = remember { MutableInteractionSource() }
Column(
Modifier
.clip(RoundedCornerShape(6.dp))
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onAlbumClick(album) }
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AlbumArt(
model = album.artUri,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp)),
)
Spacer(Modifier.height(8.dp))
Text(
withInterDigits(album.title),
style = MaterialTheme.typography.bodyMedium,
color = OnPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
album.artist?.let {
Text(
withInterDigits(it),
style = MaterialTheme.typography.labelSmall,
color = OnMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
}
}
}
}
// Maintainer, 2026-10-29: shared permission prompt for the Albums / Artists / Artist / Songs
// screens, which showed "Permission required." with no way to grant. Mirrors LocalHubScreen:
// system permission request first; after a permanent denial (the dialog no longer appears)
// the button opens this app's settings page. [onRefresh] is the screen's own load call; it
// also runs on ON_RESUME so a grant made in system Settings is picked up on return. The
// button takes focus on entry so TV D-pad Center works without navigating.
@Composable
internal fun LocalPermissionPrompt(permissionName: String, onRefresh: () -> Unit) {
val context = LocalContext.current
val activity = remember(context) {
run {
var ctx: Context? = context
while (ctx is ContextWrapper) {
if (ctx is Activity) return@run ctx
ctx = ctx.baseContext
}
null
}
}
val currentOnRefresh by rememberUpdatedState(onRefresh)
var permanentlyDenied by rememberSaveable { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permanentlyDenied = !granted && activity != null &&
!ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionName)
// Maintainer, 2026-10-29: no refresh here — ON_RESUME (below) fires right
// after the system dialog closes; refreshing in both ran the scan twice.
}
DisposableEffect(activity) {
val lifecycle = (activity as? LifecycleOwner)?.lifecycle
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) currentOnRefresh()
}
lifecycle?.addObserver(observer)
onDispose { lifecycle?.removeObserver(observer) }
}
val buttonFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { buttonFocus.requestFocus() } }
val interaction = remember { MutableInteractionSource() }
Column {
Text(
"Permission required to read audio files on this device.",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
Spacer(Modifier.height(16.dp))
Box(
Modifier
.focusRequester(buttonFocus)
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) {
if (permanentlyDenied) {
runCatching {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
if (activity == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
(activity ?: context).startActivity(intent)
}
} else {
permissionLauncher.launch(permissionName)
}
}
.padding(horizontal = 24.dp, vertical = 10.dp)
) {
Text(
if (permanentlyDenied) "Open settings" else "Grant access",
style = MaterialTheme.typography.labelLarge,
color = OnPrimary,
)
}
}
}
@Composable
internal fun Header(
title: String,
subtitle: String?,
onBack: () -> Unit,
gridView: Boolean = false,
onToggleGrid: (() -> Unit)? = null,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Column(Modifier.weight(1f)) {
Text(withInterDigits(title), style = MaterialTheme.typography.displaySmall, color = OnPrimary)
if (subtitle != null) {
Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodyLarge, color = OnMuted)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onToggleGrid != null) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onToggleGrid() }
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
androidx.compose.material3.Icon(
painter = androidx.compose.ui.res.painterResource(
if (gridView) dev.stave.R.drawable.ic_view_list
else dev.stave.R.drawable.ic_view_grid
),
contentDescription = if (gridView) "List view" else "Grid view",
tint = OnPrimary,
modifier = Modifier.size(20.dp),
)
}
}
val backInteraction = remember { MutableInteractionSource() }
Box(
Modifier
.background(SurfaceRaised)
.staveFocusable(backInteraction, cornerRadiusDp = 6)
.clickable(backInteraction, indication = null) { onBack() }
.padding(horizontal = 20.dp, vertical = 10.dp)
) {
Text("Back", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
}
}
}
}