Club menus look ahead to midnight and count repeat broadcasts
All checks were successful
Build TV app / build (push) Successful in 3m9s

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 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 10:01:14 +02:00
parent 73f6dbba75
commit 9a0fa37e55
6 changed files with 64 additions and 17 deletions

View File

@@ -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

View File

@@ -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<Pair<Channel, dev.castarr.tv.data.Programme>> {
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
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() {

View File

@@ -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

View File

@@ -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,

View File

@@ -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),

View File

@@ -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)