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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user