Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b5d3f0b0 | ||
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "dev.castarr.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 32
|
||||
versionName = "0.10.6"
|
||||
versionCode = 35
|
||||
versionName = "0.11.2"
|
||||
}
|
||||
|
||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
ws: null, connected: false, authorized: false,
|
||||
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
||||
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
||||
retryDelay: 1000, volumeDragging: false, searchTerm: '',
|
||||
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
|
||||
};
|
||||
|
||||
// --- pairing credentials ---
|
||||
@@ -450,16 +450,31 @@
|
||||
list.innerHTML = '';
|
||||
const favSet = new Set(state.extras.favorites || []);
|
||||
const favOn = state.extras.favoritesSupported && state.favOnly;
|
||||
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
|
||||
if (state.extras.favoritesSupported) {
|
||||
const teams = state.extras.teams || [];
|
||||
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');
|
||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
|
||||
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
|
||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
|
||||
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');
|
||||
chip.textContent = label;
|
||||
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
||||
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
||||
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
|
||||
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
||||
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
||||
chip.addEventListener('click', () => {
|
||||
state.teamKey = key;
|
||||
state.favOnly = fav;
|
||||
listSignature = '';
|
||||
renderChannels();
|
||||
});
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
list.appendChild(bar);
|
||||
@@ -467,8 +482,9 @@
|
||||
if (!shown.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = favOn
|
||||
? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
||||
empty.textContent = club
|
||||
? ('Heute läuft nichts mehr mit ' + club.name + '.')
|
||||
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
||||
: 'Keine Sender gefunden.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
@@ -493,6 +509,18 @@
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name'; name.textContent = c.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];
|
||||
if (info && info.now) {
|
||||
const now = document.createElement('span');
|
||||
@@ -513,6 +541,7 @@
|
||||
grp.className = 'grp'; grp.textContent = c.group;
|
||||
meta.appendChild(grp);
|
||||
}
|
||||
}
|
||||
btn.appendChild(num); btn.appendChild(meta);
|
||||
if (state.extras.favoritesSupported && c.backendId) {
|
||||
const star = document.createElement('span');
|
||||
@@ -539,7 +568,8 @@
|
||||
let listSignature = '';
|
||||
function renderChannelsIfChanged() {
|
||||
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.status.channel || '',
|
||||
].join('|');
|
||||
|
||||
@@ -170,22 +170,31 @@ class AppState(
|
||||
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
||||
}
|
||||
|
||||
/** One channel's club broadcasts: the next one plus how many follow. */
|
||||
data class TeamHit(
|
||||
val programme: dev.castarr.tv.data.Programme,
|
||||
val further: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Channels showing the viewer's club within the scanned window, paired
|
||||
* with the programme that matched — earliest kick-off first, so whatever
|
||||
* is running right now sits on top.
|
||||
*/
|
||||
fun teamMatches(key: String): List<Pair<Channel, dev.castarr.tv.data.Programme>> {
|
||||
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
|
||||
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
|
||||
val window = dev.castarr.tv.data.TeamFilters.WINDOW_MS
|
||||
val now = System.currentTimeMillis()
|
||||
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
|
||||
return activeChannels()
|
||||
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||
.mapNotNull { channel ->
|
||||
upcoming(channel, window)
|
||||
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
||||
?.let { channel to it }
|
||||
// A channel can carry several of the club's broadcasts in one
|
||||
// evening; the row names the next and counts the rest.
|
||||
val hits = upcoming(channel, window)
|
||||
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
||||
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
|
||||
}
|
||||
.sortedBy { it.second.start }
|
||||
.sortedBy { it.second.programme.start }
|
||||
}
|
||||
|
||||
fun refreshActive() {
|
||||
|
||||
@@ -147,9 +147,36 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
||||
}
|
||||
val favorites = org.json.JSONArray()
|
||||
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()
|
||||
.put("nowNext", nowNext)
|
||||
.put("favorites", favorites)
|
||||
.put("teams", teams)
|
||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,25 @@ data class TeamFilter(
|
||||
|
||||
object TeamFilters {
|
||||
|
||||
/** Window scanned ahead of now. */
|
||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||
/** Minimum window scanned ahead of now. */
|
||||
const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
|
||||
* the morning should already answer "does my club play tonight", and
|
||||
* asking late at night must not cut the evening short.
|
||||
*/
|
||||
fun windowEnd(now: Long): Long {
|
||||
val midnight = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = now
|
||||
add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
set(java.util.Calendar.MINUTE, 0)
|
||||
set(java.util.Calendar.SECOND, 0)
|
||||
set(java.util.Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
return maxOf(midnight, now + MIN_WINDOW_MS)
|
||||
}
|
||||
|
||||
/** At most this many club groups sit in the rail at once. */
|
||||
const val MAX_ACTIVE = 3
|
||||
|
||||
@@ -64,25 +64,45 @@ class ControlServer(
|
||||
// from spending someone else's budget.
|
||||
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
/** True while this address may still try; does not consume budget. */
|
||||
@Synchronized
|
||||
private fun attemptAllowed(address: String): Boolean {
|
||||
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) {
|
||||
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) {
|
||||
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() {
|
||||
// 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
|
||||
@@ -292,8 +312,6 @@ class ControlServer(
|
||||
}
|
||||
|
||||
private fun handleHello(msg: JSONObject) {
|
||||
// Every failed attempt counts against this address, whether it
|
||||
// carried a token or a code.
|
||||
if (!attemptAllowed(remoteAddress)) {
|
||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||
@@ -302,10 +320,12 @@ class ControlServer(
|
||||
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
||||
if (!tokenOk && !codeOk) {
|
||||
recordFailure(remoteAddress)
|
||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||
return
|
||||
}
|
||||
clearFailures(remoteAddress)
|
||||
authorized = true
|
||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||
// A code-authenticated client gets its own revocable token, never
|
||||
@@ -339,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
|
||||
|
||||
@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
|
||||
Text(
|
||||
when {
|
||||
activeTeam != null ->
|
||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
||||
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||
state.favoritesOnly ->
|
||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||
else -> "Diese Gruppe ist leer."
|
||||
@@ -351,7 +351,7 @@ private fun ChannelRow(
|
||||
number: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nowNext: NowNext,
|
||||
highlight: dev.castarr.tv.data.Programme? = null,
|
||||
highlight: AppState.TeamHit? = null,
|
||||
playing: Boolean,
|
||||
favorite: Boolean,
|
||||
epgStamp: Long,
|
||||
@@ -510,9 +510,10 @@ private fun LogoInitials(initials: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The programme that matched the club filter, with its start time. */
|
||||
/** The next club broadcast on this channel, and how many follow it. */
|
||||
@Composable
|
||||
private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Modifier = Modifier) {
|
||||
private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||
val programme = hit.programme
|
||||
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@@ -536,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}",
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -178,8 +179,8 @@ fun SettingsScreen(state: AppState) {
|
||||
|
||||
SettingsCard("Vereinsmenüs") {
|
||||
Text(
|
||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
||||
"nächsten 3 Stunden läuft. Bis zu " +
|
||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||
"heute noch läuft. Bis zu " +
|
||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||
@@ -189,8 +190,8 @@ fun SettingsScreen(state: AppState) {
|
||||
active.forEach { club ->
|
||||
SettingRow(
|
||||
club.fullName,
|
||||
subtitle = "Erscheint als ${club.label} in der Senderliste",
|
||||
trailing = { TogglePill(on = true) },
|
||||
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
||||
trailing = { RemoveIcon() },
|
||||
) { state.toggleTeam(club.key) }
|
||||
}
|
||||
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
|
||||
private fun TogglePill(on: Boolean) {
|
||||
Box(
|
||||
|
||||
@@ -51,6 +51,25 @@ class TeamFiltersTest {
|
||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window reaches midnight but never falls below three hours`() {
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
// Morning: the window must reach into the evening.
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
|
||||
cal.set(java.util.Calendar.MINUTE, 0)
|
||||
val morning = cal.timeInMillis
|
||||
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
|
||||
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
|
||||
|
||||
// Late at night: still at least three hours ahead.
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
|
||||
cal.set(java.util.Calendar.MINUTE, 30)
|
||||
val lateNight = cal.timeInMillis
|
||||
assertTrue(
|
||||
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `covers all three divisions`() {
|
||||
assertTrue(TeamFilters.all.size > 50)
|
||||
|
||||
Reference in New Issue
Block a user