1 Commits

Author SHA1 Message Date
be-nj
869060c56f Pick clubs from the top three divisions, and stop the rail from crashing
Some checks failed
Build TV app / build (push) Failing after 2s
The club shortcut now offers every club of the first three German
divisions instead of two hard-coded ones. Up to three can be active at
once; benjamin and tobiasb still start with Hansa and the VfB switched on.

Also fixes a crash on the left jump out of the channel list: the jump
targeted a FocusRequester bound to the selected rail row, and a row that
is scrolled out of a LazyColumn is not composed, so requesting focus on it
threw. Jumps are now attempted and fall back to normal focus movement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:54:04 +02:00
6 changed files with 174 additions and 63 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 26 versionCode = 27
versionName = "0.10.0" versionName = "0.10.1"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -54,14 +54,21 @@ class AppState(
private fun loadEnabledTeams(): Set<String> { private fun loadEnabledTeams(): Set<String> {
val stored = prefs.getStringSet("teams", null) val stored = prefs.getStringSet("teams", null)
if (stored != null) return stored.toSet() if (stored != null) return stored.toSet()
return setOfNotNull(dev.castarr.tv.data.TeamFilters.defaultKeyFor(auth.username)) return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
} }
fun toggleTeam(key: String) { /** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
fun toggleTeam(key: String): Boolean {
if (key !in enabledTeams &&
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
) {
return false
}
enabledTeams = enabledTeams =
if (key in enabledTeams) enabledTeams - key else enabledTeams + key if (key in enabledTeams) enabledTeams - key else enabledTeams + key
prefs.edit().putStringSet("teams", enabledTeams).apply() prefs.edit().putStringSet("teams", enabledTeams).apply()
if (activeTeam == key && key !in enabledTeams) activeTeam = null if (activeTeam == key && key !in enabledTeams) activeTeam = null
return true
} }
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList()) var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set private set

View File

@@ -12,9 +12,7 @@ data class TeamFilter(
val key: String, val key: String,
/** Short caption next to the crest in the rail. */ /** Short caption next to the crest in the rail. */
val label: String, val label: String,
/** How the club is named in a sentence. */ /** Full club name, shown in the settings and in messages. */
val name: String,
/** Full club name, used in the settings. */
val fullName: String, val fullName: String,
/** Lowercase needles matched against programme titles. */ /** Lowercase needles matched against programme titles. */
val needles: List<String>, val needles: List<String>,
@@ -32,31 +30,88 @@ object TeamFilters {
/** Window scanned ahead of now. */ /** Window scanned ahead of now. */
const val WINDOW_MS = 3 * 60 * 60 * 1000L const val WINDOW_MS = 3 * 60 * 60 * 1000L
val all = listOf( /** At most this many club groups sit in the rail at once. */
TeamFilter( const val MAX_ACTIVE = 3
key = "hansa",
label = "FCH", private const val WHITE = 0xFFF2F3F5
name = "Hansa", private const val BLACK = 0xFF15171B
fullName = "Hansa Rostock",
needles = listOf("hansa"), private fun club(
primary = Color(0xFF0B4EA2), key: String,
secondary = Color(0xFFF2F3F5), label: String,
), fullName: String,
TeamFilter( needles: List<String>,
key = "vfb", primary: Long,
label = "1893", secondary: Long,
name = "dem VfB", ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary))
fullName = "VfB Stuttgart",
needles = listOf("vfb stuttgart", "vfb"), /** Clubs of the top three German divisions. */
primary = Color(0xFFE32219), val all: List<TeamFilter> = listOf(
secondary = Color(0xFFF2F3F5), // --- Bundesliga ---
), club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE),
// --- 2. Bundesliga ---
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
// --- 3. Liga ---
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE),
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK),
) )
/** The club a viewer gets switched on by default. */ /** Clubs switched on for a viewer before they touch the settings. */
private val defaultForUser = mapOf( private val defaultForUser = mapOf(
"benjamin" to "hansa", "benjamin" to listOf("hansa"),
"tobiasb" to "vfb", "tobiasb" to listOf("stuttgart"),
) )
/** /**
@@ -80,6 +135,6 @@ object TeamFilters {
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key } fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
fun defaultKeyFor(username: String): String? = fun defaultKeysFor(username: String): List<String> =
defaultForUser[username.trim().lowercase()] defaultForUser[username.trim().lowercase()].orEmpty()
} }

View File

@@ -34,8 +34,12 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -130,11 +134,18 @@ fun LiveScreen(state: AppState) {
state.restorePending = false state.restorePending = false
} }
} }
val intoList = if (channels.isEmpty()) Modifier // A FocusRequester that is not currently attached throws when used, and
else Modifier.focusProperties { right = listFocus } // in a LazyColumn the selected row may well be scrolled out of
// composition — so jumps are attempted, not declared.
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
runCatching { listFocus.requestFocus() }.isSuccess
}
val railState = rememberLazyListState()
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = railState,
modifier = Modifier modifier = Modifier
.width(264.dp) .width(264.dp)
.fillMaxHeight(), .fillMaxHeight(),
@@ -235,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.name}." "In den nächsten 3 Stunden läuft nichts 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."
@@ -254,7 +265,11 @@ fun LiveScreen(state: AppState) {
// matching the remote's number pad in every view. // matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1, number = allChannels.indexOf(channel) + 1,
modifier = Modifier modifier = Modifier
.focusProperties { left = railFocus } .onPreviewKeyEvent { event ->
event.type == KeyEventType.KeyDown &&
event.key == Key.DirectionLeft &&
runCatching { railFocus.requestFocus() }.isSuccess
}
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier) .then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
.then( .then(
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus) if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)

View File

@@ -165,36 +165,37 @@ fun SettingsScreen(state: AppState) {
SettingsCard("Vereinsmenüs") { SettingsCard("Vereinsmenüs") {
Text( Text(
"Zeigt eine eigene Gruppe mit allen Sendern, auf denen der " + "Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
"Verein in den nächsten 3 Stunden läuft.", "nächsten 3 Stunden läuft. Bis zu " +
"${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),
) )
dev.castarr.tv.data.TeamFilters.all.forEach { club -> val active = dev.castarr.tv.data.TeamFilters.all
val on = club.key in state.enabledTeams .filter { it.key in state.enabledTeams }
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",
trailing = { trailing = { TogglePill(on = true) },
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(
if (on) CastarrColors.accent else CastarrColors.bg
)
.padding(horizontal = 12.dp, vertical = 5.dp)
) {
Text(
if (on) "An" else "Aus",
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
)
}
},
) { state.toggleTeam(club.key) } ) { state.toggleTeam(club.key) }
} }
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
SettingRow(
"Verein hinzufügen",
subtitle = "1. bis 3. Liga",
) {
val choices = dev.castarr.tv.data.TeamFilters.all
.filter { it.key !in state.enabledTeams }
picker = Picker(
"Verein hinzufügen",
choices.map { it.fullName },
-1,
) { index ->
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
}
}
}
} }
SettingsCard("App") { SettingsCard("App") {
@@ -419,6 +420,24 @@ private fun ValueWithCaret(value: String) {
} }
} }
@Composable
private fun TogglePill(on: Boolean) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (on) CastarrColors.accent else CastarrColors.bg)
.padding(horizontal = 12.dp, vertical = 5.dp)
) {
Text(
if (on) "An" else "Aus",
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
@Composable @Composable
private fun SettingsCard(title: String, content: @Composable () -> Unit) { private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Column( Column(

View File

@@ -2,14 +2,13 @@ package dev.castarr.tv.data
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class TeamFiltersTest { class TeamFiltersTest {
private val hansa = TeamFilters.byKey("hansa")!! private val hansa = TeamFilters.byKey("hansa")!!
private val vfb = TeamFilters.byKey("vfb")!! private val vfb = TeamFilters.byKey("stuttgart")!!
@Test @Test
fun `matches the club regardless of case and surrounding text`() { fun `matches the club regardless of case and surrounding text`() {
@@ -39,8 +38,24 @@ class TeamFiltersTest {
@Test @Test
fun `assigns each viewer their own club by default`() { fun `assigns each viewer their own club by default`() {
assertEquals("hansa", TeamFilters.defaultKeyFor("benjamin")) assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
assertEquals("vfb", TeamFilters.defaultKeyFor("TobiasB")) assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
assertNull(TeamFilters.defaultKeyFor("someone-else")) assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
}
@Test
fun `club keys are unique and every club has needles`() {
val keys = TeamFilters.all.map { it.key }
assertEquals(keys.size, keys.toSet().size)
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
}
@Test
fun `covers all three divisions`() {
assertTrue(TeamFilters.all.size > 50)
listOf("bayern", "schalke", "hansa").forEach {
assertTrue(it, TeamFilters.byKey(it) != null)
}
} }
} }