Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 |
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 32
|
versionCode = 34
|
||||||
versionName = "0.10.6"
|
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('|');
|
||||||
|
|||||||
@@ -170,22 +170,31 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
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
|
* 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
|
||||||
* is running right now sits on top.
|
* 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 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()
|
return activeChannels()
|
||||||
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||||
.mapNotNull { channel ->
|
.mapNotNull { channel ->
|
||||||
upcoming(channel, window)
|
// A channel can carry several of the club's broadcasts in one
|
||||||
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
// evening; the row names the next and counts the rest.
|
||||||
?.let { channel to it }
|
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() {
|
fun refreshActive() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,25 @@ data class TeamFilter(
|
|||||||
|
|
||||||
object TeamFilters {
|
object TeamFilters {
|
||||||
|
|
||||||
/** Window scanned ahead of now. */
|
/** Minimum window scanned ahead of now. */
|
||||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
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. */
|
/** At most this many club groups sit in the rail at once. */
|
||||||
const val MAX_ACTIVE = 3
|
const val MAX_ACTIVE = 3
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
activeTeam != null ->
|
activeTeam != null ->
|
||||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||||
state.favoritesOnly ->
|
state.favoritesOnly ->
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
else -> "Diese Gruppe ist leer."
|
else -> "Diese Gruppe ist leer."
|
||||||
@@ -351,7 +351,7 @@ private fun ChannelRow(
|
|||||||
number: Int,
|
number: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
highlight: dev.castarr.tv.data.Programme? = null,
|
highlight: AppState.TeamHit? = null,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
epgStamp: Long,
|
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
|
@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
|
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
@@ -536,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}",
|
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||||
|
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -178,8 +179,8 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
SettingsCard("Vereinsmenüs") {
|
SettingsCard("Vereinsmenüs") {
|
||||||
Text(
|
Text(
|
||||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||||
"nächsten 3 Stunden läuft. Bis zu " +
|
"heute noch läuft. Bis zu " +
|
||||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ class TeamFiltersTest {
|
|||||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
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
|
@Test
|
||||||
fun `covers all three divisions`() {
|
fun `covers all three divisions`() {
|
||||||
assertTrue(TeamFilters.all.size > 50)
|
assertTrue(TeamFilters.all.size > 50)
|
||||||
|
|||||||
Reference in New Issue
Block a user