Settings: Wiedergabe card + picker dialogs instead of chip walls

- 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 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 01:01:38 +02:00
parent 5de71193ad
commit 5b6cebd041
2 changed files with 168 additions and 135 deletions

View File

@@ -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

View File

@@ -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<String>,
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<Picker?>(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<String>,
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(