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

AboutScreen.kt

260 lines · 10.9 KB

Changed in: 0.5.0

1package dev.stave.ui.about
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.Image
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
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.aspectRatio
13import androidx.compose.foundation.layout.fillMaxHeight
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.height
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.sizeIn
20import androidx.compose.foundation.rememberScrollState
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.remember
24import androidx.compose.ui.Alignment
25import androidx.compose.ui.Modifier
26import androidx.compose.ui.res.painterResource
27import androidx.compose.ui.text.style.TextAlign
28import androidx.compose.ui.unit.dp
29import androidx.tv.material3.MaterialTheme
30import androidx.tv.material3.Text
31import dev.stave.R
32import dev.stave.ui.components.staveFocusable
33import dev.stave.ui.theme.AccentAmber
34import dev.stave.ui.theme.OnAmber
35import dev.stave.ui.theme.OnMuted
36import dev.stave.ui.theme.OnPrimary
37import dev.stave.ui.theme.OnQuiet
38import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
39import dev.stave.ui.theme.adaptiveScreenPaddingVertical
40import dev.stave.ui.theme.isCompactWidth
41import dev.stave.ui.theme.withInterDigits
42
43/**
44 * About screen with adaptive layout:
45 * - **Android TV** (uiMode == UI_MODE_TYPE_TELEVISION): the original
46 * side-by-side AboutExpanded layout, byte-identical to the pre-phone-port
47 * baseline.
48 * - **Phone landscape** (orientation == LANDSCAPE, not TV): horizontal
49 * AboutExpanded layout — large badge on the left, info on the right —
50 * same shape as the pre-phone-port About page used to have. The vertical
51 * AboutCompact layout looked correct in portrait but felt cramped in
52 * landscape where the wide-and-short viewport begs for a horizontal split.
53 * - **Phone portrait** (anything else): vertical AboutCompact layout with a
54 * larger badge sized to the available shorter dimension.
55 *
56 * Tablets in landscape get the AboutExpanded treatment too; tablets in
57 * portrait fall through to AboutCompact. If a tablet-specific layout is
58 * needed later we can split that out.
59 */
60@Composable
61fun AboutScreen(onBack: () -> Unit) {
62 BackHandler(onBack = onBack)
63
64 val configuration = androidx.compose.ui.platform.LocalConfiguration.current
65 val isAndroidTv =
66 (configuration.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
67 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
68 val isLandscape = configuration.orientation ==
69 android.content.res.Configuration.ORIENTATION_LANDSCAPE
70 if (isAndroidTv || isLandscape) {
71 AboutExpanded()
72 } else {
73 AboutCompact()
74 }
75}
76
77@Composable
78private fun AboutCompact() {
79 val config = androidx.compose.ui.platform.LocalConfiguration.current
80 // Badge at "full resolution" — fills the smaller screen dimension up
81 // to a generous fraction so the source's 512x512 detail isn't down-
82 // scaled. Portrait phones get ~80% width (so on a 411dp-wide Pixel 8a
83 // that's 329dp, rendered at 2.7x density ≈ 888px — slightly upscaled
84 // from the 512px source, comfortable for the generous brand mark).
85 // Height-limited paths (landscape phone routed to compact, short
86 // tablets) clamp to 65% of the height so it doesn't push the rest
87 // of the column off the screen. No hard dp cap — the user wants the
88 // badge to dominate the page, not sit in a tidy 320dp circle.
89 val badgeSize = minOf(
90 config.screenWidthDp.dp * 0.80f,
91 config.screenHeightDp.dp * 0.65f,
92 )
93 Column(
94 Modifier
95 .fillMaxSize()
96 .padding(
97 horizontal = adaptiveScreenPaddingHorizontal(),
98 vertical = adaptiveScreenPaddingVertical(),
99 )
100 .verticalScroll(rememberScrollState()),
101 horizontalAlignment = Alignment.CenterHorizontally,
102 ) {
103 Image(
104 painter = painterResource(R.drawable.earhorn_badge),
105 contentDescription = null,
106 modifier = Modifier.size(badgeSize),
107 )
108 Spacer(Modifier.height(20.dp))
109
110 Text(
111 "Earhorn Music",
112 style = MaterialTheme.typography.headlineMedium,
113 color = OnPrimary,
114 textAlign = TextAlign.Center,
115 )
116 Spacer(Modifier.height(4.dp))
117 Text(
118 "A UPnP music player for Android TV, phones, GNU/Linux, and Android Auto",
119 style = MaterialTheme.typography.bodyMedium,
120 color = OnMuted,
121 textAlign = TextAlign.Center,
122 modifier = Modifier.fillMaxWidth(),
123 )
124 Spacer(Modifier.height(20.dp))
125 Text(
126 withInterDigits(appVersionLabel()),
127 style = MaterialTheme.typography.labelLarge,
128 color = AccentAmber,
129 )
130 Spacer(Modifier.height(24.dp))
131 Text(
132 "A Faveat Software Project",
133 style = MaterialTheme.typography.bodyMedium,
134 color = OnQuiet,
135 )
136 Spacer(Modifier.height(20.dp))
137 val context = androidx.compose.ui.platform.LocalContext.current
138 val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
139 Box(
140 Modifier
141 .background(AccentAmber, androidx.compose.foundation.shape.RoundedCornerShape(50))
142 .staveFocusable(interaction, cornerRadiusDp = 50)
143 .clickable(interaction, indication = null) {
144 runCatching {
145 context.startActivity(
146 android.content.Intent(
147 android.content.Intent.ACTION_VIEW,
148 android.net.Uri.parse("https://ko-fi.com/faveat"),
149 ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
150 )
151 }
152 }
153 .padding(horizontal = 28.dp, vertical = 12.dp),
154 contentAlignment = Alignment.Center,
155 ) {
156 Text("Say Thanks ☕", style = MaterialTheme.typography.titleSmall, color = OnAmber)
157 }
158 }
159}
160
161@Composable
162private fun AboutExpanded() {
163 Row(
164 Modifier
165 .fillMaxSize()
166 .padding(
167 horizontal = adaptiveScreenPaddingHorizontal(),
168 vertical = adaptiveScreenPaddingVertical(),
169 ),
170 horizontalArrangement = Arrangement.spacedBy(48.dp),
171 verticalAlignment = Alignment.CenterVertically,
172 ) {
173 // Left: badge — fills 85% of available height as a square so its
174 // visual presence matches the portrait (Compact) layout's badge.
175 // Earlier value was 0.5f which produced a ~200dp badge in landscape
176 // vs ~329dp in portrait — the disparity made the brand mark feel
177 // diminished in landscape. The text Column to the right has
178 // weight(1f) so it still claims all remaining horizontal width;
179 // there's enough room on a landscape phone (~800–900dp wide) even
180 // with the badge consuming ~350dp.
181 Image(
182 painter = painterResource(R.drawable.earhorn_badge),
183 contentDescription = null,
184 modifier = Modifier
185 .fillMaxHeight(0.85f)
186 .aspectRatio(1f),
187 )
188
189 // Right: text details, vertically centered.
190 Column(
191 Modifier.weight(1f),
192 horizontalAlignment = Alignment.Start,
193 verticalArrangement = Arrangement.Center,
194 ) {
195 Text(
196 "Earhorn Music",
197 style = MaterialTheme.typography.displayLarge,
198 color = OnPrimary,
199 )
200 Spacer(Modifier.height(4.dp))
201 Text(
202 "A UPnP music player for Android TV",
203 style = MaterialTheme.typography.titleMedium,
204 color = OnMuted,
205 )
206 Spacer(Modifier.height(24.dp))
207 Text(
208 withInterDigits(appVersionLabel()),
209 style = MaterialTheme.typography.labelLarge,
210 color = AccentAmber,
211 )
212 Spacer(Modifier.height(28.dp))
213 Text(
214 "A Faveat Software Project",
215 style = MaterialTheme.typography.bodyMedium,
216 color = OnQuiet,
217 )
218 Spacer(Modifier.height(24.dp))
219 val context = androidx.compose.ui.platform.LocalContext.current
220 val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
221 Box(
222 Modifier
223 .background(AccentAmber, androidx.compose.foundation.shape.RoundedCornerShape(50))
224 .staveFocusable(interaction, cornerRadiusDp = 50)
225 .clickable(interaction, indication = null) {
226 runCatching {
227 context.startActivity(
228 android.content.Intent(
229 android.content.Intent.ACTION_VIEW,
230 android.net.Uri.parse("https://ko-fi.com/faveat"),
231 ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
232 )
233 }
234 }
235 .padding(horizontal = 28.dp, vertical = 12.dp),
236 contentAlignment = Alignment.Center,
237 ) {
238 Text("Say Thanks ☕", style = MaterialTheme.typography.titleSmall, color = OnAmber)
239 }
240 }
241 }
242}
243
244/** The app's real version, read from the installed package at runtime.
245 * Derived — never hardcoded — so version bumps in build.gradle.kts
246 * propagate here automatically. (The previous hardcoded "Version 0.0.1"
247 * survived at least one real release unchanged.) Includes the flavor's
248 * versionNameSuffix, so libre builds truthfully show e.g. "0.1.1-libre".
249 */
250@Composable
251private fun appVersionLabel(): String {
252 val context = androidx.compose.ui.platform.LocalContext.current
253 return remember {
254 runCatching {
255 "Version " + context.packageManager
256 .getPackageInfo(context.packageName, 0)
257 .versionName
258 }.getOrDefault("Version ?")
259 }
260}