Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 |
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user