2 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
be-nj
56b5d3f0b0 Stop the socket timeout from killing the remote mid-session
All checks were successful
Build TV app / build (push) Successful in 2m55s
Issue #1's fix gave accepted sockets a read deadline so idle connections
could no longer pin threads — but NanoHTTPD's 5 s default also applies to
the long-lived WebSocket, and pings only ran every 8 s. The remote was
therefore dropped roughly five seconds into every session, right after a
command or two, with no close frame.

The deadline stays (that was the point) but is now 40 s, comfortably above
the ping interval, so pongs keep an active session alive while a truly idle
socket still gets reaped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:33:39 +02:00
4 changed files with 72 additions and 9 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 = 34 versionCode = 36
versionName = "0.11.1" 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

@@ -97,7 +97,12 @@ class ControlServer(
fun startServer() { fun startServer() {
// A busy port must not take the whole app down — the remote is // A busy port must not take the whole app down — the remote is
// optional, everything else keeps working. // optional, everything else keeps working.
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) } // NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
// with pings only every 8 s the socket timed out mid-session and the
// remote was thrown out after a few seconds. The timeout still has to
// exist (idle connections must not pin threads), it just has to be
// comfortably longer than the ping interval.
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") } .onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
.isSuccess .isSuccess
if (!running) return if (!running) return
@@ -354,6 +359,7 @@ class ControlServer(
private companion object { private companion object {
const val TAG = "ControlServer" const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L const val PING_INTERVAL_MS = 8_000L
const val SOCKET_TIMEOUT_MS = 40_000
const val ATTEMPT_WINDOW_MS = 60_000L const val ATTEMPT_WINDOW_MS = 60_000L
const val ATTEMPT_MAX = 5 const val ATTEMPT_MAX = 5
const val MAX_TRACKED_ADDRESSES = 64 const val MAX_TRACKED_ADDRESSES = 64

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,13 +351,34 @@ 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,
) )
} }
} }
}
} }
@Composable @Composable