From 3b6a8d7d0aaf59d948f1354293d898a082937d8c Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 11:42:18 +0200 Subject: [PATCH] Show crests and league sections when picking a club The club chooser was a flat list of 56 names in a generic picker dialog, which is unreadable from a couch. It now has its own dialog with a crest per row, the short label on the right, and section headers per division. Enabled clubs in the settings card show their crest too. A search field would have been the obvious alternative, but text entry on a remote is exactly what the Ten-Foot rule rules out, so grouping plus crests carries the recognition instead. Also adds tests/demo, a generator for neutral playlist and EPG data so screenshots never carry real channel names. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 + app/build.gradle.kts | 4 +- app/src/main/java/dev/castarr/tv/AppState.kt | 16 +- .../java/dev/castarr/tv/data/TeamFilters.kt | 125 +++++++------ .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 93 ++++++---- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 169 ++++++++++++++++-- tests/demo/make-demo-data.py | 107 +++++++++++ 7 files changed, 408 insertions(+), 108 deletions(-) create mode 100755 tests/demo/make-demo-data.py diff --git a/.gitignore b/.gitignore index 014a4d7..7e944ab 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ local.properties .kotlin/ tests/runs/ app/src/main/assets/crests/ +tests/demo/playlist.m3u +tests/demo/epg.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d27afcc..7745a52 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 = 36 - versionName = "0.11.3" + versionCode = 37 + versionName = "0.11.4" } // 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 99133bc..7d30737 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -179,19 +179,25 @@ class AppState( /** How urgent a club menu is right now — drives the rail highlight. */ enum class TeamUrgency { NONE, SOON, LIVE } + /** The urgent state plus the kick-off it refers to, for the rail label. */ + data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long) + /** * LIVE while a match is running, SOON within half an hour of kick-off — * the window in which someone actually wants to be nudged. */ - fun teamUrgency(matches: List>): TeamUrgency { + fun teamSignal(matches: List>): TeamSignal { val now = System.currentTimeMillis() - var soon = false + var soonAt = 0L matches.forEach { (_, hit) -> val p = hit.programme - if (now in p.start until p.stop) return TeamUrgency.LIVE - if (p.start in now..(now + SOON_WINDOW_MS)) soon = true + if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start) + if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) { + soonAt = p.start + } } - return if (soon) TeamUrgency.SOON else TeamUrgency.NONE + return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt) + else TeamSignal(TeamUrgency.NONE, 0L) } /** 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 3a3acc1..3001071 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -20,6 +20,8 @@ data class TeamFilter( val secondary: Color, /** German Wikipedia article the crest is resolved from. */ val article: String, + /** 1, 2 or 3 — groups the picker by division. */ + val league: Int, ) { fun matches(title: String): Boolean { val haystack = title.lowercase() @@ -63,69 +65,70 @@ object TeamFilters { primary: Long, secondary: Long, article: String = fullName, - ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article) + league: Int = 1, + ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league) /** 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), + club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1), + club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1), + club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1), + club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1), + club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1), + club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1), + club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1), + club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1), + club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1), + club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1), + club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1), + club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1), + club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1), + club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1), + club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1), + club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1), + club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1), + club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1), // --- 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), + club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2), + club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2), + club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2), + club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2), + club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2), + club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2), + club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2), + club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2), + club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2), + club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2), + club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2), + club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2), + club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2), + club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2), + club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2), + club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2), + club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2), + club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2), // --- 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, article = "SSV Ulm 1846 Fußball"), - 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, article = "TSG 1899 Hoffenheim"), + club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3), + club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3), + club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3), + club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3), + club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3), + club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3), + club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3), + club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3), + club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3), + club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3), + club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3), + club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3), + club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3), + club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3), + club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3), + club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3), + club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3), + club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3), + club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3), + club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3), ) /** Clubs switched on for a viewer before they touch the settings. */ @@ -155,6 +158,14 @@ object TeamFilters { fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key } + /** Clubs grouped by division, in the order the picker shows them. */ + fun byLeague(clubs: List = all): List>> = + listOf( + "1. Bundesliga" to clubs.filter { it.league == 1 }, + "2. Bundesliga" to clubs.filter { it.league == 2 }, + "3. Liga" to clubs.filter { it.league == 3 }, + ).filter { it.second.isNotEmpty() } + 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 151b981..55c4f7c 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.animateFloat import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -199,14 +201,15 @@ fun LiveScreen(state: AppState) { } items(teams, key = { it.key }) { club -> val clubMatches = matchesByTeam[club.key].orEmpty() + val clubSignal = remember(clubMatches, urgencyTick) { + state.teamSignal(clubMatches) + } GroupItem( label = club.label, count = clubMatches.size, selected = state.activeTeam == club.key, - leading = { Crest(club, state) }, - urgency = remember(clubMatches, urgencyTick) { - state.teamUrgency(clubMatches) - }, + leading = { Crest(club, state, clubSignal.urgency) }, + signal = clubSignal, modifier = intoList.then( if (state.activeTeam == club.key) Modifier.focusRequester(railFocus) else Modifier @@ -312,8 +315,8 @@ private fun GroupItem( selected: Boolean, modifier: Modifier = Modifier, leading: (@Composable () -> Unit)? = null, - /** Replaces the count and adds a dot when a match is on or imminent. */ - urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, + /** Replaces the count when a match is on or about to start. */ + signal: AppState.TeamSignal? = null, suppressAutoSelect: () -> Boolean = { false }, onSelect: () -> Unit, ) { @@ -351,32 +354,25 @@ private fun GroupItem( modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) - when (urgency) { - AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> { - val accent = - if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live - else CastarrColors.accent - Box( - Modifier - .size(6.dp) - .clip(CircleShape) - .background(accent) - ) - Spacer(Modifier.width(6.dp)) - Text( - if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich", - color = accent, - fontFamily = AppFont, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - } - AppState.TeamUrgency.NONE -> Text( - "$count", - fontFamily = AppFont, - fontSize = 12.sp, - ) - } + // The signal lives in the value column instead of adding a + // badge — the count is worth less than "now" or "16:00" is. + val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE + Text( + when (urgent) { + AppState.TeamUrgency.LIVE -> "läuft" + AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff) + AppState.TeamUrgency.NONE -> "$count" + }, + color = when (urgent) { + AppState.TeamUrgency.LIVE -> CastarrColors.live + AppState.TeamUrgency.SOON -> CastarrColors.accent + AppState.TeamUrgency.NONE -> Color.Unspecified + }, + fontFamily = AppFont, + fontSize = 12.sp, + fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal + else FontWeight.SemiBold, + ) } } } @@ -474,8 +470,39 @@ private fun ChannelRow( * network; the coloured shield stands in if one is ever missing. */ @Composable -private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) { +private fun Crest( + team: dev.castarr.tv.data.TeamFilter, + state: AppState, + urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE, +) { Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { + if (urgency != AppState.TeamUrgency.NONE) { + // A slow pulse is what actually catches the eye from the sofa; + // colour alone does not at that distance. + val transition = androidx.compose.animation.core.rememberInfiniteTransition( + label = "crest-pulse", + ) + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = androidx.compose.animation.core.infiniteRepeatable( + androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing), + androidx.compose.animation.core.RepeatMode.Restart, + ), + label = "phase", + ) + val ringColor = + if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live + else CastarrColors.accent + Canvas(Modifier.size(30.dp)) { + val grow = phase.coerceAtMost(0.7f) / 0.7f + drawCircle( + color = ringColor.copy(alpha = (1f - grow) * 0.9f), + radius = size.minDimension * (0.33f + grow * 0.17f), + style = Stroke(width = 2.dp.toPx()), + ) + } + } SubcomposeAsyncImage( model = "file:///android_asset/crests/${team.key}.png", contentDescription = null, 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 c45d035..e779a75 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -79,6 +80,7 @@ fun SettingsScreen(state: AppState) { val profiles by state.dispatcharr.profiles.collectAsState() var updateStatus by remember { mutableStateOf("") } var picker by remember { mutableStateOf(null) } + var clubPicker by remember { mutableStateOf(false) } // Bumped on reset so the QR code and the four-digit code redraw. var pairingEpoch by remember { mutableStateOf(0) } @@ -191,6 +193,7 @@ fun SettingsScreen(state: AppState) { SettingRow( club.fullName, subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen", + leading = { ClubCrest(club, state) }, trailing = { RemoveIcon() }, ) { state.toggleTeam(club.key) } } @@ -198,17 +201,7 @@ fun SettingsScreen(state: AppState) { 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) } - } - } + ) { clubPicker = true } } } @@ -320,6 +313,155 @@ fun SettingsScreen(state: AppState) { picker?.let { current -> PickerDialog(current) { picker = null } } + if (clubPicker) { + ClubPickerDialog( + state = state, + onPick = { club -> state.toggleTeam(club.key); clubPicker = false }, + onClose = { clubPicker = false }, + ) + } +} + +/** Club crest from the bundled assets; initials while it loads. */ +@Composable +private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) { + Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { + coil.compose.SubcomposeAsyncImage( + model = "file:///android_asset/crests/${team.key}.png", + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + loading = { CrestInitials(team) }, + error = { CrestInitials(team) }, + ) + } +} + +@Composable +private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) { + Box( + Modifier + .fillMaxSize() + .clip(CircleShape) + .background(team.primary), + contentAlignment = Alignment.Center, + ) { + Text( + team.label.take(3), + color = team.secondary, + fontFamily = AppFont, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +/** + * Club chooser: crests plus division headers, because a flat list of 56 + * names is unreadable on a remote. + */ +@Composable +private fun ClubPickerDialog( + state: AppState, + onPick: (dev.castarr.tv.data.TeamFilter) -> Unit, + onClose: () -> Unit, +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } + val sections = remember(state.enabledTeams) { + dev.castarr.tv.data.TeamFilters.byLeague( + dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams } + ) + } + val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box( + Modifier + .fillMaxSize() + .background(CastarrColors.bgDeep.copy(alpha = 0.88f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .width(420.dp) + .clip(RoundedCornerShape(16.dp)) + .background(CastarrColors.surface) + .padding(horizontal = 10.dp, vertical = 14.dp) + ) { + Text( + "VEREIN HINZUFÜGEN", + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 2.sp, + modifier = Modifier.padding(start = 14.dp, bottom = 10.dp), + ) + LazyColumn(Modifier.heightIn(max = 460.dp)) { + sections.forEach { (league, clubs) -> + item(key = "h-$league") { + Text( + league, + color = CastarrColors.accent, + fontFamily = AppFont, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp), + ) + } + items(clubs, key = { it.key }) { club -> + val focusMod = + if (club.key == firstKey) Modifier.focusRequester(firstFocus) + else Modifier + Surface( + onClick = { onPick(club) }, + modifier = Modifier + .fillMaxWidth() + .then(focusMod), + shape = ClickableSurfaceDefaults.shape(rowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + colors = ClickableSurfaceDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + contentColor = CastarrColors.fg, + focusedContainerColor = CastarrColors.surfaceFocused, + focusedContentColor = CastarrColors.fg, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, CastarrColors.accent), + shape = rowShape, + ), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ClubCrest(club, state, size = 24) + Spacer(Modifier.width(12.dp)) + Text( + club.fullName, + fontFamily = AppFont, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + club.label, + color = CastarrColors.faint, + fontFamily = AppFont, + fontSize = 12.sp, + ) + } + } + } + } + } + } + } + } } /** TV-friendly dropdown replacement: fullscreen scrim, options centered. */ @@ -512,6 +654,7 @@ private fun SettingRow( label: String, subtitle: String? = null, danger: Boolean = false, + leading: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null, onClick: () -> Unit, ) { @@ -537,6 +680,10 @@ private fun SettingRow( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (leading != null) { + leading() + Spacer(Modifier.width(12.dp)) + } Column(Modifier.weight(1f)) { Text( label, diff --git a/tests/demo/make-demo-data.py b/tests/demo/make-demo-data.py new file mode 100755 index 0000000..90d5619 --- /dev/null +++ b/tests/demo/make-demo-data.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Neutral demo line-up for screenshots and manual testing. + +Invented channels and programmes only — no real provider data ever ends up +in a screenshot. One club match is placed a few minutes ahead so the club +menu shows its "kick-off imminent" state. +""" +import argparse +import datetime +import os + +p = argparse.ArgumentParser() +p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__))) +p.add_argument("--kickoff-in", type=int, default=18, + help="minutes until the club match starts") +args = p.parse_args() + +now = datetime.datetime.now().astimezone() + + +def fmt(dt): + # Local offset, so the emulator's clock agrees with ours (CEST vs CET). + return dt.strftime("%Y%m%d%H%M%S %z") + + +CHANNELS = [ + ("Unterhaltung", "Blau TV HD", "blau"), + ("Unterhaltung", "Nordlicht HD", "nordlicht"), + ("Unterhaltung", "Kanal Sieben", "sieben"), + ("Filme", "Kinohaus HD", "kinohaus"), + ("Filme", "Filmwerk Classic", "filmwerk"), + ("Filme", "Nachtkino HD", "nachtkino"), + ("Serien", "Serienbox HD", "serienbox"), + ("Serien", "Staffel Eins", "staffel"), + ("Sport", "Arena Sport 1 HD", "arena1"), + ("Sport", "Arena Sport 2 HD", "arena2"), + ("Sport", "Liga Live UHD", "ligalive"), + ("Sport", "Stadionkanal HD", "stadion"), + ("Nachrichten", "Tagesblick HD", "tagesblick"), + ("Nachrichten", "Weltbericht", "weltbericht"), + ("Doku & Wissen", "Terra Doku HD", "terra"), + ("Doku & Wissen", "Wissenswelt", "wissen"), + ("Kinder", "Krümelkiste", "kruemel"), + ("Musik", "Tonspur HD", "tonspur"), + ("Regional", "Hafenkanal HD", "hafen"), + ("International", "Globus TV", "globus"), +] + +PROGRAMMES = { + "blau": [("Morgenmagazin", -40, 80), ("Kochduell", 40, 60)], + "nordlicht": [("Küstenrevier", -20, 70), ("Nordlicht Talk", 50, 45)], + "sieben": [("Quiz um Sieben", -10, 60), ("Sieben Reportage", 50, 45)], + "kinohaus": [("Der weite Weg", -55, 125), ("Sturmhöhe", 70, 110)], + "filmwerk": [("Schwarzweiß", -30, 95), ("Die Erbschaft", 65, 100)], + "nachtkino": [("Mitternachtszug", -15, 105), ("Letzte Runde", 90, 95)], + "serienbox": [("Praxis Ostwind (3/12)", -25, 45), ("Praxis Ostwind (4/12)", 20, 45)], + "staffel": [("Hafenwache (7/10)", -35, 50), ("Hafenwache (8/10)", 15, 50)], + "arena1": [("Handball: Pokalrunde", -45, 105), ("Sport am Abend", 60, 60)], + "arena2": [("Radsport: Etappe 4", -50, 130), ("Motorsport kompakt", 80, 55)], + "ligalive": [("Sport aktuell", -25, 43), ("__CLUB__", None, 120)], + "stadion": [("Stadionmagazin", -20, 60), ("FC St. Pauli: Der Rückblick", 40, 45)], + "tagesblick": [("Tagesblick am Mittag", -12, 30), ("Wetter und Verkehr", 18, 15)], + "weltbericht": [("Weltbericht Spezial", -35, 75), ("Auslandsjournal", 40, 45)], + "terra": [("Wale der Arktis", -40, 90), ("Vulkane Islands", 50, 45)], + "wissen": [("Wie Brücken halten", -20, 45), ("Chemie des Alltags", 25, 45)], + "kruemel": [("Bärenbande", -15, 25), ("Malstunde", 10, 25)], + "tonspur": [("Charts der Woche", -60, 120), ("Akustik-Session", 60, 60)], + "hafen": [("Hafenrundfahrt", -30, 60), ("Regional um sechs", 30, 30)], + "globus": [("Globus Reportage", -45, 90), ("Fernweh", 45, 60)], +} + +CLUB_TITLE = "Pokal: Nordstadt - FC St. Pauli, 1. Runde" + +os.makedirs(args.out, exist_ok=True) +m3u = os.path.join(args.out, "playlist.m3u") +xml = os.path.join(args.out, "epg.xml") + +with open(m3u, "w", encoding="utf-8") as f: + f.write("#EXTM3U\n") + for group, name, cid in CHANNELS: + f.write(f'#EXTINF:-1 tvg-id="{cid}" group-title="{group}",{name}\n') + f.write(f"http://10.0.2.2:8099/stream/{cid}.ts\n") + + +def escape(text): + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +with open(xml, "w", encoding="utf-8") as f: + f.write('\n\n') + for _, name, cid in CHANNELS: + f.write(f' {escape(name)}\n') + for cid, items in PROGRAMMES.items(): + for title, offset, length in items: + if title == "__CLUB__": + title, offset = CLUB_TITLE, args.kickoff_in + start = now + datetime.timedelta(minutes=offset) + stop = start + datetime.timedelta(minutes=length) + f.write( + f' ' + f"{escape(title)}\n" + ) + f.write("\n") + +print(f"{len(CHANNELS)} Sender, {sum(len(v) for v in PROGRAMMES.values())} Sendungen") +print(f"Anstoß in {args.kickoff_in} Min: {CLUB_TITLE}") +print(f"-> {m3u}\n-> {xml}")