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

LocalAlbumScreen.kt

Each line, with the release that last changed it.

0.5.01package 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.fillMaxHeight
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.rememberScrollState
21import androidx.compose.foundation.shape.RoundedCornerShape
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.collectAsState
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.remember
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.unit.dp
31import androidx.hilt.navigation.compose.hiltViewModel
32import androidx.tv.material3.MaterialTheme
33import androidx.tv.material3.Text
34import coil3.compose.AsyncImage
35import dev.stave.local.LocalMediaScanner
36import dev.stave.ui.components.staveFocusable
37import dev.stave.ui.theme.OnMuted
38import dev.stave.ui.theme.staveInterFamily
39import dev.stave.ui.theme.withInterDigits
40import dev.stave.ui.theme.OnPrimary
41import dev.stave.ui.theme.OnQuiet
42import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
43import dev.stave.ui.theme.adaptiveScreenPaddingVertical
44import dev.stave.ui.theme.isCompactWidth
45import dev.stave.ui.theme.SurfaceRaised
46import kotlin.time.Duration.Companion.milliseconds
47
48/**
49 * Track list for a single local album, with the same playback action affordances
50 * (Play All, Shuffle, Add to Queue, Play Next) the network browse provides for an
51 * album detail. Tapping an individual track plays from that track, with the rest
52 * of the album following as the queue.
53 */
54@Composable
55fun LocalAlbumScreen(
56 albumId: Long,
57 onBack: () -> Unit,
58 onTrackPlaying: () -> Unit,
59 viewModel: LocalLibraryViewModel = hiltViewModel(),
60) {
61 val state by viewModel.albumDetailState.collectAsState()
62 LaunchedEffect(albumId) { viewModel.loadAlbum(albumId) }
63
64 Column(
65 Modifier
66 .fillMaxSize()
67 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
68 ) {
69 when (val s = state) {
70 is LocalLibraryViewModel.AlbumDetailState.Loading -> {
71 Header(title = "Loading…", subtitle = null, onBack = onBack)
72 }
73 is LocalLibraryViewModel.AlbumDetailState.PermissionRequired -> {
74 Header(title = "Permission required", subtitle = null, onBack = onBack)
75 Spacer(Modifier.height(8.dp))
76 // Maintainer, 2026-10-29: was a dead end with no way to grant access.
77 LocalPermissionPrompt(
78 permissionName = viewModel.permissionName,
79 onRefresh = { viewModel.loadAlbum(albumId) },
80 )
81 }
82 is LocalLibraryViewModel.AlbumDetailState.Ready -> {
83 Header(
84 title = s.album?.title ?: "Album",
85 subtitle = s.album?.artist,
86 onBack = onBack,
87 )
88 Spacer(Modifier.height(20.dp))
89
90 // Action row — the four standard playback actions for any track collection
91 if (s.tracks.isNotEmpty()) {
92 ActionRow(
93 onPlayAll = {
94 viewModel.playTracks(s.tracks, 0, onPlayed = onTrackPlaying)
95 },
96 onShuffle = {
97 viewModel.shuffleTracks(s.tracks, onPlayed = onTrackPlaying)
98 },
99 onEnqueue = { viewModel.enqueueTracks(s.tracks) },
100 onPlayNext = { viewModel.playNext(s.tracks) },
101 )
102 Spacer(Modifier.height(20.dp))
103 }
104
105 if (s.tracks.isEmpty()) {
106 Text("No tracks in this album.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
107 } else {
108 LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
109 items(s.tracks, key = { it.id }) { track ->
110 val index = s.tracks.indexOf(track)
111 TrackRow(
112 track = track,
113 albumArtUri = s.album?.artUri,
114 onClick = {
115 viewModel.playTracks(s.tracks, index, onPlayed = onTrackPlaying)
116 },
117 )
118 }
119 }
120 }
121 }
122 }
123 }
124}
125
126@Composable
127private fun ActionRow(
128 onPlayAll: () -> Unit,
129 onShuffle: () -> Unit,
130 onEnqueue: () -> Unit,
131 onPlayNext: () -> Unit,
132) {
133 Row(
134 Modifier
135 .fillMaxWidth()
136 .horizontalScroll(rememberScrollState()),
137 horizontalArrangement = Arrangement.spacedBy(12.dp),
138 ) {
139 ActionPill("▶ Play All", onPlayAll)
140 ActionPill("⇅ Shuffle", onShuffle)
141 ActionPill("↻ Play Next", onPlayNext)
142 ActionPill("+ Queue", onEnqueue)
143 }
144}
145
146@Composable
147private fun ActionPill(label: String, onClick: () -> Unit) {
148 val interaction = remember { MutableInteractionSource() }
149 Box(
150 Modifier
151 .background(SurfaceRaised)
152 .staveFocusable(interaction, cornerRadiusDp = 6)
153 .clickable(interaction, indication = null) { onClick() }
154 .padding(horizontal = 12.dp, vertical = 6.dp)
155 ) {
156 Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
157 }
158}
159
160@Composable
161private fun TrackRow(
162 track: LocalMediaScanner.LocalTrack,
163 albumArtUri: android.net.Uri?,
164 onClick: () -> Unit,
165) {
166 val interaction = remember { MutableInteractionSource() }
167 Box(
168 Modifier
169 .fillMaxWidth()
170 .background(SurfaceRaised)
171 .staveFocusable(interaction, cornerRadiusDp = 4)
172 .clickable(interaction, indication = null) { onClick() }
173 .padding(start = 2.dp, end = 8.dp, top = 4.dp, bottom = 2.dp)
174 ) {
175 if (isCompactWidth()) {
176 val numAndDur = buildList {
177 track.trackNumber?.takeIf { it > 0 }?.let { add("#$it") }
178 val mins = track.duration.milliseconds.inWholeMinutes
179 val secs = track.duration.milliseconds.inWholeSeconds % 60
180 add("%d:%02d".format(mins, secs))
181 }.joinToString(" \u2022 ")
182 Column {
183 Row(
184 Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min),
185 horizontalArrangement = Arrangement.spacedBy(12.dp),
186 ) {
187 AsyncImage(
188 model = track.artUri ?: albumArtUri,
189 contentDescription = null,
190 modifier = Modifier
191 .size(96.dp)
192 .clip(RoundedCornerShape(4.dp)),
193 )
194 Column(
195 Modifier.weight(1f).fillMaxHeight(),
196 verticalArrangement = Arrangement.SpaceEvenly,
197 ) {
198 Text(
199 withInterDigits(track.title),
200 style = MaterialTheme.typography.bodySmall,
201 color = OnPrimary,
202 maxLines = 2,
203 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
204 )
205 val albumLine = track.album?.takeIf { it.isNotBlank() }
206 if (albumLine != null) {
207 Text(
208 withInterDigits(albumLine),
209 style = MaterialTheme.typography.labelSmall,
210 color = OnMuted,
211 maxLines = 1,
212 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
213 )
214 }
215 val artistLine = track.artist?.takeIf { it.isNotBlank() }
216 if (artistLine != null) {
217 Text(
218 withInterDigits(artistLine),
219 style = MaterialTheme.typography.labelSmall,
220 color = OnMuted,
221 maxLines = 1,
222 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
223 )
224 }
225 }
226 }
227 Spacer(Modifier.height(4.dp))
228 Row {
229 if (numAndDur.isNotEmpty()) {
230 Text(
231 numAndDur,
232 style = MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily()),
233 color = OnMuted,
234 )
235 }
236 }
237 }
238 } else {
239 Row(verticalAlignment = Alignment.CenterVertically) {
240 AsyncImage(
241 model = track.artUri ?: albumArtUri,
242 contentDescription = null,
243 modifier = Modifier.size(40.dp),
244 )
245 Spacer(Modifier.size(12.dp))
246 Column(Modifier.weight(1f)) {
247 val numberedTitle = if (track.trackNumber != null) {
248 "${track.trackNumber}. ${track.title}"
249 } else track.title
250 Text(withInterDigits(numberedTitle), style = MaterialTheme.typography.titleSmall, color = OnPrimary)
251 if (!track.artist.isNullOrBlank()) {
252 Text(withInterDigits(track.artist), style = MaterialTheme.typography.bodySmall, color = OnMuted)
253 }
254 }
255 val mins = track.duration.milliseconds.inWholeMinutes
256 val secs = (track.duration.milliseconds.inWholeSeconds % 60)
257 Text(
258 "%d:%02d".format(mins, secs),
259 style = MaterialTheme.typography.bodySmall.copy(fontFamily = staveInterFamily()),
260 color = OnQuiet,
261 )
262 }
263 }
264 }
265}