3 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
be-nj
b0b8f8801d Stop throwing out honest remotes, and mirror club menus to the phone
All checks were successful
Build TV app / build (push) Successful in 4m39s
The rate limit charged every hello, not just failed ones. The phone remote
reconnects on any network hiccup, so five reconnects in a minute — entirely
normal — got it kicked with "zu viele Versuche". Only failed authentication
costs budget now, and a success clears the address.

The club menus existed only on the TV. The phone now receives them and
shows a chip per club next to Alle/Favoriten, listing the matching channels
with kick-off time instead of the usual now/next.

Settings: the club row showed an "An" pill although it can only ever
remove, which read like a toggle. It is a remove icon now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:25:22 +02:00
7 changed files with 175 additions and 28 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 = 33 versionCode = 36
versionName = "0.11.0" 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

@@ -267,7 +267,7 @@
ws: null, connected: false, authorized: false, ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 }, status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false, channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '', retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
}; };
// --- pairing credentials --- // --- pairing credentials ---
@@ -450,16 +450,31 @@
list.innerHTML = ''; list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []); const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly; const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered; const teams = state.extras.teams || [];
if (state.extras.favoritesSupported) { const club = teams.find((t) => t.key === state.teamKey);
// Club view: the TV's club menu, mirrored onto the phone.
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
: favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px'; bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => { const views = [['Alle', null, false]];
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
views.forEach(([label, key, fav]) => {
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
const chip = document.createElement('button'); const chip = document.createElement('button');
chip.textContent = label; chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' + chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8'); (active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); }); chip.addEventListener('click', () => {
state.teamKey = key;
state.favOnly = fav;
listSignature = '';
renderChannels();
});
bar.appendChild(chip); bar.appendChild(chip);
}); });
list.appendChild(bar); list.appendChild(bar);
@@ -467,8 +482,9 @@
if (!shown.length) { if (!shown.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty'; empty.className = 'empty';
empty.textContent = favOn empty.textContent = club
? 'Keine Favoriten — Stern auf einem Sender antippen.' ? ('Heute läuft nichts mehr mit ' + club.name + '.')
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
: 'Keine Sender gefunden.'; : 'Keine Sender gefunden.';
list.appendChild(empty); list.appendChild(empty);
return; return;
@@ -493,6 +509,18 @@
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name; name.className = 'name'; name.textContent = c.name;
meta.appendChild(name); meta.appendChild(name);
const hit = clubHits ? clubHits.get(c.url) : null;
if (hit) {
const line = document.createElement('span');
line.className = 'grp';
const t = new Date(hit.start);
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
const running = Date.now() >= hit.start && Date.now() < hit.stop;
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
meta.appendChild(line);
} else {
const info = (state.extras.nowNext || [])[idx]; const info = (state.extras.nowNext || [])[idx];
if (info && info.now) { if (info && info.now) {
const now = document.createElement('span'); const now = document.createElement('span');
@@ -513,6 +541,7 @@
grp.className = 'grp'; grp.textContent = c.group; grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp); meta.appendChild(grp);
} }
}
btn.appendChild(num); btn.appendChild(meta); btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) { if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span'); const star = document.createElement('span');
@@ -539,7 +568,8 @@
let listSignature = ''; let listSignature = '';
function renderChannelsIfChanged() { function renderChannelsIfChanged() {
const sig = [ const sig = [
state.channels.length, state.searchTerm, state.favOnly, state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
(state.extras.favorites || []).join(','), (state.extras.favorites || []).join(','),
state.status.channel || '', state.status.channel || '',
].join('|'); ].join('|');

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

@@ -147,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
} }
val favorites = org.json.JSONArray() val favorites = org.json.JSONArray()
state.dispatcharr.favorites.value.forEach { favorites.put(it) } state.dispatcharr.favorites.value.forEach { favorites.put(it) }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
.filter { it.key in state.enabledTeams }
.forEach { club ->
val entries = org.json.JSONArray()
state.teamMatches(club.key).forEach { (channel, hit) ->
entries.put(
JSONObject()
.put("channel", channel.name)
.put("url", channel.url)
.put("group", channel.group)
.put("title", hit.programme.title)
.put("start", hit.programme.start)
.put("stop", hit.programme.stop)
.put("further", hit.further)
)
}
teams.put(
JSONObject()
.put("key", club.key)
.put("label", club.label)
.put("name", club.fullName)
.put("entries", entries)
)
}
return JSONObject() return JSONObject()
.put("nowNext", nowNext) .put("nowNext", nowNext)
.put("favorites", favorites) .put("favorites", favorites)
.put("teams", teams)
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR) .put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
} }

