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

LocalAlbumsScreen.kt

355 lines · 15.7 KB

Changed in: 0.5.0

1package dev.stave.ui.local
2
3// Maintainer, 2026-10-29: imports for LocalPermissionPrompt (permission request, app-settings
4// fallback, ON_RESUME refresh, TV focus).
5import android.app.Activity
6import android.content.Context
7import android.content.ContextWrapper
8import android.content.Intent
9import android.net.Uri
10import android.provider.Settings
11import androidx.activity.compose.rememberLauncherForActivityResult
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.compose.runtime.DisposableEffect
14import androidx.compose.runtime.rememberUpdatedState
15import androidx.compose.ui.focus.FocusRequester
16import androidx.compose.ui.focus.focusRequester
17import androidx.compose.ui.platform.LocalContext
18import androidx.core.app.ActivityCompat
19import androidx.lifecycle.Lifecycle
20import androidx.lifecycle.LifecycleEventObserver
21import androidx.lifecycle.LifecycleOwner
22import androidx.compose.foundation.background
23import androidx.compose.foundation.clickable
24import androidx.compose.foundation.interaction.MutableInteractionSource
25import androidx.compose.foundation.layout.Arrangement
26import androidx.compose.foundation.layout.Box
27import androidx.compose.foundation.layout.Column
28import androidx.compose.foundation.layout.Row
29import androidx.compose.foundation.layout.Spacer
30import androidx.compose.foundation.layout.aspectRatio
31import androidx.compose.foundation.layout.fillMaxSize
32import androidx.compose.foundation.layout.fillMaxWidth
33import androidx.compose.foundation.layout.height
34import androidx.compose.foundation.layout.padding
35import androidx.compose.foundation.layout.size
36import androidx.compose.foundation.lazy.LazyColumn
37import androidx.compose.foundation.lazy.grid.GridCells
38import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
39// Maintainer, 2026-10-29: indexed variants for collision-proof (index-qualified) keys.
40import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed
41import androidx.compose.foundation.lazy.itemsIndexed
42import androidx.compose.foundation.shape.RoundedCornerShape
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.collectAsState
46import androidx.compose.runtime.getValue
47import androidx.compose.runtime.mutableStateOf
48import androidx.compose.runtime.remember
49import androidx.compose.runtime.saveable.rememberSaveable
50import androidx.compose.runtime.setValue
51import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
53import androidx.compose.ui.draw.clip
54import androidx.compose.ui.text.style.TextAlign
55import androidx.compose.ui.text.style.TextOverflow
56import androidx.compose.ui.unit.dp
57import androidx.hilt.navigation.compose.hiltViewModel
58import androidx.tv.material3.MaterialTheme
59import androidx.tv.material3.Text
60import coil3.compose.AsyncImage
61import dev.stave.local.LocalMediaScanner
62import dev.stave.ui.components.AlbumArt
63import dev.stave.ui.components.staveFocusable
64import dev.stave.ui.theme.OnMuted
65import dev.stave.ui.theme.withInterDigits
66import dev.stave.ui.theme.OnPrimary
67import dev.stave.ui.theme.OnQuiet
68import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
69import dev.stave.ui.theme.adaptiveScreenPaddingVertical
70import dev.stave.ui.theme.SurfaceRaised
71
72/**
73 * List of all albums on the device. Tap a row to descend into the album's tracks
74 * via [LocalAlbumScreen]. Used both directly (from the LocalHub "Albums" tile) and
75 * indirectly (for an artist's albums via [LocalArtistScreen]).
76 */
77@Composable
78fun LocalAlbumsScreen(
79 title: String = "Albums",
80 onAlbumClick: (Long) -> Unit,
81 onBack: () -> Unit,
82 viewModel: LocalLibraryViewModel = hiltViewModel(),
83) {
84 val state by viewModel.albumsState.collectAsState()
85 var gridView by rememberSaveable { mutableStateOf(true) }
86 LaunchedEffect(Unit) { viewModel.loadAlbums() }
87
88 Column(
89 Modifier
90 .fillMaxSize()
91 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
92 ) {
93 Header(
94 title = title,
95 subtitle = "Tap an album to see its tracks",
96 onBack = onBack,
97 gridView = gridView,
98 onToggleGrid = { gridView = !gridView },
99 )
100 Spacer(Modifier.height(8.dp))
101
102 when (val s = state) {
103 is LocalLibraryViewModel.AlbumsState.Loading ->
104 Text("Scanning…", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
105 // Maintainer, 2026-10-29: was a bare "Permission required." with no way to grant.
106 is LocalLibraryViewModel.AlbumsState.PermissionRequired ->
107 LocalPermissionPrompt(viewModel.permissionName, onRefresh = { viewModel.loadAlbums() })
108 is LocalLibraryViewModel.AlbumsState.Ready -> {
109 if (s.albums.isEmpty()) {
110 Text("No albums on this device.", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
111 } else if (gridView) {
112 LocalAlbumGrid(albums = s.albums, onAlbumClick = { onAlbumClick(it.id) })
113 } else {
114 LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
115 // Maintainer, 2026-10-29: index-qualified key — two albums sharing an id
116 // crashed the LazyColumn with a duplicate key.
117 itemsIndexed(s.albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
118 AlbumRow(album = album, onClick = { onAlbumClick(album.id) })
119 }
120 }
121 }
122 }
123 }
124 }
125}
126
127@Composable
128internal fun AlbumRow(album: LocalMediaScanner.LocalAlbum, onClick: () -> Unit) {
129 val interaction = remember { MutableInteractionSource() }
130 Box(
131 Modifier
132 .fillMaxWidth()
133 .background(SurfaceRaised)
134 .staveFocusable(interaction, cornerRadiusDp = 4)
135 .clickable(interaction, indication = null) { onClick() }
136 .padding(horizontal = 16.dp, vertical = 12.dp)
137 ) {
138 Row(verticalAlignment = Alignment.CenterVertically) {
139 // Album art thumbnail (56dp). Coil falls back gracefully when the
140 // album-art content URI returns nothing — we just get an empty box.
141 AsyncImage(
142 model = album.artUri,
143 contentDescription = null,
144 modifier = Modifier.size(56.dp),
145 )
146 Spacer(Modifier.size(16.dp))
147 Column(Modifier.weight(1f)) {
148 Text(
149 withInterDigits(album.title),
150 style = MaterialTheme.typography.titleMedium,
151 color = OnPrimary,
152 )
153 val sub = listOfNotNull(
154 album.artist,
155 "${album.trackCount} track${if (album.trackCount == 1) "" else "s"}",
156 ).joinToString(" \u00b7 ")
157 Text(withInterDigits(sub), style = MaterialTheme.typography.bodySmall, color = OnMuted)
158 }
159 Text("\u25b8", style = MaterialTheme.typography.titleMedium, color = OnQuiet)
160 }
161 }
162}
163
164/** 2-column album art grid for LocalAlbum data. Same visual pattern as
165 * the BrowseScreen AlbumGrid but operates on LocalAlbum directly. */
166@Composable
167internal fun LocalAlbumGrid(
168 albums: List<LocalMediaScanner.LocalAlbum>,
169 onAlbumClick: (LocalMediaScanner.LocalAlbum) -> Unit,
170) {
171 LazyVerticalGrid(
172 columns = GridCells.Fixed(2),
173 modifier = Modifier.fillMaxSize(),
174 horizontalArrangement = Arrangement.spacedBy(12.dp),
175 verticalArrangement = Arrangement.spacedBy(12.dp),
176 ) {
177 // Maintainer, 2026-10-29: index-qualified key — two albums sharing an id crashed
178 // the grid with a duplicate key.
179 gridItemsIndexed(albums, key = { i, a -> "${i}_${a.id}" }) { _, album ->
180 // Maintainer, 2026-10-29: staveFocusable sharing the clickable's interaction source
181 // (as list rows do) — plain .clickable gave no focus ring on TV. Covers every
182 // LocalAlbumGrid user (Albums, Artist, Random Albums).
183 val interaction = remember { MutableInteractionSource() }
184 Column(
185 Modifier
186 .clip(RoundedCornerShape(6.dp))
187 .background(SurfaceRaised)
188 .staveFocusable(interaction, cornerRadiusDp = 6)
189 .clickable(interaction, indication = null) { onAlbumClick(album) }
190 .padding(bottom = 8.dp),
191 horizontalAlignment = Alignment.CenterHorizontally,
192 ) {
193 AlbumArt(
194 model = album.artUri,
195 modifier = Modifier
196 .fillMaxWidth()
197 .aspectRatio(1f)
198 .clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp)),
199 )
200 Spacer(Modifier.height(8.dp))
201 Text(
202 withInterDigits(album.title),
203 style = MaterialTheme.typography.bodyMedium,
204 color = OnPrimary,
205 maxLines = 2,
206 overflow = TextOverflow.Ellipsis,
207 textAlign = TextAlign.Center,
208 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
209 )
210 album.artist?.let {
211 Text(
212 withInterDigits(it),
213 style = MaterialTheme.typography.labelSmall,
214 color = OnMuted,
215 maxLines = 1,
216 overflow = TextOverflow.Ellipsis,
217 textAlign = TextAlign.Center,
218 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
219 )
220 }
221 }
222 }
223 }
224}
225
226// Maintainer, 2026-10-29: shared permission prompt for the Albums / Artists / Artist / Songs
227// screens, which showed "Permission required." with no way to grant. Mirrors LocalHubScreen:
228// system permission request first; after a permanent denial (the dialog no longer appears)
229// the button opens this app's settings page. [onRefresh] is the screen's own load call; it
230// also runs on ON_RESUME so a grant made in system Settings is picked up on return. The
231// button takes focus on entry so TV D-pad Center works without navigating.
232@Composable
233internal fun LocalPermissionPrompt(permissionName: String, onRefresh: () -> Unit) {
234 val context = LocalContext.current
235 val activity = remember(context) {
236 run {
237 var ctx: Context? = context
238 while (ctx is ContextWrapper) {
239 if (ctx is Activity) return@run ctx
240 ctx = ctx.baseContext
241 }
242 null
243 }
244 }
245 val currentOnRefresh by rememberUpdatedState(onRefresh)
246 var permanentlyDenied by rememberSaveable { mutableStateOf(false) }
247 val permissionLauncher = rememberLauncherForActivityResult(
248 ActivityResultContracts.RequestPermission()
249 ) { granted ->
250 permanentlyDenied = !granted && activity != null &&
251 !ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionName)
252 // Maintainer, 2026-10-29: no refresh here — ON_RESUME (below) fires right
253 // after the system dialog closes; refreshing in both ran the scan twice.
254 }
255 DisposableEffect(activity) {
256 val lifecycle = (activity as? LifecycleOwner)?.lifecycle
257 val observer = LifecycleEventObserver { _, event ->
258 if (event == Lifecycle.Event.ON_RESUME) currentOnRefresh()
259 }
260 lifecycle?.addObserver(observer)
261 onDispose { lifecycle?.removeObserver(observer) }
262 }
263 val buttonFocus = remember { FocusRequester() }
264 LaunchedEffect(Unit) { runCatching { buttonFocus.requestFocus() } }
265 val interaction = remember { MutableInteractionSource() }
266 Column {
267 Text(
268 "Permission required to read audio files on this device.",
269 style = MaterialTheme.typography.bodyLarge,
270 color = OnMuted,
271 )
272 Spacer(Modifier.height(16.dp))
273 Box(
274 Modifier
275 .focusRequester(buttonFocus)
276 .background(SurfaceRaised)
277 .staveFocusable(interaction, cornerRadiusDp = 6)
278 .clickable(interaction, indication = null) {
279 if (permanentlyDenied) {
280 runCatching {
281 val intent = Intent(
282 Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
283 Uri.fromParts("package", context.packageName, null),
284 )
285 if (activity == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
286 (activity ?: context).startActivity(intent)
287 }
288 } else {
289 permissionLauncher.launch(permissionName)
290 }
291 }
292 .padding(horizontal = 24.dp, vertical = 10.dp)
293 ) {
294 Text(
295 if (permanentlyDenied) "Open settings" else "Grant access",
296 style = MaterialTheme.typography.labelLarge,
297 color = OnPrimary,
298 )
299 }
300 }
301}
302
303@Composable
304internal fun Header(
305 title: String,
306 subtitle: String?,
307 onBack: () -> Unit,
308 gridView: Boolean = false,
309 onToggleGrid: (() -> Unit)? = null,
310) {
311 Row(
312 Modifier.fillMaxWidth(),
313 horizontalArrangement = Arrangement.SpaceBetween,
314 verticalAlignment = Alignment.Bottom,
315 ) {
316 Column(Modifier.weight(1f)) {
317 Text(withInterDigits(title), style = MaterialTheme.typography.displaySmall, color = OnPrimary)
318 if (subtitle != null) {
319 Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodyLarge, color = OnMuted)
320 }
321 }
322 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
323 if (onToggleGrid != null) {
324 val interaction = remember { MutableInteractionSource() }
325 Box(
326 Modifier
327 .background(SurfaceRaised)
328 .staveFocusable(interaction, cornerRadiusDp = 6)
329 .clickable(interaction, indication = null) { onToggleGrid() }
330 .padding(horizontal = 14.dp, vertical = 10.dp)
331 ) {
332 androidx.compose.material3.Icon(
333 painter = androidx.compose.ui.res.painterResource(
334 if (gridView) dev.stave.R.drawable.ic_view_list
335 else dev.stave.R.drawable.ic_view_grid
336 ),
337 contentDescription = if (gridView) "List view" else "Grid view",
338 tint = OnPrimary,
339 modifier = Modifier.size(20.dp),
340 )
341 }
342 }
343 val backInteraction = remember { MutableInteractionSource() }
344 Box(
345 Modifier
346 .background(SurfaceRaised)
347 .staveFocusable(backInteraction, cornerRadiusDp = 6)
348 .clickable(backInteraction, indication = null) { onBack() }
349 .padding(horizontal = 20.dp, vertical = 10.dp)
350 ) {
351 Text("Back", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
352 }
353 }
354 }
355}