Club menus look ahead to midnight and count repeat broadcasts
All checks were successful
Build TV app / build (push) Successful in 3m9s
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:
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 32
|
versionCode = 33
|
||||||
versionName = "0.10.6"
|
versionName = "0.11.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -170,22 +170,31 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
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
|
* Channels showing the viewer's club within the scanned window, paired
|
||||||
* with the programme that matched — earliest kick-off first, so whatever
|
* with the programme that matched — earliest kick-off first, so whatever
|
||||||
* is running right now sits on top.
|
* 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 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()
|
return activeChannels()
|
||||||
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||||
.mapNotNull { channel ->
|
.mapNotNull { channel ->
|
||||||
upcoming(channel, window)
|
// A channel can carry several of the club's broadcasts in one
|
||||||
.firstOrNull { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
// evening; the row names the next and counts the rest.
|
||||||
?.let { channel to it }
|
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() {
|
fun refreshActive() {
|
||||||
|
|||||||
@@ -29,8 +29,25 @@ data class TeamFilter(
|
|||||||
|
|
||||||
object TeamFilters {
|
object TeamFilters {
|
||||||
|
|
||||||
/** Window scanned ahead of now. */
|
/** Minimum window scanned ahead of now. */
|
||||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
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. */
|
/** At most this many club groups sit in the rail at once. */
|
||||||
const val MAX_ACTIVE = 3
|
const val MAX_ACTIVE = 3
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
activeTeam != null ->
|
activeTeam != null ->
|
||||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||||
state.favoritesOnly ->
|
state.favoritesOnly ->
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
else -> "Diese Gruppe ist leer."
|
else -> "Diese Gruppe ist leer."
|
||||||
@@ -351,7 +351,7 @@ private fun ChannelRow(
|
|||||||
number: Int,
|
number: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
highlight: dev.castarr.tv.data.Programme? = null,
|
highlight: AppState.TeamHit? = null,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
epgStamp: Long,
|
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
|
@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
|
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
@@ -536,7 +537,8 @@ private fun HighlightCell(programme: dev.castarr.tv.data.Programme, modifier: Mo
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}",
|
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||||
|
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
SettingsCard("Vereinsmenüs") {
|
SettingsCard("Vereinsmenüs") {
|
||||||
Text(
|
Text(
|
||||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||||
"nächsten 3 Stunden läuft. Bis zu " +
|
"heute noch läuft. Bis zu " +
|
||||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ class TeamFiltersTest {
|
|||||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
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
|
@Test
|
||||||
fun `covers all three divisions`() {
|
fun `covers all three divisions`() {
|
||||||
assertTrue(TeamFilters.all.size > 50)
|
assertTrue(TeamFilters.all.size > 50)
|
||||||
|
|||||||
Reference in New Issue
Block a user