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

LocalAlbumScreen.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.fillMaxHeight
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.rememberScrollState
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.isCompactWidth
import dev.stave.ui.theme.SurfaceRaised
import kotlin.time.Duration.Companion.milliseconds

/**
 * Track list for a single local album, with the same playback action affordances
 * (Play All, Shuffle, Add to Queue, Play Next) the network browse provides for an
 * album detail. Tapping an individual track plays from that track, with the rest
 * of the album following as the queue.
 */
@Composable
fun LocalAlbumScreen(
    albumId: Long,
    onBack: () -> Unit,
    onTrackPlaying: () -> Unit,
    viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
    val state by viewModel.albumDetailState.collectAsState()
    LaunchedEffect(albumId) { viewModel.loadAlbum(albumId) }

    Column(
        Modifier
            .fillMaxSize()
            .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
    ) {
        when (val s = state) {
            is LocalLibraryViewModel.AlbumDetailState.Loading -> {
                Header(title = "Loading…", subtitle = null, onBack = onBack)
            }
            is LocalLibraryViewModel.AlbumDetailState.PermissionRequired -> {
                Header(title = "Permission required", subtitle = null, onBack = onBack)
                Spacer(Modifier.height(8.dp))
                // Maintainer, 2026-10-29: was a dead end with no way to grant access.
                LocalPermissionPrompt(
                    permissionName = viewModel.permissionName,
                    onRefresh = { viewModel.loadAlbum(albumId) },
                )
            }
            is LocalLibraryViewModel.AlbumDetailState.Ready -> {
                Header(
                    title = s.album?.title ?: "Album",
                    subtitle = s.album?.artist,
                    onBack = onBack,
                )
                Spacer(Modifier.height(20.dp))

                // Action row — the four standard playback actions for any track collection
                if (s.tracks.isNotEmpty()) {
                    ActionRow(
                        onPlayAll = {
                            viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
                        },
                        onShuffle = {
                            viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
                        },
                        onEnqueue = { viewModel.enqueueTracks(s.tracks) },
                        onPlayNext = { viewModel.playNext(s.tracks) },
                    )
                    Spacer(Modifier.height(20.dp))
                }

                if (s.tracks.isEmpty()) {
                    Text("No tracks in this album.", 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)
                            TrackRow(
                                track = track,
                                albumArtUri = s.album?.artUri,
                                onClick = {
                                    viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
                                },
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ActionRow(
    onPlayAll: () -> Unit,
    onShuffle: () -> Unit,
    onEnqueue: () -> Unit,
    onPlayNext: () -> Unit,
) {
    Row(
        Modifier
            .fillMaxWidth()
            .horizontalScroll(rememberScrollState()),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        ActionPill("▶ Play All", onPlayAll)
        ActionPill("⇅ Shuffle", onShuffle)
        ActionPill("↻ Play Next", onPlayNext)
        ActionPill("+ Queue", onEnqueue)
    }
}

@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 = 12.dp, vertical = 6.dp)
    ) {
        Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
    }
}

@Composable
private fun TrackRow(
    track: LocalMediaScanner.LocalTrack,
    albumArtUri: android.net.Uri?,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .fillMaxWidth()
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 4)
            .clickable(interaction, indication = null) { onClick() }
            .padding(start = 2.dp, end = 8.dp, top = 4.dp, bottom = 2.dp)
    ) {
        if (isCompactWidth()) {
            val numAndDur = buildList {
                track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
                val mins = track.duration.milliseconds.inWholeMinutes
                val secs = track.duration.milliseconds.inWholeSeconds % 60
                add("%d:%02d".format(mins, secs))
            }.joinToString(" \u2022 ")
            Column {
                Row(
                    Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                ) {
                    AsyncImage(
                        model = track.artUri ?: albumArtUri,
                        contentDescription = null,
                        modifier = Modifier
                            .size(96.dp)
                            .clip(RoundedCornerShape(4.dp)),
                    )
                    Column(
                        Modifier.weight(1f).fillMaxHeight(),
                        verticalArrangement = Arrangement.SpaceEvenly,
                    ) {
                        Text(
                            withInterDigits(track.title),
                            style = MaterialTheme.typography.bodySmall,
                            color = OnPrimary,
                            maxLines = 2,
                            overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
                        )
                        val albumLine = track.album?.takeIf { it.isNotBlank() }
                        if (albumLine != null) {
                            Text(
                                withInterDigits(albumLine),
                                style = MaterialTheme.typography.labelSmall,
                                color = OnMuted,
                                maxLines = 1,
                                overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
                            )
                        }
                        val artistLine = track.artist?.takeIf { it.isNotBlank() }
                        if (artistLine != null) {
                            Text(
                                withInterDigits(artistLine),
                                style = MaterialTheme.typography.labelSmall,
                                color = OnMuted,
                                maxLines = 1,
                                overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
                            )
                        }
                    }
                }
                Spacer(Modifier.height(4.dp))
                Row {
                    if (numAndDur.isNotEmpty()) {
                        Text(
                            numAndDur,
                            style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
                            color = OnMuted,
                        )
                    }
                }
            }
        } else {
            Row(verticalAlignment = Alignment.CenterVertically) {
                AsyncImage(
                    model = track.artUri ?: albumArtUri,
                    contentDescription = null,
                    modifier = Modifier.size(40.dp),
                )
                Spacer(Modifier.size(12.dp))
                Column(Modifier.weight(1f)) {
                    val numberedTitle = if (track.trackNumber != null) {
                        "${track.trackNumber}. ${track.title}"
                    } else track.title
                    Text(withInterDigits(numberedTitle), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
                    if (!track.artist.isNullOrBlank()) {
                        Text(withInterDigits(track.artist), 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,
                )
            }
        }
    }
}