Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "dev.castarr.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 34
|
||||
versionName = "0.11.1"
|
||||
versionCode = 36
|
||||
versionName = "0.11.3"
|
||||
}
|
||||
|
||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,7 +97,12 @@ class ControlServer(
|
||||
fun startServer() {
|
||||
// A busy port must not take the whole app down — the remote is
|
||||
// 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}") }
|
||||
.isSuccess
|
||||
if (!running) return
|
||||
@@ -354,6 +359,7 @@ class ControlServer(
|
||||
private companion object {
|
||||
const val TAG = "ControlServer"
|
||||
const val PING_INTERVAL_MS = 8_000L
|
||||
const val SOCKET_TIMEOUT_MS = 40_000
|
||||
const val ATTEMPT_WINDOW_MS = 60_000L
|
||||
const val ATTEMPT_MAX = 5
|
||||
const val MAX_TRACKED_ADDRESSES = 64
|
||||
|
||||
@@ -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,7 +351,27 @@ private fun GroupItem(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
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(
|
||||
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,
|
||||
@@ -344,6 +379,7 @@ private fun GroupItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
|
||||
Reference in New Issue
Block a user