From 5b6cebd041e3a425d99a0f300d029d77e4c11e07 Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 01:01:38 +0200 Subject: [PATCH] Settings: Wiedergabe card + picker dialogs instead of chip walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quelle and Stream-Qualität move out of Konto into their own Wiedergabe card; Konto is account + Abmelden only. - Choices open a TV-style picker dialog (focus starts on the current value, Back closes) with the current value + drawn caret shown at the row's trailing edge. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 4 +- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 299 ++++++++++-------- 2 files changed, 168 insertions(+), 135 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 243f51b..08c142b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 15 - versionName = "0.7.2" + versionCode = 16 + versionName = "0.8.0" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index 68ef0ec..cd3b5f3 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -2,9 +2,9 @@ package dev.castarr.tv.ui import android.graphics.Bitmap import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,17 +29,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface @@ -50,11 +50,18 @@ import dev.castarr.tv.pairing.Qr import dev.castarr.tv.update.UpdateChecker import kotlinx.coroutines.launch +/** One open picker dialog: which setting, its options, what happens on pick. */ +private data class Picker( + val title: String, + val options: List, + val selected: Int, + val onPick: (Int) -> Unit, +) + /** - * Google-TV-style settings: cards with full-width focusable rows, segmented - * controls switched with left/right inside the row. Ten-Foot-Regel (#14): - * one screen, no text fields, no scrolling — text-heavy input lives in the - * Erweitert screen. + * Google-TV-style settings: cards with full-width focusable rows; choices + * open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung). + * Ten-Foot-Regel (#14): no text fields, no scrolling. */ @Composable fun SettingsScreen(state: AppState) { @@ -62,6 +69,7 @@ fun SettingsScreen(state: AppState) { val scope = rememberCoroutineScope() val profiles by state.dispatcharr.profiles.collectAsState() var updateStatus by remember { mutableStateOf("") } + var picker by remember { mutableStateOf(null) } Row( Modifier @@ -102,32 +110,6 @@ fun SettingsScreen(state: AppState) { ) } } - if (state.source.m3uUrl.isNotEmpty()) { - val modes = listOf("Dispatcharr", "Eigene M3U") - val selected = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1 - SegmentedRow("Quelle", "Woher die Senderliste kommt", modes, selected) { - state.setMode( - if (it == 0) AppState.SourceMode.DISPATCHARR - else AppState.SourceMode.GENERIC - ) - } - } - val qualities = listOf("Standard", "Original") + profiles - val qualityIndex = when (state.outputProfile) { - "" -> 0 - "raw" -> 1 - else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0) - } - SegmentedRow( - "Stream-Qualität", "Standard remuxt nur den Ton", - qualities, qualityIndex, - ) { - state.outputProfile = when (it) { - 0 -> "" - 1 -> "raw" - else -> profiles.getOrElse(it - 2) { "" } - } - } SettingRow("Abmelden", danger = true) { state.startOnboarding() } } else { Text( @@ -139,6 +121,44 @@ fun SettingsScreen(state: AppState) { } } + SettingsCard("Wiedergabe") { + if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) { + val modes = listOf("Dispatcharr", "Eigene M3U") + val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1 + SettingRow( + "Quelle", + subtitle = "Woher die Senderliste kommt", + trailing = { ValueWithCaret(modes[modeIndex]) }, + ) { + picker = Picker("Quelle", modes, modeIndex) { index -> + state.setMode( + if (index == 0) AppState.SourceMode.DISPATCHARR + else AppState.SourceMode.GENERIC + ) + } + } + } + val qualities = listOf("Standard", "Original") + profiles + val qualityIndex = when (state.outputProfile) { + "" -> 0 + "raw" -> 1 + else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0) + } + SettingRow( + "Stream-Qualität", + subtitle = "Standard remuxt nur den Ton", + trailing = { ValueWithCaret(qualities[qualityIndex]) }, + ) { + picker = Picker("Stream-Qualität", qualities, qualityIndex) { index -> + state.outputProfile = when (index) { + 0 -> "" + 1 -> "raw" + else -> profiles.getOrElse(index - 2) { "" } + } + } + } + } + SettingsCard("App") { SettingRow( "Version ${BuildConfig.VERSION_NAME}", @@ -172,9 +192,10 @@ fun SettingsScreen(state: AppState) { if (state.updateAvailable != null) { SettingRow("Update installieren") { scope.launch { + updateStatus = "Update wird heruntergeladen…" updateStatus = UpdateChecker.downloadAndInstall(context, state) - ?: "Update wird geöffnet…" + ?: "Installation wird geöffnet…" } } } @@ -224,6 +245,113 @@ fun SettingsScreen(state: AppState) { } } } + + picker?.let { current -> + PickerDialog(current) { picker = null } + } +} + +/** TV-friendly dropdown replacement: a centered dialog listing the options. */ +@Composable +private fun PickerDialog(picker: Picker, onClose: () -> Unit) { + val selectedFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { selectedFocus.requestFocus() } + Dialog(onDismissRequest = onClose) { + Column( + Modifier + .width(360.dp) + .clip(RoundedCornerShape(16.dp)) + .background(CastarrColors.surface) + .padding(horizontal = 10.dp, vertical = 14.dp) + ) { + Text( + picker.title.uppercase(), + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), + ) + picker.options.forEachIndexed { index, option -> + val selected = index == picker.selected + Surface( + onClick = { + picker.onPick(index) + onClose() + }, + modifier = Modifier + .fillMaxWidth() + .then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier), + shape = ClickableSurfaceDefaults.shape(rowShape), + colors = ClickableSurfaceDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + contentColor = CastarrColors.fg, + focusedContainerColor = CastarrColors.surfaceFocused, + focusedContentColor = CastarrColors.fg, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, CastarrColors.accent), + shape = rowShape, + ), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + option, + color = if (selected) CastarrColors.accent else CastarrColors.fg, + fontFamily = AppFont, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (selected) { + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(CastarrColors.accent) + ) + } + } + } + } + } + } +} + +/** Current value plus a small drawn caret, shown at a row's trailing edge. */ +@Composable +private fun ValueWithCaret(value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + value, + color = CastarrColors.muted, + fontFamily = AppFont, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + val color = CastarrColors.faint + Canvas(Modifier.size(9.dp)) { + drawPath( + Path().apply { + moveTo(0f, size.height * 0.3f) + lineTo(size.width, size.height * 0.3f) + lineTo(size.width / 2f, size.height * 0.75f) + close() + }, + color, + ) + } + } } @Composable @@ -257,19 +385,11 @@ private fun SettingRow( subtitle: String? = null, danger: Boolean = false, trailing: (@Composable () -> Unit)? = null, - keyHandler: ((Key) -> Boolean)? = null, onClick: () -> Unit, ) { Surface( onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .let { m -> - if (keyHandler == null) m - else m.onPreviewKeyEvent { event -> - event.type == KeyEventType.KeyDown && keyHandler(event.key) - } - }, + modifier = Modifier.fillMaxWidth(), shape = ClickableSurfaceDefaults.shape(rowShape), colors = ClickableSurfaceDefaults.colors( containerColor = androidx.compose.ui.graphics.Color.Transparent, @@ -316,93 +436,6 @@ private fun SettingRow( } } -/** Row whose value is a set of option chips below the label; left/right - * switches in place, chips wrap when names are long. */ -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -@Composable -private fun SegmentedRow( - label: String, - subtitle: String, - options: List, - selected: Int, - onChange: (Int) -> Unit, -) { - Surface( - onClick = { onChange((selected + 1) % options.size) }, - modifier = Modifier - .fillMaxWidth() - .onPreviewKeyEvent { event -> - event.type == KeyEventType.KeyDown && when { - event.key == Key.DirectionLeft && selected > 0 -> { - onChange(selected - 1); true - } - event.key == Key.DirectionRight && selected < options.lastIndex -> { - onChange(selected + 1); true - } - else -> false - } - }, - shape = ClickableSurfaceDefaults.shape(rowShape), - colors = ClickableSurfaceDefaults.colors( - containerColor = androidx.compose.ui.graphics.Color.Transparent, - contentColor = CastarrColors.fg, - focusedContainerColor = CastarrColors.surfaceFocused, - focusedContentColor = CastarrColors.fg, - ), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, CastarrColors.accent), - shape = rowShape, - ), - ), - ) { - Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { - Text( - label, - color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp, - ) - Text( - subtitle, - color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, - ) - Spacer(Modifier.height(8.dp)) - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - options.forEachIndexed { index, option -> - Box( - Modifier - .clip(RoundedCornerShape(999.dp)) - .background( - if (index == selected) CastarrColors.accent - else CastarrColors.bg - ) - .border( - 1.dp, - if (index == selected) CastarrColors.accent else CastarrColors.line, - RoundedCornerShape(999.dp), - ) - .padding(horizontal = 12.dp, vertical = 5.dp) - ) { - Text( - option, - color = if (index == selected) CastarrColors.onAccent - else CastarrColors.muted, - fontFamily = AppFont, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = if (index == selected) FontWeight.SemiBold - else FontWeight.Normal, - ) - } - } - } - } - } -} - @Composable fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) { Surface(