Split the two screens that kept breaking, and let the smoke test reach them
Some checks failed
Build TV app / build (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 14:33:09 +02:00
parent e35500fa2f
commit a7d8fcd089
9 changed files with 960 additions and 792 deletions

View File

@@ -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))

View File

@@ -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,
)
}
}
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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() }

View File

@@ -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,
)
}
}
}
}
}
}
}
}
}

View File

@@ -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<String>,
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),
)
}
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -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 <path> # 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'" <<XML || fail "Quelle konnte nicht gesetzt werden"
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="m3u_url">http://10.0.2.2:$DEMO_PORT/playlist.m3u</string>
<string name="epg_url">http://10.0.2.2:$DEMO_PORT/epg.xml</string>
</map>
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