diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a2303b..d27afcc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 35 - versionName = "0.11.2" + versionCode = 36 + versionName = "0.11.3" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index 1377da4..99133bc 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -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>): 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 { diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 858e0ef..151b981 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -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, + ) + } } } }