From a7d8fcd089090f3792539bb39ad0ee1e8d6a5e34 Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 14:33:09 +0200 Subject: [PATCH] Split the two screens that kept breaking, and let the smoke test reach them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsScreen and LiveScreen had grown to 760 and 689 lines and were where most of the last few days' bugs lived. Each is now the screen itself plus the pieces it composes — club chooser, pickers and rows next to settings, channel row and cells next to the live list. No behaviour changes: the same composables, moved, with the visibility widened from private to internal where a caller now sits in another file. formatClock existed twice, once per screen, and the move turned that into a compile error rather than a quiet duplicate. It is one internal function now. The smoke test seeds the demo playlist into the app's preferences before launching, so it walks the channel list and the player instead of stopping at the onboarding screen — the screens this commit moves are now actually exercised. It also refuses to start when its port is already taken: a leftover server from an earlier session answered every request with a 404 and the wait loop span forever. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/dev/castarr/tv/ui/Clock.kt | 13 + .../main/java/dev/castarr/tv/ui/LiveCells.kt | 247 ++++++++++ .../java/dev/castarr/tv/ui/LiveChannelRow.kt | 117 +++++ .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 318 ------------ .../java/dev/castarr/tv/ui/PlayerScreen.kt | 6 - .../java/dev/castarr/tv/ui/SettingsClubs.kt | 182 +++++++ .../java/dev/castarr/tv/ui/SettingsScreen.kt | 461 +----------------- .../java/dev/castarr/tv/ui/SettingsWidgets.kt | 343 +++++++++++++ tests/smoke.sh | 65 ++- 9 files changed, 960 insertions(+), 792 deletions(-) create mode 100644 app/src/main/java/dev/castarr/tv/ui/Clock.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/LiveCells.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt create mode 100644 app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt diff --git a/app/src/main/java/dev/castarr/tv/ui/Clock.kt b/app/src/main/java/dev/castarr/tv/ui/Clock.kt new file mode 100644 index 0000000..18b8b36 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/Clock.kt @@ -0,0 +1,13 @@ +package dev.castarr.tv.ui + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Wall-clock time as the screens show it: "20:15", German locale, device + * timezone. Lived twice in two screens, which is once too often for a rule + * that has to be the same everywhere. + */ +internal fun formatClock(millis: Long): String = + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt new file mode 100644 index 0000000..2964eba --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt @@ -0,0 +1,247 @@ +package dev.castarr.tv.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.animateFloat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.input.key.key +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 coil.compose.SubcomposeAsyncImage +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.Text +import dev.castarr.tv.AppState +import dev.castarr.tv.data.NowNext +import dev.castarr.tv.data.isEpgPlaceholder +import dev.castarr.tv.playlist.Channel + +/** + * The club badge. The images ship in the APK (fetched at build time by + * tools/fetch-crests.py, never committed), so nothing has to load over the + * network; the coloured shield stands in if one is ever missing. + */ +@Composable +internal fun Crest( + team: dev.castarr.tv.data.TeamFilter, + state: AppState, + urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, +) { + // The box is wider than the crest on purpose: the pulse ring is drawn + // around it, and a box sized to the crest would clip the ring away. + Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) { + if (urgency != AppState.TeamUrgency.NONE) { + // A slow pulse is what actually catches the eye from the sofa; + // colour alone does not at that distance. + val transition = androidx.compose.animation.core.rememberInfiniteTransition( + label = "crest-pulse", + ) + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = androidx.compose.animation.core.infiniteRepeatable( + androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing), + androidx.compose.animation.core.RepeatMode.Restart, + ), + label = "phase", + ) + val ringColor = + if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live + else CastarrColors.accent + Canvas(Modifier.fillMaxSize()) { + val grow = phase.coerceAtMost(0.75f) / 0.75f + drawCircle( + color = ringColor.copy(alpha = (1f - grow) * 0.85f), + radius = size.minDimension * (0.34f + grow * 0.15f), + style = Stroke(width = 2.dp.toPx()), + ) + } + } + SubcomposeAsyncImage( + model = "file:///android_asset/crests/${team.key}.png", + contentDescription = null, + contentScale = ContentScale.Fit, + filterQuality = androidx.compose.ui.graphics.FilterQuality.High, + modifier = Modifier.size(20.dp), + loading = { ShieldFallback(team) }, + error = { ShieldFallback(team) }, + ) + } +} + +@Composable +private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) { + Canvas(Modifier.size(18.dp)) { + val w = size.width + val h = size.height + val shield = Path().apply { + moveTo(w * 0.5f, 0f) + lineTo(w, h * 0.18f) + lineTo(w, h * 0.55f) + cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h) + cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f) + lineTo(0f, h * 0.18f) + close() + } + drawPath(shield, team.primary) + clipPath(shield) { + drawRect( + team.secondary, + topLeft = Offset(0f, h * 0.42f), + size = androidx.compose.ui.geometry.Size(w, h * 0.16f), + ) + } + } +} + +/** Bare channel logo (they ship transparent); initials as fallback. */ +@Composable +internal fun LogoTile(channel: Channel) { + val initials = channel.name.split(" ").filter { it.isNotBlank() } + .take(2).map { it.first() }.joinToString("").uppercase() + Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) { + if (channel.logo.isNotEmpty()) { + SubcomposeAsyncImage( + model = channel.logo, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + loading = { LogoInitials(initials) }, + error = { LogoInitials(initials) }, + ) + } else { + LogoInitials(initials) + } + } +} + +@Composable +private fun LogoInitials(initials: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + initials, + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** The next club broadcast on this channel, and how many follow it. */ +@Composable +internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) { + val programme = hit.programme + val running = System.currentTimeMillis() in programme.start until programme.stop + Column(modifier) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + programme.title, + color = CastarrColors.fg, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(12.dp)) + Text( + if (running) "läuft" else "ab ${formatClock(programme.start)}", + color = if (running) CastarrColors.accent else CastarrColors.muted, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal, + ) + } + Spacer(Modifier.height(6.dp)) + Text( + "${formatClock(programme.start)}–${formatClock(programme.stop)}" + + if (hit.further > 0) " · +${hit.further} weitere" else "", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + ) + } +} + +/** + * Left-aligned EPG column with a fixed leading edge: title + times, progress + * under the title, next programme only when it differs (design review P1/P2). + */ +@Composable +internal fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { + val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) } + Column(modifier) { + if (now != null) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + now.title, + color = CastarrColors.fg, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(12.dp)) + Text( + "${formatClock(now.start)}–${formatClock(now.stop)}", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + ) + } + val fraction = ((System.currentTimeMillis() - now.start).toFloat() / + (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(CastarrColors.line) + ) { + Box( + Modifier + .fillMaxWidth(fraction) + .height(2.dp) + .background(CastarrColors.accent) + ) + } + val next = nowNext.next?.takeUnless { + isEpgPlaceholder(it.title) || it.title == now.title + } + if (next != null) { + Spacer(Modifier.height(5.dp)) + Text( + "danach: ${next.title}", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt b/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt new file mode 100644 index 0000000..d276de7 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt @@ -0,0 +1,117 @@ +package dev.castarr.tv.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.getValue +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.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 +import dev.castarr.tv.AppState +import dev.castarr.tv.data.NowNext +import dev.castarr.tv.playlist.Channel + +@Composable +internal fun ChannelRow( + channel: Channel, + number: Int, + modifier: Modifier = Modifier, + nowNext: NowNext, + highlight: AppState.TeamHit? = null, + playing: Boolean, + favorite: Boolean, + epgStamp: Long, + onLongClick: (() -> Unit)?, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + // No focus scale — grown rows get clipped at the pane edges. + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface, + contentColor = CastarrColors.fg, + focusedContainerColor = CastarrColors.surfaceFocused, + focusedContentColor = CastarrColors.fg, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, CastarrColors.accent), + shape = RoundedCornerShape(12.dp), + ), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "$number", + color = if (playing) CastarrColors.accent else CastarrColors.faint, + fontFamily = AppFont, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(40.dp), + ) + LogoTile(channel) + Spacer(Modifier.width(14.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.width(230.dp), + ) { + Text( + channel.name, + color = if (playing) CastarrColors.accent else CastarrColors.fg, + fontFamily = AppFont, + fontSize = 16.sp, + fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (favorite) { + Spacer(Modifier.width(8.dp)) + Text("★", color = CastarrColors.accent, fontSize = 13.sp) + } + } + Spacer(Modifier.width(24.dp)) + if (highlight != null) { + // In the club view the matching broadcast is the point, not + // whatever happens to be running. + HighlightCell(highlight, Modifier.weight(1f)) + } else { + EpgCell(nowNext, Modifier.weight(1f)) + } + if (playing) { + Spacer(Modifier.width(12.dp)) + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(CastarrColors.accent) + ) + } + } + } +} diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index dfe85fd..26ecc89 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -1,11 +1,8 @@ package dev.castarr.tv.ui -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background 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.fillMaxHeight @@ -19,9 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.animation.core.animateFloat import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -31,10 +26,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key @@ -49,19 +40,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.SubcomposeAsyncImage -import androidx.compose.ui.layout.ContentScale -import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState -import dev.castarr.tv.data.NowNext -import dev.castarr.tv.data.isEpgPlaceholder -import dev.castarr.tv.playlist.Channel -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale /** * Live tab: group rail on the left (focusing a group opens it — exclusive, @@ -387,303 +369,3 @@ private fun GroupItem( } } } - -@Composable -private fun ChannelRow( - channel: Channel, - number: Int, - modifier: Modifier = Modifier, - nowNext: NowNext, - highlight: AppState.TeamHit? = null, - playing: Boolean, - favorite: Boolean, - epgStamp: Long, - onLongClick: (() -> Unit)?, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier.fillMaxWidth(), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), - // No focus scale — grown rows get clipped at the pane edges. - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface, - contentColor = CastarrColors.fg, - focusedContainerColor = CastarrColors.surfaceFocused, - focusedContentColor = CastarrColors.fg, - ), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, CastarrColors.accent), - shape = RoundedCornerShape(12.dp), - ), - ), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "$number", - color = if (playing) CastarrColors.accent else CastarrColors.faint, - fontFamily = AppFont, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.width(40.dp), - ) - LogoTile(channel) - Spacer(Modifier.width(14.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.width(230.dp), - ) { - Text( - channel.name, - color = if (playing) CastarrColors.accent else CastarrColors.fg, - fontFamily = AppFont, - fontSize = 16.sp, - fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - if (favorite) { - Spacer(Modifier.width(8.dp)) - Text("★", color = CastarrColors.accent, fontSize = 13.sp) - } - } - Spacer(Modifier.width(24.dp)) - if (highlight != null) { - // In the club view the matching broadcast is the point, not - // whatever happens to be running. - HighlightCell(highlight, Modifier.weight(1f)) - } else { - EpgCell(nowNext, Modifier.weight(1f)) - } - if (playing) { - Spacer(Modifier.width(12.dp)) - Box( - Modifier - .size(7.dp) - .clip(CircleShape) - .background(CastarrColors.accent) - ) - } - } - } -} - -/** - * The club badge. The images ship in the APK (fetched at build time by - * tools/fetch-crests.py, never committed), so nothing has to load over the - * network; the coloured shield stands in if one is ever missing. - */ -@Composable -private fun Crest( - team: dev.castarr.tv.data.TeamFilter, - state: AppState, - urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, -) { - // The box is wider than the crest on purpose: the pulse ring is drawn - // around it, and a box sized to the crest would clip the ring away. - Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) { - if (urgency != AppState.TeamUrgency.NONE) { - // A slow pulse is what actually catches the eye from the sofa; - // colour alone does not at that distance. - val transition = androidx.compose.animation.core.rememberInfiniteTransition( - label = "crest-pulse", - ) - val phase by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = androidx.compose.animation.core.infiniteRepeatable( - androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing), - androidx.compose.animation.core.RepeatMode.Restart, - ), - label = "phase", - ) - val ringColor = - if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live - else CastarrColors.accent - Canvas(Modifier.fillMaxSize()) { - val grow = phase.coerceAtMost(0.75f) / 0.75f - drawCircle( - color = ringColor.copy(alpha = (1f - grow) * 0.85f), - radius = size.minDimension * (0.34f + grow * 0.15f), - style = Stroke(width = 2.dp.toPx()), - ) - } - } - SubcomposeAsyncImage( - model = "file:///android_asset/crests/${team.key}.png", - contentDescription = null, - contentScale = ContentScale.Fit, - filterQuality = androidx.compose.ui.graphics.FilterQuality.High, - modifier = Modifier.size(20.dp), - loading = { ShieldFallback(team) }, - error = { ShieldFallback(team) }, - ) - } -} - -@Composable -private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) { - Canvas(Modifier.size(18.dp)) { - val w = size.width - val h = size.height - val shield = Path().apply { - moveTo(w * 0.5f, 0f) - lineTo(w, h * 0.18f) - lineTo(w, h * 0.55f) - cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h) - cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f) - lineTo(0f, h * 0.18f) - close() - } - drawPath(shield, team.primary) - clipPath(shield) { - drawRect( - team.secondary, - topLeft = Offset(0f, h * 0.42f), - size = androidx.compose.ui.geometry.Size(w, h * 0.16f), - ) - } - } -} - -/** Bare channel logo (they ship transparent); initials as fallback. */ -@Composable -private fun LogoTile(channel: Channel) { - val initials = channel.name.split(" ").filter { it.isNotBlank() } - .take(2).map { it.first() }.joinToString("").uppercase() - Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) { - if (channel.logo.isNotEmpty()) { - SubcomposeAsyncImage( - model = channel.logo, - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), - loading = { LogoInitials(initials) }, - error = { LogoInitials(initials) }, - ) - } else { - LogoInitials(initials) - } - } -} - -@Composable -private fun LogoInitials(initials: String) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - initials, - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - } -} - -/** The next club broadcast on this channel, and how many follow it. */ -@Composable -private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) { - val programme = hit.programme - val running = System.currentTimeMillis() in programme.start until programme.stop - Column(modifier) { - Row(verticalAlignment = Alignment.Bottom) { - Text( - programme.title, - color = CastarrColors.fg, - fontFamily = AppFont, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Spacer(Modifier.width(12.dp)) - Text( - if (running) "läuft" else "ab ${formatClock(programme.start)}", - color = if (running) CastarrColors.accent else CastarrColors.muted, - fontFamily = AppFont, - fontSize = 12.sp, - fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal, - ) - } - Spacer(Modifier.height(6.dp)) - Text( - "${formatClock(programme.start)}–${formatClock(programme.stop)}" + - if (hit.further > 0) " · +${hit.further} weitere" else "", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - ) - } -} - -private fun formatClock(millis: Long): String = - SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) - -/** - * Left-aligned EPG column with a fixed leading edge: title + times, progress - * under the title, next programme only when it differs (design review P1/P2). - */ -@Composable -private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) { - val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) } - Column(modifier) { - if (now != null) { - Row(verticalAlignment = Alignment.Bottom) { - Text( - now.title, - color = CastarrColors.fg, - fontFamily = AppFont, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Spacer(Modifier.width(12.dp)) - Text( - "${formatClock(now.start)}–${formatClock(now.stop)}", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - ) - } - val fraction = ((System.currentTimeMillis() - now.start).toFloat() / - (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .height(2.dp) - .clip(RoundedCornerShape(1.dp)) - .background(CastarrColors.line) - ) { - Box( - Modifier - .fillMaxWidth(fraction) - .height(2.dp) - .background(CastarrColors.accent) - ) - } - val next = nowNext.next?.takeUnless { - isEpgPlaceholder(it.title) || it.title == now.title - } - if (next != null) { - Spacer(Modifier.height(5.dp)) - Text( - "danach: ${next.title}", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt index 76e2e65..f8f87d2 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -51,9 +51,6 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState import dev.castarr.tv.data.isEpgPlaceholder -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale /** * Fullscreen playback. OK opens the auto-hiding overlay whose transport @@ -106,9 +103,6 @@ private val OverlayText = TextStyle( shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f), ) -private fun formatClock(millis: Long): String = - SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis)) - @Composable private fun Overlay(state: AppState) { val playFocus = remember { FocusRequester() } diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt new file mode 100644 index 0000000..1cca46f --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt @@ -0,0 +1,182 @@ +package dev.castarr.tv.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.getValue +import androidx.compose.runtime.remember +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.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.compose.ui.window.DialogProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.castarr.tv.AppState + +/** Club crest from the bundled assets; initials while it loads. */ +@Composable +internal fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) { + Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { + coil.compose.SubcomposeAsyncImage( + model = "file:///android_asset/crests/${team.key}.png", + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + loading = { CrestInitials(team) }, + error = { CrestInitials(team) }, + ) + } +} + +@Composable +private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) { + Box( + Modifier + .fillMaxSize() + .clip(CircleShape) + .background(team.primary), + contentAlignment = Alignment.Center, + ) { + Text( + team.label.take(3), + color = team.secondary, + fontFamily = AppFont, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +/** + * Club chooser: crests plus division headers, because a flat list of 56 + * names is unreadable on a remote. + */ +@Composable +internal fun ClubPickerDialog( + state: AppState, + onPick: (dev.castarr.tv.data.TeamFilter) -> Unit, + onClose: () -> Unit, +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } + val sections = remember(state.enabledTeams) { + dev.castarr.tv.data.TeamFilters.byLeague( + dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams } + ) + } + val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box( + Modifier + .fillMaxSize() + .background(CastarrColors.bgDeep.copy(alpha = 0.88f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .width(420.dp) + .clip(RoundedCornerShape(16.dp)) + .background(CastarrColors.surface) + .padding(horizontal = 10.dp, vertical = 14.dp) + ) { + Text( + "VEREIN HINZUFÜGEN", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), + ) + LazyColumn(Modifier.heightIn(max = 460.dp)) { + sections.forEach { (league, clubs) -> + item(key = "h-$league") { + Text( + league, + color = CastarrColors.accent, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp), + ) + } + items(clubs, key = { it.key }) { club -> + val focusMod = + if (club.key == firstKey) Modifier.focusRequester(firstFocus) + else Modifier + Surface( + onClick = { onPick(club) }, + modifier = Modifier + .fillMaxWidth() + .then(focusMod), + shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + 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 = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ClubCrest(club, state, size = 24) + Spacer(Modifier.width(12.dp)) + Text( + club.fullName, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + club.label, + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + ) + } + } + } + } + } + } + } + } +} 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 e779a75..d310f26 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -1,11 +1,6 @@ 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement @@ -16,18 +11,12 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState 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 @@ -37,21 +26,10 @@ 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.geometry.Offset -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.asImageBitmap 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.compose.ui.window.DialogProperties -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.castarr.tv.AppState import dev.castarr.tv.BuildConfig @@ -61,7 +39,7 @@ 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( +internal data class Picker( val title: String, val options: List, val selected: Int, @@ -321,440 +299,3 @@ fun SettingsScreen(state: AppState) { ) } } - -/** Club crest from the bundled assets; initials while it loads. */ -@Composable -private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) { - Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { - coil.compose.SubcomposeAsyncImage( - model = "file:///android_asset/crests/${team.key}.png", - contentDescription = null, - contentScale = androidx.compose.ui.layout.ContentScale.Fit, - modifier = Modifier.fillMaxSize(), - loading = { CrestInitials(team) }, - error = { CrestInitials(team) }, - ) - } -} - -@Composable -private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) { - Box( - Modifier - .fillMaxSize() - .clip(CircleShape) - .background(team.primary), - contentAlignment = Alignment.Center, - ) { - Text( - team.label.take(3), - color = team.secondary, - fontFamily = AppFont, - fontSize = 8.sp, - fontWeight = FontWeight.Bold, - ) - } -} - -/** - * Club chooser: crests plus division headers, because a flat list of 56 - * names is unreadable on a remote. - */ -@Composable -private fun ClubPickerDialog( - state: AppState, - onPick: (dev.castarr.tv.data.TeamFilter) -> Unit, - onClose: () -> Unit, -) { - val firstFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } - val sections = remember(state.enabledTeams) { - dev.castarr.tv.data.TeamFilters.byLeague( - dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams } - ) - } - val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key - Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) { - Box( - Modifier - .fillMaxSize() - .background(CastarrColors.bgDeep.copy(alpha = 0.88f)), - contentAlignment = Alignment.Center, - ) { - Column( - Modifier - .width(420.dp) - .clip(RoundedCornerShape(16.dp)) - .background(CastarrColors.surface) - .padding(horizontal = 10.dp, vertical = 14.dp) - ) { - Text( - "VEREIN HINZUFÜGEN", - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 2.sp, - modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), - ) - LazyColumn(Modifier.heightIn(max = 460.dp)) { - sections.forEach { (league, clubs) -> - item(key = "h-$league") { - Text( - league, - color = CastarrColors.accent, - fontFamily = AppFont, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 1.5.sp, - modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp), - ) - } - items(clubs, key = { it.key }) { club -> - val focusMod = - if (club.key == firstKey) Modifier.focusRequester(firstFocus) - else Modifier - Surface( - onClick = { onPick(club) }, - modifier = Modifier - .fillMaxWidth() - .then(focusMod), - shape = ClickableSurfaceDefaults.shape(rowShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - 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 = 9.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ClubCrest(club, state, size = 24) - Spacer(Modifier.width(12.dp)) - Text( - club.fullName, - fontFamily = AppFont, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - club.label, - color = CastarrColors.faint, - fontFamily = AppFont, - fontSize = 12.sp, - ) - } - } - } - } - } - } - } - } -} - -/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */ -@Composable -private fun PickerDialog(picker: Picker, onClose: () -> Unit) { - val selectedFocus = remember { FocusRequester() } - // A requester bound to no composed row throws; with no preselection - // (adding something new) there is deliberately no such row. - LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } } - val listState = rememberLazyListState() - LaunchedEffect(picker.selected) { - if (picker.selected > 0) listState.scrollToItem(picker.selected) - } - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - Modifier - .fillMaxSize() - // Own heavy scrim — the platform default is too light and - // lets the white QR card fight the panel. - .background(CastarrColors.bgDeep.copy(alpha = 0.88f)), - contentAlignment = Alignment.Center, - ) { - 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), - ) - LazyColumn( - state = listState, - modifier = Modifier.heightIn(max = 420.dp), - ) { - itemsIndexed(picker.options) { index, option -> - val selected = index == picker.selected - Surface( - onClick = { - picker.onPick(index) - onClose() - }, - modifier = Modifier - .fillMaxWidth() - .then( - if (selected || (picker.selected < 0 && index == 0)) - Modifier.focusRequester(selectedFocus) - else Modifier - ), - shape = ClickableSurfaceDefaults.shape(rowShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - 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, - ) - } - } -} - -/** Drawn "x": the row only ever removes, so a toggle would mislead. */ -@Composable -private fun RemoveIcon() { - val color = CastarrColors.muted - Canvas(Modifier.size(14.dp)) { - val stroke = 2.dp.toPx() - drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke) - drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke) - } -} - -@Composable -private fun TogglePill(on: Boolean) { - Box( - Modifier - .clip(RoundedCornerShape(999.dp)) - .background(if (on) CastarrColors.accent else CastarrColors.bg) - .padding(horizontal = 12.dp, vertical = 5.dp) - ) { - Text( - if (on) "An" else "Aus", - color = if (on) CastarrColors.onAccent else CastarrColors.muted, - fontFamily = AppFont, - fontSize = 12.sp, - fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal, - ) - } -} - -@Composable -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( - 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, - leading: (@Composable () -> Unit)? = null, - trailing: (@Composable () -> Unit)? = null, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = ClickableSurfaceDefaults.shape(rowShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - 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, - ) { - if (leading != null) { - leading() - Spacer(Modifier.width(12.dp)) - } - 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() - } - } - } -} - -@Composable -fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) { - Surface( - onClick = onClick, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim, - contentColor = if (danger) CastarrColors.muted else CastarrColors.accent, - focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent, - focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent, - ), - ) { - Text( - label, - fontFamily = AppFont, fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) - } -} - -@Composable -fun QrCard(bitmap: Bitmap) { - // Quiet zone stays white for scannability, but the tile sits in a - // bordered frame on the card instead of floating as a bare white block. - Column( - Modifier - .clip(RoundedCornerShape(18.dp)) - .background(CastarrColors.bg) - .border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.Center, - ) { - Column( - Modifier - .clip(RoundedCornerShape(12.dp)) - .background(androidx.compose.ui.graphics.Color(0xFFF4F6F7)) - .padding(10.dp), - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "QR-Code zum Koppeln", - modifier = Modifier.size(164.dp), - ) - } - } -} diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt new file mode 100644 index 0000000..7b1060d --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt @@ -0,0 +1,343 @@ +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 +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.getValue +import androidx.compose.runtime.remember +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.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap +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.compose.ui.window.DialogProperties +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.Surface +import androidx.tv.material3.Text + +/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */ +@Composable +internal fun PickerDialog(picker: Picker, onClose: () -> Unit) { + val selectedFocus = remember { FocusRequester() } + // A requester bound to no composed row throws; with no preselection + // (adding something new) there is deliberately no such row. + LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } } + val listState = rememberLazyListState() + LaunchedEffect(picker.selected) { + if (picker.selected > 0) listState.scrollToItem(picker.selected) + } + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + Modifier + .fillMaxSize() + // Own heavy scrim — the platform default is too light and + // lets the white QR card fight the panel. + .background(CastarrColors.bgDeep.copy(alpha = 0.88f)), + contentAlignment = Alignment.Center, + ) { + 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), + ) + LazyColumn( + state = listState, + modifier = Modifier.heightIn(max = 420.dp), + ) { + itemsIndexed(picker.options) { index, option -> + val selected = index == picker.selected + Surface( + onClick = { + picker.onPick(index) + onClose() + }, + modifier = Modifier + .fillMaxWidth() + .then( + if (selected || (picker.selected < 0 && index == 0)) + Modifier.focusRequester(selectedFocus) + else Modifier + ), + shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + 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 +internal 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, + ) + } + } +} + +/** Drawn "x": the row only ever removes, so a toggle would mislead. */ +@Composable +internal fun RemoveIcon() { + val color = CastarrColors.muted + Canvas(Modifier.size(14.dp)) { + val stroke = 2.dp.toPx() + drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke) + drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke) + } +} + +@Composable +private fun TogglePill(on: Boolean) { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(if (on) CastarrColors.accent else CastarrColors.bg) + .padding(horizontal = 12.dp, vertical = 5.dp) + ) { + Text( + if (on) "An" else "Aus", + color = if (on) CastarrColors.onAccent else CastarrColors.muted, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} + +@Composable +internal 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( + 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() + } +} + +internal val rowShape = RoundedCornerShape(10.dp) + +/** One full-width focusable settings row: label + subtitle, value right. */ +@Composable +internal fun SettingRow( + label: String, + subtitle: String? = null, + danger: Boolean = false, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + 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, + ) { + if (leading != null) { + leading() + Spacer(Modifier.width(12.dp)) + } + 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() + } + } + } +} + +@Composable +fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim, + contentColor = if (danger) CastarrColors.muted else CastarrColors.accent, + focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent, + focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent, + ), + ) { + Text( + label, + fontFamily = AppFont, fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + ) + } +} + +@Composable +fun QrCard(bitmap: Bitmap) { + // Quiet zone stays white for scannability, but the tile sits in a + // bordered frame on the card instead of floating as a bare white block. + Column( + Modifier + .clip(RoundedCornerShape(18.dp)) + .background(CastarrColors.bg) + .border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.Center, + ) { + Column( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(androidx.compose.ui.graphics.Color(0xFFF4F6F7)) + .padding(10.dp), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "QR-Code zum Koppeln", + modifier = Modifier.size(164.dp), + ) + } + } +} diff --git a/tests/smoke.sh b/tests/smoke.sh index 318a66f..593c102 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash # Smoke test on the headless Google TV emulator. # -# Builds the debug APK, installs it, walks the first screen with the D-pad -# and fails on anything the unit tests cannot see: a crash on startup, a -# crash while navigating, an ANR. The 0.11.0 startup crash and the rail -# crash on "Verein hinzufügen" would both have been caught here. +# Builds the debug APK, seeds the demo playlist as its source, installs it +# and walks from the channel list into the player with the D-pad. Fails on +# anything the unit tests cannot see: a crash on startup, a crash while +# navigating, an ANR, or a channel list that stayed empty. The 0.11.0 +# startup crash and the rail crash on "Verein hinzufügen" would both have +# been caught here. # # tests/smoke.sh # build, run, leave the emulator up # tests/smoke.sh --apk # skip the build, test this APK @@ -17,6 +19,7 @@ RUNS="$ROOT/tests/runs" SERIAL="${CASTARR_SERIAL:-emulator-5554}" ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb" PKG="dev.castarr.tv" +DEMO_PORT="${CASTARR_DEMO_PORT:-8099}" ACTIVITY="$PKG/.MainActivity" APK="" @@ -49,11 +52,44 @@ fi step "Emulator starten" "$HELPERS/emulator.sh" start | tee -a "$LOG" +step "Demo-Quelle bereitstellen" +python3 "$ROOT/tests/demo/make-demo-data.py" >>"$LOG" 2>&1 || fail "Demo-Daten fehlgeschlagen" +# The emulator reaches the host at 10.0.2.2, which is the address baked into +# the demo playlist. +# A leftover server from an earlier run happily answers on this port while +# serving a directory that no longer exists — every request a 404, and the +# wait below would spin forever. +if ss -ltn "sport = :$DEMO_PORT" 2>/dev/null | grep -q LISTEN; then + fail "Port $DEMO_PORT ist belegt: $(ss -ltnp "sport = :$DEMO_PORT" 2>/dev/null | tail -1)" +fi +# 10.0.2.2 inside the emulator is the host loopback, so binding there is enough. +(cd "$ROOT/tests/demo" && exec python3 -m http.server "$DEMO_PORT" --bind 127.0.0.1) \ + >>"$RUNS/http.log" 2>&1 & +DEMO_PID=$! +trap 'kill "$DEMO_PID" 2>/dev/null || true' EXIT +for _ in $(seq 1 50); do + curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null && break + sleep 0.2 +done +curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null || + fail "Demo-Server antwortet nicht auf Port $DEMO_PORT" + step "Installieren" # A debug build over a signed release needs the old one gone first. "$ADB" -s "$SERIAL" uninstall "$PKG" >/dev/null 2>&1 || true "$ADB" -s "$SERIAL" install -r "$APK" >>"$LOG" 2>&1 || fail "Installation fehlgeschlagen" +# Onboarding runs on a phone (ADR-0007), which a test has no way to be, so +# the source is seeded straight into the preferences the first run reads. +# Only a debug build allows this, which is the build under test. +"$ADB" -s "$SERIAL" shell run-as "$PKG" sh -c "'mkdir -p shared_prefs && cat > shared_prefs/source.xml'" < + + http://10.0.2.2:$DEMO_PORT/playlist.m3u + http://10.0.2.2:$DEMO_PORT/epg.xml + +XML + step "Starten" "$ADB" -s "$SERIAL" logcat -c "$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 || @@ -83,6 +119,18 @@ check() { check "beim Start" "$HELPERS/emulator.sh" shot smoke-start >/dev/null +# Reaching the channel list is the point: it is the screen the demo source +# feeds, and a blank one means the playlist never arrived. +step "Senderliste prüfen" +DUMP="$RUNS/ui-dump.xml" +"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml >>"$LOG" 2>&1 || true +"$ADB" -s "$SERIAL" pull /sdcard/ui.xml "$DUMP" >>"$LOG" 2>&1 || true +if grep -q "Blau TV HD" "$DUMP" 2>/dev/null; then + echo " Sender aus der Demo-Playlist sichtbar" +else + fail "Senderliste zeigt die Demo-Playlist nicht — Dump in $DUMP" +fi + step "D-Pad-Navigation" # Down/right walks the rail and opens whatever has focus; back returns. for key in DPAD_DOWN DPAD_RIGHT DPAD_RIGHT DPAD_DOWN DPAD_CENTER BACK DPAD_UP; do @@ -93,7 +141,7 @@ sleep 2 check "bei der Navigation" "$HELPERS/emulator.sh" shot smoke-nav >/dev/null -step "Einstellungen öffnen" +step "Wiedereintritt" "$ADB" -s "$SERIAL" shell am start -n "$ACTIVITY" >/dev/null 2>&1 sleep 2 check "nach dem Wiedereintritt" @@ -109,7 +157,8 @@ OK — kein Absturz, App läuft. Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png Log: $LOG -Der Test deckt Start, D-Pad und Wiedereintritt ab. Alles hinter dem -Onboarding (echte Quelle, Wiedergabe, Kopplung) braucht ein Backend und -bleibt Handarbeit. +Abgedeckt: Start mit gesetzter Quelle, Senderliste aus der Demo-Playlist, +D-Pad bis in den Player, Wiedereintritt. Nicht abgedeckt: echte Wiedergabe +(die Demo-Streams sind Attrappen), Onboarding am Handy, Kopplung und alles, +was ein Dispatcharr-Backend braucht. EOF