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

LocalHubScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.local
2
3// Maintainer, 2026-10-29: imports for the permanent-denial → app settings path and the
4// ON_RESUME permission re-check.
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.compose.runtime.DisposableEffect
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.saveable.rememberSaveable
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.platform.LocalContext
16import androidx.core.app.ActivityCompat
17import androidx.lifecycle.Lifecycle
18import androidx.lifecycle.LifecycleEventObserver
19import androidx.lifecycle.LifecycleOwner
20import androidx.activity.compose.rememberLauncherForActivityResult
21import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.padding
34import androidx.compose.foundation.layout.width
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.collectAsState
38import androidx.compose.runtime.getValue
39import androidx.compose.runtime.remember
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.unit.dp
43import androidx.hilt.navigation.compose.hiltViewModel
44import androidx.tv.material3.MaterialTheme
45import androidx.tv.material3.Text
46import dev.stave.ui.components.staveFocusable
47import dev.stave.ui.theme.OnMuted
48import dev.stave.ui.theme.withInterDigits
49import dev.stave.ui.theme.OnPrimary
50import dev.stave.ui.theme.OnQuiet
51import dev.stave.ui.theme.SurfaceRaised
52import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
53import dev.stave.ui.theme.adaptiveScreenPaddingVertical
54import dev.stave.ui.theme.isCompactHeight
55import dev.stave.ui.theme.isCompactWidth
56// Maintainer, 2026-10-29: HubContent vertical scroll.
57import androidx.compose.foundation.rememberScrollState
58import androidx.compose.foundation.verticalScroll
59
60/**
61 * Hub screen for on-device ("This Device") music. Sits at the entry point of the
62 * local-music browse tree: shows summary counts, exposes a "Shuffle All" action, and
63 * provides tiles to descend into Albums / Artists / Songs.
64 *
65 * Permission gating: if READ_MEDIA_AUDIO (or READ_EXTERNAL_STORAGE on API <33) is
66 * not yet granted, the screen renders a permission-request UI instead of the hub.
67 * Once granted, [LocalLibraryViewModel.refreshPermissionState] reloads the summary.
68 */
69@Composable
70fun LocalHubScreen(
71 onOpenAlbums: () -> Unit,
72 onOpenArtists: () -> Unit,
73 onOpenSongs: () -> Unit,
74 onBack: () -> Unit,
75 viewModel: LocalLibraryViewModel = hiltViewModel(),
76) {
77 val state by viewModel.hubState.collectAsState()
78
79 // Maintainer, 2026-10-29: hosting Activity, unwrapped from LocalContext the same way
80 // StaveNavHost does — needed for shouldShowRequestPermissionRationale and lifecycle.
81 val context = LocalContext.current
82 val activity = remember(context) {
83 run {
84 var ctx: Context? = context
85 while (ctx is ContextWrapper) {
86 if (ctx is Activity) return@run ctx
87 ctx = ctx.baseContext
88 }
89 null
90 }
91 }
92 // Maintainer, 2026-10-29: after a permanent denial the system dialog never shows again,
93 // so "Grant permission" did nothing. Track it; the button then opens app settings.
94 var permanentlyDenied by rememberSaveable { mutableStateOf(false) }
95
96 val permissionLauncher = rememberLauncherForActivityResult(
97 ActivityResultContracts.RequestPermission()
98 ) { granted ->
99 // Maintainer, 2026-10-29: denied + no rationale = permanently denied.
100 permanentlyDenied = !granted && activity != null &&
101 !ActivityCompat.shouldShowRequestPermissionRationale(activity, viewModel.permissionName)
102 viewModel.refreshPermissionState()
103 }
104
105 // Refresh permission state on each recomposition initiated by navigation —
106 // covers the case where the user grants permission via system Settings and
107 // returns to the app rather than tapping our in-app request button.
108 LaunchedEffect(Unit) { viewModel.refreshPermissionState() }
109
110 // Maintainer, 2026-10-29: re-check on every ON_RESUME too, so a grant (or revoke) made
111 // in system Settings is noticed when the user comes back, without re-entering the
112 // screen. Only rescans when the state actually needs it (no full rescan per resume).
113 DisposableEffect(activity) {
114 val lifecycle = (activity as? LifecycleOwner)?.lifecycle
115 val observer = LifecycleEventObserver { _, event ->
116 if (event == Lifecycle.Event.ON_RESUME && (
117 !viewModel.hasPermission() ||
118 viewModel.hubState.value is LocalLibraryViewModel.HubState.PermissionRequired
119 )
120 ) {
121 viewModel.refreshPermissionState()
122 }
123 }
124 lifecycle?.addObserver(observer)
125 onDispose { lifecycle?.removeObserver(observer) }
126 }
127
128 Column(
129 Modifier
130 .fillMaxSize()
131 .padding(
132 horizontal = adaptiveScreenPaddingHorizontal(),
133 vertical = adaptiveScreenPaddingVertical(),
134 )
135 ) {
136 // Header
137 if (isCompactWidth()) {
138 Row(
139 Modifier.fillMaxWidth(),
140 horizontalArrangement = Arrangement.SpaceBetween,
141 verticalAlignment = Alignment.CenterVertically,
142 ) {
143 Column(Modifier.weight(1f)) {
144 Text(
145 "On this device",
146 style = MaterialTheme.typography.headlineMedium,
147 color = OnPrimary
148 )
149 Text(
150 "Music files stored locally",
151 style = MaterialTheme.typography.bodyMedium,
152 color = OnMuted
153 )
154 }
155 HubAction(label = "Back", onClick = onBack)
156 }
157 } else {
158 Row(
159 Modifier.fillMaxWidth(),
160 horizontalArrangement = Arrangement.SpaceBetween,
161 verticalAlignment = Alignment.Bottom
162 ) {
163 Column(Modifier.weight(1f)) {
164 Text(
165 "On this device",
166 style = MaterialTheme.typography.displayMedium,
167 color = OnPrimary
168 )
169 Text(
170 "Music files stored locally on this device",
171 style = MaterialTheme.typography.bodyLarge,
172 color = OnMuted
173 )
174 }
175 HubAction(label = "Back", onClick = onBack)
176 }
177 }
178
179 // Maintainer, 2026-10-29: compact 24dp spacing on compact height too (phone landscape).
180 Spacer(Modifier.height(if (isCompactWidth() || isCompactHeight()) 24.dp else 40.dp))
181
182 when (val s = state) {
183 is LocalLibraryViewModel.HubState.PermissionRequired -> {
184 // Maintainer, 2026-10-29: permanently denied → open this app's system
185 // settings page instead of a launcher call that silently does nothing.
186 PermissionRequestState(
187 buttonLabel = if (permanentlyDenied) "Open settings" else "Grant permission",
188 onRequest = {
189 if (permanentlyDenied) {
190 runCatching {
191 val intent = Intent(
192 Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
193 Uri.fromParts("package", context.packageName, null),
194 )
195 if (activity == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
196 (activity ?: context).startActivity(intent)
197 }
198 } else {
199 permissionLauncher.launch(viewModel.permissionName)
200 }
201 }
202 )
203 }
204 is LocalLibraryViewModel.HubState.Loading -> {
205 Text("Scanning…", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
206 }
207 is LocalLibraryViewModel.HubState.Ready -> {
208 if (s.songCount == 0) {
209 EmptyLocalState()
210 } else {
211 HubContent(
212 albumCount = s.albumCount,
213 artistCount = s.artistCount,
214 songCount = s.songCount,
215 onShuffleAll = { viewModel.shuffleAll() },
216 onOpenAlbums = onOpenAlbums,
217 onOpenArtists = onOpenArtists,
218 onOpenSongs = onOpenSongs,
219 )
220 }
221 }
222 }
223 }
224}
225
226@Composable
227private fun HubContent(
228 albumCount: Int,
229 artistCount: Int,
230 songCount: Int,
231 onShuffleAll: () -> Unit,
232 onOpenAlbums: () -> Unit,
233 onOpenArtists: () -> Unit,
234 onOpenSongs: () -> Unit,
235) {
236 // Maintainer, 2026-10-29: scrolls so the Artists/Songs tiles are reachable on phone
237 // landscape (~245dp usable); no visible change where everything already fits (TV).
238 Column(
239 Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
240 verticalArrangement = Arrangement.spacedBy(16.dp),
241 ) {
242 // Primary action — Shuffle All. Most common car-use pattern.
243 PrimaryAction(
244 label = "Shuffle All",
245 subtitle = "Play every song in random order",
246 onClick = onShuffleAll,
247 )
248
249 Spacer(Modifier.height(8.dp))
250
251 HubTile(
252 title = "Albums",
253 subtitle = "$albumCount album${if (albumCount == 1) "" else "s"}",
254 onClick = onOpenAlbums,
255 )
256 HubTile(
257 title = "Artists",
258 subtitle = "$artistCount artist${if (artistCount == 1) "" else "s"}",
259 onClick = onOpenArtists,
260 )
261 HubTile(
262 title = "Songs",
263 subtitle = "$songCount track${if (songCount == 1) "" else "s"}",
264 onClick = onOpenSongs,
265 )
266 }
267}
268
269@Composable
270private fun PrimaryAction(label: String, subtitle: String, onClick: () -> Unit) {
271 val interaction = remember { MutableInteractionSource() }
272 Box(
273 Modifier
274 .fillMaxWidth()
275 .background(SurfaceRaised)
276 .staveFocusable(interaction, cornerRadiusDp = 6)
277 .clickable(interaction, indication = null) { onClick() }
278 .padding(horizontal = 12.dp, vertical = 10.dp)
279 ) {
280 Row(verticalAlignment = Alignment.CenterVertically) {
281 Column(Modifier.weight(1f)) {
282 Text(withInterDigits(label), style = MaterialTheme.typography.titleLarge, color = OnPrimary)
283 Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodyMedium, color = OnMuted)
284 }
285 Text("\u25b8", style = MaterialTheme.typography.titleLarge, color = OnQuiet)
286 }
287 }
288}
289
290@Composable
291private fun HubTile(title: String, subtitle: String, onClick: () -> Unit) {
292 val interaction = remember { MutableInteractionSource() }
293 Box(
294 Modifier
295 .fillMaxWidth()
296 .background(SurfaceRaised)
297 .staveFocusable(interaction, cornerRadiusDp = 6)
298 .clickable(interaction, indication = null) { onClick() }
299 .padding(horizontal = 12.dp, vertical = 8.dp)
300 ) {
301 Row(verticalAlignment = Alignment.CenterVertically) {
302 Column(Modifier.weight(1f)) {
303 Text(withInterDigits(title), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
304 Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted)
305 }
306 Text("Open \u25b8", style = MaterialTheme.typography.labelMedium, color = OnQuiet)
307 }
308 }
309}
310
311@Composable
312private fun HubAction(label: String, onClick: () -> Unit) {
313 val interaction = remember { MutableInteractionSource() }
314 Box(
315 Modifier
316 .background(SurfaceRaised)
317 .staveFocusable(interaction, cornerRadiusDp = 6)
318 .clickable(interaction, indication = null) { onClick() }
319 .padding(horizontal = 20.dp, vertical = 10.dp)
320 ) {
321 Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
322 }
323}
324
325@Composable
326// Maintainer, 2026-10-29: buttonLabel added ("Grant permission" / "Open settings").
327private fun PermissionRequestState(buttonLabel: String, onRequest: () -> Unit) {
328 Column(
329 Modifier.fillMaxWidth().padding(vertical = 40.dp),
330 horizontalAlignment = Alignment.CenterHorizontally
331 ) {
332 Text("Permission needed", style = MaterialTheme.typography.headlineMedium, color = OnPrimary)
333 Spacer(Modifier.height(12.dp))
334 Text(
335 "Earhorn needs permission to read audio files on this device " +
336 "to show your local music.",
337 style = MaterialTheme.typography.bodyLarge,
338 color = OnMuted,
339 )
340 Spacer(Modifier.height(24.dp))
341 val interaction = remember { MutableInteractionSource() }
342 Box(
343 Modifier
344 .background(SurfaceRaised)
345 .staveFocusable(interaction, cornerRadiusDp = 6)
346 .clickable(interaction, indication = null) { onRequest() }
347 .padding(horizontal = 32.dp, vertical = 14.dp)
348 ) {
349 Text(buttonLabel, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
350 }
351 }
352}
353
354@Composable
355private fun EmptyLocalState() {
356 Column(
357 Modifier.fillMaxWidth().padding(vertical = 40.dp),
358 horizontalAlignment = Alignment.CenterHorizontally
359 ) {
360 Text("No music found", style = MaterialTheme.typography.headlineMedium, color = OnPrimary)
361 Spacer(Modifier.height(12.dp))
362 Text(
363 "There are no audio files on this device. Connect a UPnP server " +
364 "to play music from your library, or copy audio files to this device's storage.",
365 style = MaterialTheme.typography.bodyLarge,
366 color = OnMuted,
367 )
368 }
369}