View File

@@ -64,25 +64,45 @@ class ControlServer(
// from spending someone else's budget. // from spending someone else's budget.
private val attempts = HashMap<String, ArrayDeque<Long>>() private val attempts = HashMap<String, ArrayDeque<Long>>()
/** True while this address may still try; does not consume budget. */
@Synchronized @Synchronized
private fun attemptAllowed(address: String): Boolean { private fun attemptAllowed(address: String): Boolean {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val queue = attempts.getOrPut(address) { ArrayDeque() } val queue = attempts[address] ?: return true
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) { while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
queue.removeFirst() queue.removeFirst()
} }
return queue.size < ATTEMPT_MAX
}
/**
* Only *failed* authentication costs budget. Counting successes too
* threw out honest clients: the phone remote reconnects on every
* network hiccup, and five reconnects a minute are normal.
*/
@Synchronized
private fun recordFailure(address: String) {
val queue = attempts.getOrPut(address) { ArrayDeque() }
queue.addLast(System.currentTimeMillis())
if (attempts.size > MAX_TRACKED_ADDRESSES) { if (attempts.size > MAX_TRACKED_ADDRESSES) {
attempts.entries.removeAll { it.value.isEmpty() } attempts.entries.removeAll { it.value.isEmpty() }
} }
if (queue.size >= ATTEMPT_MAX) return false }
queue.addLast(now)
return true @Synchronized
private fun clearFailures(address: String) {
attempts.remove(address)
} }
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
@@ -292,8 +312,6 @@ class ControlServer(
} }
private fun handleHello(msg: JSONObject) { private fun handleHello(msg: JSONObject) {
// Every failed attempt counts against this address, whether it
// carried a token or a code.
if (!attemptAllowed(remoteAddress)) { if (!attemptAllowed(remoteAddress)) {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
@@ -302,10 +320,12 @@ class ControlServer(
val tokenOk = Pairing.isValidToken(context, msg.optString("token")) val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code")) val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
if (!tokenOk && !codeOk) { if (!tokenOk && !codeOk) {
recordFailure(remoteAddress)
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return return
} }
clearFailures(remoteAddress)
authorized = true authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" } deviceName = msg.optString("name").ifEmpty { "Handy" }
// A code-authenticated client gets its own revocable token, never // A code-authenticated client gets its own revocable token, never
@@ -339,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,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(

View File

@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -189,8 +190,8 @@ fun SettingsScreen(state: AppState) {
active.forEach { club -> active.forEach { club ->
SettingRow( SettingRow(
club.fullName, club.fullName,
subtitle = "Erscheint als ${club.label} in der Senderliste", subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
trailing = { TogglePill(on = true) }, trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) } ) { state.toggleTeam(club.key) }
} }
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) { if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
@@ -452,6 +453,17 @@ private fun ValueWithCaret(value: String) {
} }
} }
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
@Composable
private fun RemoveIcon() {
val color = CastarrColors.muted
Canvas(Modifier.size(14.dp)) {
val stroke = 2.dp.toPx()
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
}
}
@Composable @Composable
private fun TogglePill(on: Boolean) { private fun TogglePill(on: Boolean) {
Box( Box(