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)