diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06c8159..a673c17 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 = 12 - versionName = "0.6.1" + versionCode = 13 + versionName = "0.7.0" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt index 1301ef8..cc64dff 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -11,11 +11,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -23,8 +28,12 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.tv.material3.Surface import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Text import dev.castarr.tv.AppState +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin @Composable fun CastarrApp(state: AppState) { @@ -123,36 +132,48 @@ private fun TopBar(state: AppState) { } Spacer(Modifier.width(18.dp)) } - TabItem( - if (state.screen == AppState.Screen.LIVE) "Einstellungen" else "Zurück zu Live", - selected = false, - ) { + GearButton { state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS else AppState.Screen.LIVE } } } +/** Round settings button with a drawn gear (glyphs render as emoji). */ @Composable -private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) { +private fun GearButton(onClick: () -> Unit) { Surface( onClick = onClick, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), + modifier = Modifier.semantics { contentDescription = "Einstellungen" }, + shape = ClickableSurfaceDefaults.shape(CircleShape), colors = ClickableSurfaceDefaults.colors( - // Visible resting surface, so the pill reads as a target even - // without focus (design review P3). - containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface, - contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, + containerColor = CastarrColors.surface, + contentColor = CastarrColors.muted, focusedContainerColor = CastarrColors.accent, focusedContentColor = CastarrColors.onAccent, ), ) { - Text( - label, - fontFamily = AppFont, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), - ) + val color = LocalContentColor.current + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.size(17.dp)) { + val stroke = 1.7.dp.toPx() + val center = Offset(size.width / 2f, size.height / 2f) + drawCircle(color, radius = size.minDimension * 0.17f, center = center, style = Stroke(stroke)) + val inner = size.minDimension * 0.30f + val outer = size.minDimension * 0.48f + for (i in 0 until 8) { + val angle = i * (PI / 4) + val dx = cos(angle).toFloat() + val dy = sin(angle).toFloat() + drawLine( + color, + start = center + Offset(dx * inner, dy * inner), + end = center + Offset(dx * outer, dy * outer), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } + } + } } } 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 953ccf7..1f0e1f3 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -1,17 +1,22 @@ package dev.castarr.tv.ui import android.graphics.Bitmap +import androidx.compose.foundation.BorderStroke 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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.collectAsState @@ -24,9 +29,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface import androidx.tv.material3.Text @@ -38,8 +51,10 @@ import dev.castarr.tv.update.UpdateChecker import kotlinx.coroutines.launch /** - * Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text - * fields, no scrolling. Everything text-heavy lives in the Erweitert screen. + * 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. */ @Composable fun SettingsScreen(state: AppState) { @@ -51,131 +66,313 @@ fun SettingsScreen(state: AppState) { Row( Modifier .fillMaxSize() - .padding(horizontal = 40.dp, vertical = 20.dp) + .padding(horizontal = 40.dp, vertical = 12.dp) ) { - Column(Modifier.weight(1.2f)) { - SectionTitle("Konto") - Spacer(Modifier.height(8.dp)) - if (state.auth.isLoggedIn) { - Text( - "Angemeldet als ${state.auth.username}", - color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp, - ) - Spacer(Modifier.height(14.dp)) - if (state.source.m3uUrl.isNotEmpty()) { - Row { - Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) { - state.setMode(AppState.SourceMode.DISPATCHARR) + Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsCard("Konto") { + if (state.auth.isLoggedIn) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(34.dp) + .clip(CircleShape) + .background(CastarrColors.accentDim), + contentAlignment = Alignment.Center, + ) { + Text( + state.auth.username.take(1).uppercase().ifEmpty { "?" }, + color = CastarrColors.accent, + fontFamily = AppFont, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) } - Spacer(Modifier.width(8.dp)) - Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) { - state.setMode(AppState.SourceMode.GENERIC) + Spacer(Modifier.width(14.dp)) + Column { + Text( + state.auth.username, + color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp, + ) + Text( + "Angemeldet über SSO", + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, + ) } } - Spacer(Modifier.height(14.dp)) - } - Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp) - Spacer(Modifier.height(6.dp)) - Row { - Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" } - Spacer(Modifier.width(8.dp)) - Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" } - profiles.forEach { name -> - Spacer(Modifier.width(8.dp)) - Chip(name, state.outputProfile == name) { state.outputProfile = name } + 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( + "Nicht angemeldet.", + color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + SettingRow("Jetzt anmelden") { state.startOnboarding() } } - Spacer(Modifier.height(18.dp)) - ActionButton("Abmelden", danger = true) { state.startOnboarding() } - } else { - Text( - "Nicht angemeldet.", - color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp, - ) - Spacer(Modifier.height(12.dp)) - ActionButton("Jetzt anmelden") { state.startOnboarding() } } - Spacer(Modifier.height(28.dp)) - SectionTitle("App") - Spacer(Modifier.height(8.dp)) - Text( - "Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""), - color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, - ) - Spacer(Modifier.height(8.dp)) - Row { - ActionButton( - if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen" - ) { - scope.launch { - updateStatus = if (state.updateAvailable != null) { - UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…" - } else { - when (val v = UpdateChecker.check(state)) { - null -> "App ist aktuell" - else -> "$v verfügbar — nochmal drücken zum Installieren" + SettingsCard("App") { + SettingRow( + "Version ${BuildConfig.VERSION_NAME}", + subtitle = updateStatus.ifEmpty { "Klicken prüft auf Updates" }, + trailing = state.updateAvailable?.let { version -> + { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(CastarrColors.accentDim) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + "$version verfügbar", + color = CastarrColors.accent, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) } } + }, + ) { + scope.launch { + updateStatus = when (val v = UpdateChecker.check(state)) { + null -> "App ist aktuell" + else -> "$v verfügbar" + } } } - Spacer(Modifier.width(8.dp)) - ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED } - } - if (updateStatus.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - Text(updateStatus, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp) + if (state.updateAvailable != null) { + SettingRow("Update installieren") { + scope.launch { + updateStatus = + UpdateChecker.downloadAndInstall(context, state) + ?: "Update wird geöffnet…" + } + } + } + SettingRow( + "Erweitert", + subtitle = "M3U/EPG-Adressen von Hand eintragen", + ) { state.screen = AppState.Screen.ADVANCED } } } - Spacer(Modifier.width(40.dp)) + Spacer(Modifier.width(16.dp)) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - SectionTitle("Handy-Fernbedienung") - Spacer(Modifier.height(10.dp)) - val address = remember { Pairing.lanAddress() } - if (address != null) { - val qr = remember(address) { - Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216")) + Column(Modifier.weight(0.75f)) { + SettingsCard("Handy-Fernbedienung") { + val address = remember { Pairing.lanAddress() } + if (address != null) { + val qr = remember(address) { + Qr.encode( + Pairing.pairingUrl(context, address), 400, + android.graphics.Color.parseColor("#101216"), + ) + } + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCard(qr) + Spacer(Modifier.height(10.dp)) + Text( + "Mit der Handy-Kamera scannen", + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, + ) + Text( + "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", + color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp, + ) + } + } else { + Text( + "Keine Netzwerkverbindung", + color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) } - QrCard(qr) - Spacer(Modifier.height(10.dp)) - Text( - "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", - color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, - ) - } else { - Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp) } } } } @Composable -private fun SectionTitle(text: String) { - Text(text, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 20.sp) -} - -@Composable -fun Chip(label: String, selected: Boolean, onClick: () -> Unit) { - Surface( - onClick = onClick, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface, - contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, - focusedContainerColor = CastarrColors.accent, - focusedContentColor = CastarrColors.onAccent, - ), +private fun SettingsCard(title: String, content: @Composable () -> Unit) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(CastarrColors.surface) + .padding(horizontal = 10.dp, vertical = 14.dp) ) { Text( - label, - fontFamily = AppFont, fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + title.uppercase(), + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.padding(start = 14.dp, bottom = 8.dp), ) + content() } } +private val rowShape = RoundedCornerShape(10.dp) + +/** One full-width focusable settings row: label + subtitle, value right. */ +@Composable +private fun SettingRow( + label: String, + 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) + } + }, + 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, if (danger) CastarrColors.live else CastarrColors.accent), + shape = rowShape, + ), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + label, + color = if (danger) CastarrColors.live else CastarrColors.fg, + fontFamily = AppFont, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + subtitle, + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(12.dp)) + trailing() + } + } + } +} + +/** Row whose value is a segmented control; left/right switches in place. */ +@Composable +private fun SegmentedRow( + label: String, + subtitle: String, + options: List, + selected: Int, + onChange: (Int) -> Unit, +) { + SettingRow( + label = label, + subtitle = subtitle, + trailing = { + Row( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(CastarrColors.bg) + .border(1.dp, CastarrColors.line, RoundedCornerShape(999.dp)) + .padding(3.dp) + ) { + options.forEachIndexed { index, option -> + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background( + if (index == selected) CastarrColors.accent + else androidx.compose.ui.graphics.Color.Transparent + ) + .padding(horizontal = 12.dp, vertical = 5.dp) + ) { + Text( + option, + color = if (index == selected) CastarrColors.onAccent + else CastarrColors.muted, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = if (index == selected) FontWeight.SemiBold + else FontWeight.Normal, + ) + } + } + } + }, + keyHandler = { key -> + when { + key == Key.DirectionLeft && selected > 0 -> { + onChange(selected - 1); true + } + key == Key.DirectionRight && selected < options.lastIndex -> { + onChange(selected + 1); true + } + else -> false + } + }, + ) { onChange((selected + 1) % options.size) } +} + @Composable fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) { Surface(