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
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:
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal file
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal 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))
|
||||||
247
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal file
247
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal file
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -31,10 +26,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.Key
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
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,
|
* 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ import androidx.tv.material3.Surface
|
|||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
import dev.castarr.tv.data.isEpgPlaceholder
|
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
|
* 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),
|
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun formatClock(millis: Long): String =
|
|
||||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Overlay(state: AppState) {
|
private fun Overlay(state: AppState) {
|
||||||
val playFocus = remember { FocusRequester() }
|
val playFocus = remember { FocusRequester() }
|
||||||
|
|||||||
182
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal file
182
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package dev.castarr.tv.ui
|
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.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -37,21 +26,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.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 androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
import dev.castarr.tv.BuildConfig
|
import dev.castarr.tv.BuildConfig
|
||||||
@@ -61,7 +39,7 @@ import dev.castarr.tv.update.UpdateChecker
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** One open picker dialog: which setting, its options, what happens on pick. */
|
/** One open picker dialog: which setting, its options, what happens on pick. */
|
||||||
private data class Picker(
|
internal data class Picker(
|
||||||
val title: String,
|
val title: String,
|
||||||
val options: List<String>,
|
val options: List<String>,
|
||||||
val selected: Int,
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
343
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal file
343
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Smoke test on the headless Google TV emulator.
|
# Smoke test on the headless Google TV emulator.
|
||||||
#
|
#
|
||||||
# Builds the debug APK, installs it, walks the first screen with the D-pad
|
# Builds the debug APK, seeds the demo playlist as its source, installs it
|
||||||
# and fails on anything the unit tests cannot see: a crash on startup, a
|
# and walks from the channel list into the player with the D-pad. Fails on
|
||||||
# crash while navigating, an ANR. The 0.11.0 startup crash and the rail
|
# anything the unit tests cannot see: a crash on startup, a crash while
|
||||||
# crash on "Verein hinzufügen" would both have been caught here.
|
# 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 # build, run, leave the emulator up
|
||||||
# tests/smoke.sh --apk <path> # skip the build, test this APK
|
# tests/smoke.sh --apk <path> # skip the build, test this APK
|
||||||
@@ -17,6 +19,7 @@ RUNS="$ROOT/tests/runs"
|
|||||||
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
|
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
|
||||||
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
|
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
|
||||||
PKG="dev.castarr.tv"
|
PKG="dev.castarr.tv"
|
||||||
|
DEMO_PORT="${CASTARR_DEMO_PORT:-8099}"
|
||||||
ACTIVITY="$PKG/.MainActivity"
|
ACTIVITY="$PKG/.MainActivity"
|
||||||
|
|
||||||
APK=""
|
APK=""
|
||||||
@@ -49,11 +52,44 @@ fi
|
|||||||
step "Emulator starten"
|
step "Emulator starten"
|
||||||
"$HELPERS/emulator.sh" start | tee -a "$LOG"
|
"$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"
|
step "Installieren"
|
||||||
# A debug build over a signed release needs the old one gone first.
|
# 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" uninstall "$PKG" >/dev/null 2>&1 || true
|
||||||
"$ADB" -s "$SERIAL" install -r "$APK" >>"$LOG" 2>&1 || fail "Installation fehlgeschlagen"
|
"$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"
|
step "Starten"
|
||||||
"$ADB" -s "$SERIAL" logcat -c
|
"$ADB" -s "$SERIAL" logcat -c
|
||||||
"$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 ||
|
"$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 ||
|
||||||
@@ -83,6 +119,18 @@ check() {
|
|||||||
check "beim Start"
|
check "beim Start"
|
||||||
"$HELPERS/emulator.sh" shot smoke-start >/dev/null
|
"$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"
|
step "D-Pad-Navigation"
|
||||||
# Down/right walks the rail and opens whatever has focus; back returns.
|
# 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
|
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"
|
check "bei der Navigation"
|
||||||
"$HELPERS/emulator.sh" shot smoke-nav >/dev/null
|
"$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
|
"$ADB" -s "$SERIAL" shell am start -n "$ACTIVITY" >/dev/null 2>&1
|
||||||
sleep 2
|
sleep 2
|
||||||
check "nach dem Wiedereintritt"
|
check "nach dem Wiedereintritt"
|
||||||
@@ -109,7 +157,8 @@ OK — kein Absturz, App läuft.
|
|||||||
Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png
|
Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png
|
||||||
Log: $LOG
|
Log: $LOG
|
||||||
|
|
||||||
Der Test deckt Start, D-Pad und Wiedereintritt ab. Alles hinter dem
|
Abgedeckt: Start mit gesetzter Quelle, Senderliste aus der Demo-Playlist,
|
||||||
Onboarding (echte Quelle, Wiedergabe, Kopplung) braucht ein Backend und
|
D-Pad bis in den Player, Wiedereintritt. Nicht abgedeckt: echte Wiedergabe
|
||||||
bleibt Handarbeit.
|
(die Demo-Streams sind Attrappen), Onboarding am Handy, Kopplung und alles,
|
||||||
|
was ein Dispatcharr-Backend braucht.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user