Stop throwing out honest remotes, and mirror club menus to the phone
All checks were successful
Build TV app / build (push) Successful in 4m39s
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('|');
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,19 +64,34 @@ 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() {
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user