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

WelcomeScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.welcome
2
3import androidx.compose.foundation.Image
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.height
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.shape.RoundedCornerShape
18import androidx.compose.foundation.verticalScroll
19import androidx.compose.runtime.Composable
20import androidx.compose.runtime.LaunchedEffect
21import androidx.compose.runtime.remember
22import androidx.compose.ui.Alignment
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.focus.FocusRequester
25import androidx.compose.ui.focus.focusRequester
26import androidx.compose.ui.res.painterResource
27import androidx.compose.ui.unit.dp
28import androidx.tv.material3.MaterialTheme
29import androidx.tv.material3.Text
30import dev.stave.R
31import dev.stave.ui.components.staveFocusable
32import dev.stave.ui.theme.AccentAmber
33import dev.stave.ui.theme.OnAmber
34import dev.stave.ui.theme.OnMuted
35import dev.stave.ui.theme.OnPrimary
36import dev.stave.ui.theme.SurfaceRaised
37import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
38import dev.stave.ui.theme.adaptiveScreenPaddingVertical
39import dev.stave.ui.theme.isCompactHeight
40import dev.stave.ui.theme.isCompactWidth
41import dev.stave.ui.theme.withInterDigits
42
43/**
44 * Landing page when no UPnP server has been picked yet. Gives the user two clear paths
45 * forward — direct to picker (fastest for first-run) or via Settings (if they want to
46 * explore other options first). Without a focusable button here the screen is a dead
47 * end on TV where there's no cursor/touch to fall back on.
48 */
49@Composable
50fun WelcomeScreen(
51 onPickServer: () -> Unit,
52 onOpenSettings: () -> Unit,
53 // Maintainer, 2026-10-29: way into Cast/Observe from Welcome; button
54 // shown only when the caller wires it.
55 onOpenCast: (() -> Unit)? = null,
56 // Maintainer, 2026-10-29: false while startup is still deciding whether
57 // to auto-navigate a returning user to Browse, so Welcome doesn't flash.
58 ready: Boolean = true,
59) {
60 // Maintainer, 2026-10-29: not ready yet — render nothing; the NavHost's
61 // background shows through. Returning before the focus effect so it
62 // runs (and focuses the primary button) once the screen is ready.
63 if (!ready) return
64 val primaryFocus = remember { FocusRequester() }
65 // Auto-focus the primary action on open. Wrapped so a detached node doesn't crash.
66 LaunchedEffect(Unit) {
67 runCatching { primaryFocus.requestFocus() }
68 }
69
70 Box(
71 Modifier
72 .fillMaxSize()
73 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
74 contentAlignment = Alignment.Center,
75 ) {
76 // Maintainer, 2026-10-29: scrolls, and the badge shrinks on short
77 // screens — in phone landscape the column was taller than the
78 // screen and the buttons got zero height. TV (never compact
79 // height) keeps the 200dp badge and fits without scrolling.
80 Column(
81 modifier = Modifier.verticalScroll(rememberScrollState()),
82 horizontalAlignment = Alignment.CenterHorizontally,
83 verticalArrangement = Arrangement.spacedBy(12.dp),
84 ) {
85 Image(
86 painter = painterResource(id = R.drawable.earhorn_badge),
87 contentDescription = null,
88 modifier = Modifier.size(if (isCompactHeight()) 96.dp else 200.dp),
89 )
90 Spacer(Modifier.height(8.dp))
91 Text(
92 "Welcome to Earhorn",
93 style = MaterialTheme.typography.displaySmall,
94 color = OnPrimary,
95 )
96 // Maintainer, 2026-10-29: copy names all three ways in (server,
97 // this device, Cast) instead of only the server.
98 Text(
99 "Pick a UPnP/DLNA library on your network, play music stored on this device, or follow a Cast speaker.",
100 style = MaterialTheme.typography.titleMedium,
101 color = OnMuted,
102 )
103
104 Spacer(Modifier.height(24.dp))
105
106 // Maintainer, 2026-10-29: the Cast button sits in the button row
107 // where there is width for three; on phone-portrait widths the
108 // row is already nearly full, so it goes on its own line below.
109 val castInRow = !isCompactWidth()
110 Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
111 PrimaryButton(
112 label = "Find my server",
113 focusRequester = primaryFocus,
114 onClick = onPickServer,
115 )
116 SecondaryButton(
117 label = "Settings",
118 onClick = onOpenSettings,
119 )
120 if (onOpenCast != null && castInRow) {
121 SecondaryButton(
122 label = "Cast / Observe",
123 onClick = onOpenCast,
124 )
125 }
126 }
127 if (onOpenCast != null && !castInRow) {
128 SecondaryButton(
129 label = "Cast / Observe",
130 onClick = onOpenCast,
131 )
132 }
133 }
134 }
135}
136
137@Composable
138private fun PrimaryButton(
139 label: String,
140 focusRequester: FocusRequester,
141 onClick: () -> Unit,
142) {
143 val interaction = remember { MutableInteractionSource() }
144 val shape = RoundedCornerShape(6.dp)
145 Box(
146 Modifier
147 .background(AccentAmber, shape)
148 .focusRequester(focusRequester)
149 .staveFocusable(interaction, cornerRadiusDp = 6)
150 .clickable(interaction, indication = null) { onClick() }
151 .padding(horizontal = 28.dp, vertical = 14.dp),
152 contentAlignment = Alignment.Center,
153 ) {
154 Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnAmber)
155 }
156}
157
158@Composable
159private fun SecondaryButton(label: String, onClick: () -> Unit) {
160 val interaction = remember { MutableInteractionSource() }
161 val shape = RoundedCornerShape(6.dp)
162 Box(
163 Modifier
164 .background(SurfaceRaised, shape)
165 .staveFocusable(interaction, cornerRadiusDp = 6)
166 .clickable(interaction, indication = null) { onClick() }
167 .padding(horizontal = 28.dp, vertical = 14.dp),
168 contentAlignment = Alignment.Center,
169 ) {
170 Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
171 }
172}