From 9a0fa37e551753848bab74181ed993966a2f5a6c Mon Sep 17 00:00:00 2001 From: be-nj Date: Wed, 26 Aug 2026 10:01:14 +0200 Subject: [PATCH] Club menus look ahead to midnight and count repeat broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-hour window was empty most of the day, which is exactly when someone wants to know whether their club plays tonight. The scan now runs until midnight, never less than three hours, so a late-evening question still covers the rest of the night. A channel can carry several of the club's broadcasts in one evening — a preview at 18:20 and the match at 21:45 on the same station. The row names the next one and appends "+n weitere" instead of silently hiding the rest, so the list stays a channel list where one click means one station. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 4 ++-- app/src/main/java/dev/castarr/tv/AppState.kt | 21 +++++++++++++------ .../java/dev/castarr/tv/data/TeamFilters.kt | 21 +++++++++++++++++-- .../main/java/dev/castarr/tv/ui/LiveScreen.kt | 12 ++++++----- .../java/dev/castarr/tv/ui/SettingsScreen.kt | 4 ++-- tests/unit/TeamFiltersTest.kt | 19 +++++++++++++++++ 6 files changed, 64 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3af78c9..4794e17 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 = 32 - versionName = "0.10.6" + versionCode = 33 + versionName = "0.11.0" } // 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 b2f79c3..1377da4 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -170,22 +170,31 @@ class AppState( 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 * with the programme that matched — earliest kick-off first, so whatever * is running right now sits on top. */ - fun teamMatches(key: String): List> { + fun teamMatches(key: String): List> { 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() .filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) } .mapNotNull { channel -> - upcoming(channel, window) - .firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) } - ?.let { channel to it } + // A channel can carry several of the club's broadcasts in one + // evening; the row names the next and counts the rest. + 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() { 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 4a4841e..3a3acc1 100644 --- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt +++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt @@ -29,8 +29,25 @@ data class TeamFilter( object TeamFilters { - /** Window scanned ahead of now. */ - const val WINDOW_MS = 3 * 60 * 60 * 1000L + /** Minimum window scanned ahead of now. */ + 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. */ const val MAX_ACTIVE = 3 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 7d1b7bc..858e0ef 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) { Text( when { activeTeam != null -> - "In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}." + "Heute läuft nichts mehr mit ${activeTeam.fullName}." state.favoritesOnly -> "Noch keine Favoriten — halte OK auf einem Sender gedrückt." else -> "Diese Gruppe ist leer." @@ -351,7 +351,7 @@ private fun ChannelRow( number: Int, modifier: Modifier = Modifier, nowNext: NowNext, - highlight: dev.castarr.tv.data.Programme? = null, + highlight: AppState.TeamHit? = null, playing: Boolean, favorite: Boolean, 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 -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 Column(modifier) { Row(verticalAlignment = Alignment.Bottom) { @@ -536,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo } Spacer(Modifier.height(6.dp)) Text( - "${formatClock(programme.start)}–${formatClock(programme.stop)}", + "${formatClock(programme.start)}–${formatClock(programme.stop)}" + + if (hit.further > 0) " · +${hit.further} weitere" else "", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, 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 ad09592..bf50d46 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -178,8 +178,8 @@ fun SettingsScreen(state: AppState) { SettingsCard("Vereinsmenüs") { Text( - "Eigene Gruppe mit allen Sendern, auf denen der Verein in den " + - "nächsten 3 Stunden läuft. Bis zu " + + "Eigene Gruppe mit allen Sendern, auf denen der Verein " + + "heute noch 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), diff --git a/tests/unit/TeamFiltersTest.kt b/tests/unit/TeamFiltersTest.kt index 4ba86c0..5a337dc 100644 --- a/tests/unit/TeamFiltersTest.kt +++ b/tests/unit/TeamFiltersTest.kt @@ -51,6 +51,25 @@ class TeamFiltersTest { 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 fun `covers all three divisions`() { assertTrue(TeamFilters.all.size > 50)