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
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +351,27 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
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(
|
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",
|
"$count",
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
@@ -344,6 +379,7 @@ private fun GroupItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelRow(
|
private fun ChannelRow(
|
||||||
|
|||||||
Reference in New Issue
Block a user