Highlight a club menu while its match is on or about to start
All checks were successful
Build TV app / build (push) Successful in 3m6s

The rail entry showed a plain hit count, which says nothing about whether
that hit is happening now or six hours from now. It now turns into a red
dot with "läuft" while a match is running, and an accent dot with "gleich"
within half an hour of kick-off — the only two moments where the menu is
worth interrupting for. A one-minute ticker re-evaluates it so the state
appears on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 10:45:15 +02:00
parent 56b5d3f0b0
commit e491f8e50f
3 changed files with 65 additions and 8 deletions

View File

@@ -176,6 +176,24 @@ class AppState(
val further: Int,
)
/** How urgent a club menu is right now — drives the rail highlight. */
enum class TeamUrgency { NONE, SOON, LIVE }
/**
* LIVE while a match is running, SOON within half an hour of kick-off —
* the window in which someone actually wants to be nudged.
*/
fun teamUrgency(matches: List<Pair<Channel, TeamHit>>): TeamUrgency {
val now = System.currentTimeMillis()
var soon = false
matches.forEach { (_, hit) ->
val p = hit.programme
if (now in p.start until p.stop) return TeamUrgency.LIVE
if (p.start in now..(now + SOON_WINDOW_MS)) soon = true
}
return if (soon) TeamUrgency.SOON else TeamUrgency.NONE
}
/**
* Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever
@@ -230,6 +248,9 @@ class AppState(
private companion object {
/** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L
/** How far ahead a kick-off counts as "about to start". */
const val SOON_WINDOW_MS = 30 * 60 * 1000L
}
fun isOnline(): Boolean {

View File

@@ -87,6 +87,15 @@ fun LiveScreen(state: AppState) {
val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) }
}
// Re-evaluates the highlight every minute; without it "gleich" would
// only appear when something else happened to recompose.
var urgencyTick by remember { mutableStateOf(0) }
LaunchedEffect(teams) {
while (true) {
kotlinx.coroutines.delay(60_000)
urgencyTick++
}
}
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
@@ -189,11 +198,15 @@ fun LiveScreen(state: AppState) {
}
}
items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty()
GroupItem(
label = club.label,
count = matchesByTeam[club.key]?.size ?: 0,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state) },
urgency = remember(clubMatches, urgencyTick) {
state.teamUrgency(clubMatches)
},
modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier
@@ -299,6 +312,8 @@ private fun GroupItem(
selected: Boolean,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
/** Replaces the count and adds a dot when a match is on or imminent. */
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit,
) {
@@ -336,11 +351,32 @@ private fun GroupItem(
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
when (urgency) {
AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> {
val accent =
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent
Box(
Modifier
.size(6.dp)
.clip(CircleShape)
.background(accent)
)
Spacer(Modifier.width(6.dp))
Text(
if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich",
color = accent,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
AppState.TeamUrgency.NONE -> Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}