diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4794e17..8e58283 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "dev.castarr.tv" minSdk = 26 targetSdk = 35 - versionCode = 33 - versionName = "0.11.0" + versionCode = 34 + versionName = "0.11.1" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html index f409316..2279ca0 100644 --- a/app/src/main/assets/remote/index.html +++ b/app/src/main/assets/remote/index.html @@ -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('|'); diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index f090a16..c54cc35 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index 848f49c..42c2b16 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -64,19 +64,34 @@ class ControlServer( // from spending someone else's budget. private val attempts = HashMap>() + /** 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() { @@ -292,8 +307,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 +315,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 diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index bf50d46..c45d035 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -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 @@ -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(