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