From 869060c56f9b690f9879a1102b2069fe82c4da48 Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 02:54:04 +0200 Subject: [PATCH] Pick clubs from the top three divisions, and stop the rail from crashing 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 --- app/build.gradle.kts | 4 +- app/src/main/java/dev/castarr/tv/AppState.kt | 11 +- .../java/dev/castarr/tv/data/TeamFilters.kt | 109 +++++++++++++----- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 25 +++- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 63 ++++++---- tests/unit/TeamFiltersTest.kt | 25 +++- 6 files changed, 174 insertions(+), 63 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2df4882..46e841f 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 = 26 - versionName = "0.10.0" + versionCode = 27 + versionName = "0.10.1" } // Release signing from environment (see ~/.keys/castarr-release.env on the diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index debfa90..e2b078b 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -54,14 +54,21 @@ class AppState( private fun loadEnabledTeams(): Set { val stored = prefs.getStringSet("teams", null) 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 = if (key in enabledTeams) enabledTeams - key else enabledTeams + key prefs.edit().putStringSet("teams", enabledTeams).apply() if (activeTeam == key && key !in enabledTeams) activeTeam = null + return true } var audioTracks by mutableStateOf>(emptyList()) private set diff --git a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt index f7fc9fa..7598d58 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -12,9 +12,7 @@ data class TeamFilter( val key: String, /** Short caption next to the crest in the rail. */ val label: String, - /** How the club is named in a sentence. */ - val name: String, - /** Full club name, used in the settings. */ + /** Full club name, shown in the settings and in messages. */ val fullName: String, /** Lowercase needles matched against programme titles. */ val needles: List, @@ -32,31 +30,88 @@ object TeamFilters { /** Window scanned ahead of now. */ const val WINDOW_MS = 3 * 60 * 60 * 1000L - val all = listOf( - TeamFilter( - key = "hansa", - label = "FCH", - name = "Hansa", - fullName = "Hansa Rostock", - needles = listOf("hansa"), - primary = Color(0xFF0B4EA2), - secondary = Color(0xFFF2F3F5), - ), - TeamFilter( - key = "vfb", - label = "1893", - name = "dem VfB", - fullName = "VfB Stuttgart", - needles = listOf("vfb stuttgart", "vfb"), - primary = Color(0xFFE32219), - secondary = Color(0xFFF2F3F5), - ), + /** At most this many club groups sit in the rail at once. */ + const val MAX_ACTIVE = 3 + + private const val WHITE = 0xFFF2F3F5 + private const val BLACK = 0xFF15171B + + private fun club( + key: String, + label: String, + fullName: String, + needles: List, + primary: Long, + secondary: Long, + ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary)) + + /** Clubs of the top three German divisions. */ + val all: List = listOf( + // --- 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( - "benjamin" to "hansa", - "tobiasb" to "vfb", + "benjamin" to listOf("hansa"), + "tobiasb" to listOf("stuttgart"), ) /** @@ -80,6 +135,6 @@ object TeamFilters { fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key } - fun defaultKeyFor(username: String): String? = - defaultForUser[username.trim().lowercase()] + fun defaultKeysFor(username: String): List = + defaultForUser[username.trim().lowercase()].orEmpty() } diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 4a5aa1d..b9eb78d 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -34,8 +34,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties 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.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -130,11 +134,18 @@ fun LiveScreen(state: AppState) { state.restorePending = false } } - val intoList = if (channels.isEmpty()) Modifier - else Modifier.focusProperties { right = listFocus } + // A FocusRequester that is not currently attached throws when used, and + // 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()) { LazyColumn( + state = railState, modifier = Modifier .width(264.dp) .fillMaxHeight(), @@ -235,7 +246,7 @@ fun LiveScreen(state: AppState) { Text( when { 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 -> "Noch keine Favoriten — halte OK auf einem Sender gedrückt." else -> "Diese Gruppe ist leer." @@ -254,7 +265,11 @@ fun LiveScreen(state: AppState) { // matching the remote's number pad in every view. number = allChannels.indexOf(channel) + 1, 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 == restoreIndex) Modifier.focusRequester(restoreFocus) 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 ae2ef22..f86af0a 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -165,36 +165,37 @@ fun SettingsScreen(state: AppState) { SettingsCard("Vereinsmenüs") { Text( - "Zeigt eine eigene Gruppe mit allen Sendern, auf denen der " + - "Verein in den nächsten 3 Stunden läuft.", + "Eigene Gruppe mit allen Sendern, auf denen der Verein in den " + + "nächsten 3 Stunden läuft. Bis zu " + + "${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp, modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp), ) - dev.castarr.tv.data.TeamFilters.all.forEach { club -> - val on = club.key in state.enabledTeams + val active = dev.castarr.tv.data.TeamFilters.all + .filter { it.key in state.enabledTeams } + active.forEach { club -> SettingRow( club.fullName, subtitle = "Erscheint als ${club.label} in der Senderliste", - trailing = { - 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, - ) - } - }, + trailing = { TogglePill(on = true) }, ) { 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") { @@ -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 private fun SettingsCard(title: String, content: @Composable () -> Unit) { Column( diff --git a/tests/unit/TeamFiltersTest.kt b/tests/unit/TeamFiltersTest.kt index 3913868..4ba86c0 100644 --- a/tests/unit/TeamFiltersTest.kt +++ b/tests/unit/TeamFiltersTest.kt @@ -2,14 +2,13 @@ package dev.castarr.tv.data import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class TeamFiltersTest { private val hansa = TeamFilters.byKey("hansa")!! - private val vfb = TeamFilters.byKey("vfb")!! + private val vfb = TeamFilters.byKey("stuttgart")!! @Test fun `matches the club regardless of case and surrounding text`() { @@ -39,8 +38,24 @@ class TeamFiltersTest { @Test fun `assigns each viewer their own club by default`() { - assertEquals("hansa", TeamFilters.defaultKeyFor("benjamin")) - assertEquals("vfb", TeamFilters.defaultKeyFor("TobiasB")) - assertNull(TeamFilters.defaultKeyFor("someone-else")) + assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin")) + assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB")) + 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) + } } }