1 Commits

Author SHA1 Message Date
be-nj
e491f8e50f 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>
2026-08-26 10:45:15 +02:00
3 changed files with 65 additions and 8 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 35 versionCode = 36
versionName = "0.11.2" versionName = "0.11.3"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -176,6 +176,24 @@ class AppState(
val further: Int, 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 * Channels showing the viewer's club within the scanned window, paired
* with the programme that matched — earliest kick-off first, so whatever * with the programme that matched — earliest kick-off first, so whatever
@@ -230,6 +248,9 @@ class AppState(
private companion object { private companion object {
/** Grace period before re-opening the channel just closed. */ /** Grace period before re-opening the channel just closed. */
const val REENTRY_GRACE_MS = 2_500L 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 { fun isOnline(): Boolean {

View File

@@ -87,6 +87,15 @@ fun LiveScreen(state: AppState) {
val matchesByTeam = remember(teams, allChannels, epgStamp) { val matchesByTeam = remember(teams, allChannels, epgStamp) {
teams.associate { it.key to state.teamMatches(it.key) } 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 activeTeam = teams.firstOrNull { it.key == state.activeTeam }
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty() val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } } 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 -> items(teams, key = { it.key }) { club ->
val clubMatches = matchesByTeam[club.key].orEmpty()
GroupItem( GroupItem(
label = club.label, label = club.label,
count = matchesByTeam[club.key]?.size ?: 0, count = clubMatches.size,
selected = state.activeTeam == club.key, selected = state.activeTeam == club.key,
leading = { Crest(club, state) }, leading = { Crest(club, state) },
urgency = remember(clubMatches, urgencyTick) {
state.teamUrgency(clubMatches)
},
modifier = intoList.then( modifier = intoList.then(
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
else Modifier else Modifier
@@ -299,6 +312,8 @@ private fun GroupItem(
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null, 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 }, suppressAutoSelect: () -> Boolean = { false },
onSelect: () -> Unit, onSelect: () -> Unit,
) { ) {
@@ -336,11 +351,32 @@ private fun GroupItem(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( when (urgency) {
"$count", AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> {
fontFamily = AppFont, val accent =
fontSize = 12.sp, 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,
)
}
} }
} }
} }