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"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 15
|
versionCode = 16
|
||||||
versionName = "0.7.2"
|
versionName = "0.8.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// 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 android.graphics.Bitmap
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,17 +29,16 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.tv.material3.Border
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
@@ -50,11 +50,18 @@ import dev.castarr.tv.pairing.Qr
|
|||||||
import dev.castarr.tv.update.UpdateChecker
|
import dev.castarr.tv.update.UpdateChecker
|
||||||
import kotlinx.coroutines.launch
|
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
|
* Google-TV-style settings: cards with full-width focusable rows; choices
|
||||||
* controls switched with left/right inside the row. Ten-Foot-Regel (#14):
|
* open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
|
||||||
* one screen, no text fields, no scrolling — text-heavy input lives in the
|
* Ten-Foot-Regel (#14): no text fields, no scrolling.
|
||||||
* Erweitert screen.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(state: AppState) {
|
fun SettingsScreen(state: AppState) {
|
||||||
@@ -62,6 +69,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val profiles by state.dispatcharr.profiles.collectAsState()
|
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||||
var updateStatus by remember { mutableStateOf("") }
|
var updateStatus by remember { mutableStateOf("") }
|
||||||
|
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
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() }
|
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
|
||||||
} else {
|
} else {
|
||||||
Text(
|
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") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
@@ -172,9 +192,10 @@ fun SettingsScreen(state: AppState) {
|
|||||||
if (state.updateAvailable != null) {
|
if (state.updateAvailable != null) {
|
||||||
SettingRow("Update installieren") {
|
SettingRow("Update installieren") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
updateStatus = "Update wird heruntergeladen…"
|
||||||
updateStatus =
|
updateStatus =
|
||||||
UpdateChecker.downloadAndInstall(context, state)
|
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
|
@Composable
|
||||||
@@ -257,19 +385,11 @@ private fun SettingRow(
|
|||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
danger: Boolean = false,
|
danger: Boolean = false,
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
keyHandler: ((Key) -> Boolean)? = null,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.let { m ->
|
|
||||||
if (keyHandler == null) m
|
|
||||||
else m.onPreviewKeyEvent { event ->
|
|
||||||
event.type == KeyEventType.KeyDown && keyHandler(event.key)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
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
|
@Composable
|
||||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
Reference in New Issue
Block a user