Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b8f8801d |
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 33
|
versionCode = 34
|
||||||
versionName = "0.11.0"
|
versionName = "0.11.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -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('|');
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,19 +64,34 @@ 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() {
|
||||||
@@ -292,8 +307,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 +315,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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user