Add Events sport chips and shelves with sportBucket (TV 0.14.65).

Normalize FAAB events into stable sport buckets on master-api, group the TV Events tab by sport (with client fallback), and bump Android TV to 128/0.14.65.
This commit is contained in:
Jos Vooges | STH 2026-09-19 14:26:43 +02:00
parent d1b627752c
commit 796edf36db
7 changed files with 647 additions and 90 deletions

View file

@ -671,14 +671,34 @@ class ApiClient(
}
}
}
val category = optClean("category")
val eventType = optClean("eventType")
val subtitle = optClean("subtitle")
val apiBucket = optClean("sportBucket")
val apiLabel = optClean("sportLabel")
val sportBucket = apiBucket ?: "other"
val sportLabel = apiLabel ?: when (sportBucket) {
"football" -> "Voetbal"
"racing" -> "Racing"
"tennis" -> "Tennis"
"golf" -> "Golf"
"darts" -> "Darts"
"snooker" -> "Snooker"
"basketball" -> "Basketbal"
"waterpolo" -> "Waterpolo"
"sailing" -> "Zeilen"
"hockey" -> "Hockey"
"cycling" -> "Wielrennen"
else -> "Overig"
}
return ScheduleEvent(
id = id,
name = name,
description = optClean("description"),
provider = optClean("provider") ?: "unknown",
category = optClean("category"),
eventType = optClean("eventType"),
subtitle = optClean("subtitle"),
category = category,
eventType = eventType,
subtitle = subtitle,
channel = optClean("channel"),
network = optClean("network"),
isLive = optBoolean("isLive"),
@ -689,6 +709,8 @@ class ApiClient(
stop = optClean("stop") ?: "",
startLocal = optClean("startLocal"),
stopLocal = optClean("stopLocal"),
sportBucket = sportBucket,
sportLabel = sportLabel,
imageLandscape = optClean("imageLandscape"),
imageBackdrop = optClean("imageBackdrop"),
imagePortrait = optClean("imagePortrait"),
@ -1028,6 +1050,8 @@ data class ScheduleEvent(
val stop: String = "",
val startLocal: String? = null,
val stopLocal: String? = null,
val sportBucket: String = "other",
val sportLabel: String = "Overig",
val imageLandscape: String? = null,
val imageBackdrop: String? = null,
val imagePortrait: String? = null,

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 127
versionName = "0.14.64"
versionCode = 128
versionName = "0.14.65"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -708,14 +708,24 @@ class ApiClient(
}
}
}
val category = optClean("category")
val eventType = optClean("eventType")
val subtitle = optClean("subtitle")
val apiBucket = optClean("sportBucket")
val apiLabel = optClean("sportLabel")
val resolved = if (!apiBucket.isNullOrBlank()) {
apiBucket to (apiLabel ?: sportLabelForBucket(apiBucket))
} else {
resolveSportBucketLocal(category, eventType, name, subtitle)
}
return ScheduleEvent(
id = id,
name = name,
description = optClean("description"),
provider = optClean("provider") ?: "unknown",
category = optClean("category"),
eventType = optClean("eventType"),
subtitle = optClean("subtitle"),
category = category,
eventType = eventType,
subtitle = subtitle,
channel = optClean("channel"),
network = optClean("network"),
isLive = optBoolean("isLive"),
@ -726,6 +736,8 @@ class ApiClient(
stop = optClean("stop") ?: "",
startLocal = optClean("startLocal"),
stopLocal = optClean("stopLocal"),
sportBucket = resolved.first,
sportLabel = resolved.second,
imageLandscape = optClean("imageLandscape"),
imageBackdrop = optClean("imageBackdrop"),
imagePortrait = optClean("imagePortrait"),
@ -1114,6 +1126,9 @@ data class ScheduleEvent(
val stop: String = "",
val startLocal: String? = null,
val stopLocal: String? = null,
/** Stable sport id (`football`, `racing`, …, `other`); client-fallback if API oud. */
val sportBucket: String = "other",
val sportLabel: String = "Overig",
val imageLandscape: String? = null,
val imageBackdrop: String? = null,
val imagePortrait: String? = null,
@ -1122,6 +1137,112 @@ data class ScheduleEvent(
val audioLanguages: List<String> = emptyList(),
)
/** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
val SportBucketOrder = listOf(
"football", "racing", "tennis", "golf", "darts", "snooker",
"basketball", "waterpolo", "sailing", "hockey", "cycling", "other",
)
fun sportLabelForBucket(bucket: String): String = when (bucket) {
"football" -> "Voetbal"
"racing" -> "Racing"
"tennis" -> "Tennis"
"golf" -> "Golf"
"darts" -> "Darts"
"snooker" -> "Snooker"
"basketball" -> "Basketbal"
"waterpolo" -> "Waterpolo"
"sailing" -> "Zeilen"
"hockey" -> "Hockey"
"cycling" -> "Wielrennen"
else -> "Overig"
}
/** Client fallback when API nog geen sportBucket levert. */
fun resolveSportBucketLocal(
category: String?,
eventType: String?,
name: String?,
subtitle: String?,
): Pair<String, String> {
fun label(b: String) = b to sportLabelForBucket(b)
val cat = category?.trim().orEmpty()
val type = eventType?.trim().orEmpty()
val hay = listOfNotNull(name, subtitle).joinToString(" ")
if (Regex("""\b(documentaire|documentary|talkshow|talk\s*show)\b""", RegexOption.IGNORE_CASE).containsMatchIn(cat)) {
return label("other")
}
val token = cat.split(Regex("""[,/|·•]"""))
.firstOrNull()?.trim()?.lowercase().orEmpty()
val noise = setOf("all", "upcoming", "live", "overig", "other", "vod", "documentaire", "documentary", "talkshow", "talk", "instant", "restart", "")
if (token.isNotEmpty() && token !in noise) {
val fromCat = when (token) {
"voetbal", "football", "soccer" -> "football"
"racing", "motorsport", "motorsports", "formule", "formula" -> "racing"
"tennis" -> "tennis"
"golf" -> "golf"
"darts" -> "darts"
"snooker" -> "snooker"
"basketbal", "basketball", "nba" -> "basketball"
"waterpolo" -> "waterpolo"
"zeilen", "sailing", "sail" -> "sailing"
"hockey", "ijshockey" -> "hockey"
"wielrennen", "cycling" -> "cycling"
else -> null
}
if (fromCat != null) return label(fromCat)
}
if (type.isNotEmpty() && !Regex("""\binstant\s*restart\b""", RegexOption.IGNORE_CASE).containsMatchIn(type)) {
val typeRules = listOf(
Regex("""\b(formula\s*[1-4e]|f1|f2|f3|f4|motogp|indycar|imsa|nascar|wec|dtm|rally|ferrari\s*challenge|goodwood|festival\s*of\s*speed)\b""", RegexOption.IGNORE_CASE) to "racing",
Regex("""\b(atp|wta|davis\s*cup|billie\s*jean|grand\s*slam|wimbledon|roland\s*garros|us\s*open|australian\s*open)\b""", RegexOption.IGNORE_CASE) to "tennis",
Regex("""\b(pga|lpga|european\s*(pga\s*)?tour|masters|ryder\s*cup|dp\s*world|liv\s*golf)\b""", RegexOption.IGNORE_CASE) to "golf",
Regex("""\b(pdc|darts|world\s*matchplay|uk\s*open)\b""", RegexOption.IGNORE_CASE) to "darts",
Regex("""\b(snooker|world\s*snooker|uk\s*championship)\b""", RegexOption.IGNORE_CASE) to "snooker",
Regex("""\b(nba|ncaa|euroleague|basketball|basketbal)\b""", RegexOption.IGNORE_CASE) to "basketball",
Regex("""\b(waterpolo|water\s*polo)\b""", RegexOption.IGNORE_CASE) to "waterpolo",
Regex("""\b(sail\s*gp|sailing|zeilen)\b""", RegexOption.IGNORE_CASE) to "sailing",
Regex("""\b(nhl|hockey|ijshockey|field\s*hockey)\b""", RegexOption.IGNORE_CASE) to "hockey",
Regex("""\b(tour\s*de\s*france|giro|vuelta|uci|wielrennen|cycling)\b""", RegexOption.IGNORE_CASE) to "cycling",
Regex("""\b(premier\s*league|bundesliga|ligue\s*[12]|la\s*liga|serie\s*a|eredivisie|fa\s*cup|uefa|champions\s*league|europa\s*league|conference\s*league|copa|mls|nwsl|womens?\s*super\s*league|damallsvenskan|frauen[- ]?bundesliga|2\.\s*bundesliga|championship)\b""", RegexOption.IGNORE_CASE) to "football",
)
for ((re, bucket) in typeRules) {
if (re.containsMatchIn(type)) return label(bucket)
}
}
if (hay.isNotBlank()) {
val nameRules = listOf(
Regex("""\b(formule\s*1|formula\s*[1-4]|f1\b|motogp|nascar|indycar|imsa|race\s*caf|racing|motorsport|grand\s*prix)\b""", RegexOption.IGNORE_CASE) to "racing",
Regex("""\b(tennis|atp|wta|davis\s*cup)\b""", RegexOption.IGNORE_CASE) to "tennis",
Regex("""\b(golf|pga\b|lpga)\b""", RegexOption.IGNORE_CASE) to "golf",
Regex("""\b(darts|pdc)\b""", RegexOption.IGNORE_CASE) to "darts",
Regex("""\b(snooker)\b""", RegexOption.IGNORE_CASE) to "snooker",
Regex("""\b(nba|basketbal|basketball)\b""", RegexOption.IGNORE_CASE) to "basketball",
Regex("""\b(waterpolo)\b""", RegexOption.IGNORE_CASE) to "waterpolo",
Regex("""\b(sail\s*gp|zeilen)\b""", RegexOption.IGNORE_CASE) to "sailing",
Regex("""\b(hockey|ijshockey)\b""", RegexOption.IGNORE_CASE) to "hockey",
Regex("""\b(wielrennen|tour\s*de\s*france|cycling)\b""", RegexOption.IGNORE_CASE) to "cycling",
Regex("""\b(voetbal|football|soccer|eredivisie|premier\s*league|bundesliga|champions\s*league|europa\s*league)\b""", RegexOption.IGNORE_CASE) to "football",
)
for ((re, bucket) in nameRules) {
if (re.containsMatchIn(hay)) return label(bucket)
}
if (
(Regex("""^[A-Za-z0-9 .'+]+:\s+.+\s[-–—]\s+.+""", RegexOption.IGNORE_CASE).containsMatchIn(hay) ||
Regex("""\b(vs\.?|versus)\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay)) &&
!Regex("""\b(documentaire|talkshow|show|hoogtepunten|highlights|vandaag|today|stories)\b""", RegexOption.IGNORE_CASE)
.containsMatchIn(hay)
) {
return label("football")
}
}
return label("other")
}
data class IptvChannel(
val streamId: String,
val name: String,

View file

@ -64,6 +64,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ScheduleEvent
import nl.vonas.mediacluster.tv.data.SportBucketOrder
import nl.vonas.mediacluster.tv.data.sportLabelForBucket
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.time.Instant
@ -96,9 +98,11 @@ fun EventsScreen(
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sportFilter by remember { mutableStateOf<String?>(null) }
var providerFilter by remember { mutableStateOf<String?>(null) }
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
var chipsFocusReady by remember { mutableStateOf(false) }
var sportChipsFocusReady by remember { mutableStateOf(false) }
var providerChipsFocusReady by remember { mutableStateOf(false) }
/** Owner-id van first-rail FocusRequester — voorkomt dispose-race live↔upcoming. */
var railFocusOwner by remember { mutableStateOf<String?>(null) }
val railFocusReady = railFocusOwner != null
@ -107,6 +111,7 @@ fun EventsScreen(
val listState = rememberLazyListState()
val firstRailFocus = remember { FocusRequester() }
val restoreCardFocus = remember { FocusRequester() }
val providerRowFocus = remember { FocusRequester() }
suspend fun refresh() {
runCatching { api.listScheduleEvents() }
@ -132,47 +137,91 @@ fun EventsScreen(
}
}
val filtered = remember(events, providerFilter) {
if (providerFilter.isNullOrBlank()) events
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
}
// Client-side time window + API flags — geen hero-art filter op rails.
val live = remember(filtered, nowMs) {
filtered.filter { eventIsLive(it, nowMs) }
.sortedByDescending { it.playable }
}
val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
val providers = remember(events) {
events.map { it.provider }.distinct().sorted()
}
val showProviderChips = providers.size > 1
val upcomingRails = remember(byDay, nowMs) {
byDay.mapNotNull { (label, dayEvents) ->
val upcoming = dayEvents.filter { !eventIsLive(it, nowMs) }
if (upcoming.isEmpty()) null else label to upcoming
// Provider filter first → sport chips only from that subset.
val providerFiltered = remember(events, providerFilter) {
if (providerFilter.isNullOrBlank()) events
else events.filter { it.provider.equals(providerFilter, ignoreCase = true) }
}
val sportChips = remember(providerFiltered) {
val present = providerFiltered.map { it.sportBucket }.toSet()
SportBucketOrder.filter { it in present }.map { it to sportLabelForBucket(it) }
}
val showSportChips = sportChips.isNotEmpty()
val filtered = remember(providerFiltered, sportFilter) {
if (sportFilter.isNullOrBlank()) providerFiltered
else providerFiltered.filter { it.sportBucket == sportFilter }
}
val live = remember(filtered, nowMs) {
filtered.filter { eventIsLive(it, nowMs) }
.sortedWith(eventRailComparator(nowMs))
}
val upcoming = remember(filtered, nowMs) {
filtered.filter { !eventIsLive(it, nowMs) }
.sortedWith(eventRailComparator(nowMs))
}
// Alles: sport-shelves (upcoming). Specifieke sport: dag-rails.
val sportShelves = remember(upcoming, sportFilter) {
if (sportFilter != null) emptyList()
else {
val byBucket = upcoming.groupBy { it.sportBucket }
SportBucketOrder.mapNotNull { bucket ->
val items = byBucket[bucket] ?: return@mapNotNull null
if (items.isEmpty()) null
else bucket to (sportLabelForBucket(bucket) to items)
}.map { (bucket, pair) -> Triple(bucket, pair.first, pair.second) }
}
}
val byDayRails = remember(upcoming, sportFilter, nowMs) {
if (sportFilter == null) emptyList()
else groupEventsByDay(upcoming).mapNotNull { (label, dayEvents) ->
val sorted = dayEvents.sortedWith(eventRailComparator(nowMs))
if (sorted.isEmpty()) null else label to sorted
}
}
val upcomingRails = if (sportFilter == null) {
sportShelves.map { (_, label, items) -> label to items }
} else {
byDayRails
}
val hasContent = filtered.isNotEmpty()
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
val firstRailOwnerId = when {
live.isNotEmpty() -> "live"
upcomingRails.isNotEmpty() -> "day-${upcomingRails.first().first}"
upcomingRails.isNotEmpty() -> {
if (sportFilter == null) "sport-${sportShelves.first().first}"
else "day-${upcomingRails.first().first}"
}
else -> null
}
// Focus: TopNav → chips (optioneel) → first rail card.
// Films/Home-patroon: focusProperties.down alleen naar een gebonden FocusRequester
// (of Cancel bij lege filter) — nooit naar uninitialized firstRailFocus.
// Focus: TopNav → sport chips → provider chips → first rail.
val showAnyChips = showSportChips || showProviderChips
val firstRailUpTarget = when {
showProviderChips && chipsFocusReady -> contentFocusRequester
showProviderChips && providerChipsFocusReady -> providerRowFocus
showSportChips && sportChipsFocusReady -> contentFocusRequester
else -> navFocusRequester
}
// Chip/TopNav down-target: ready rail, anders Cancel (geen spatial crash) of weglaten.
val chipFocusDown: FocusRequester? = when {
val sportChipFocusDown: FocusRequester? = when {
showProviderChips -> if (providerChipsFocusReady) providerRowFocus else null
!hasRails -> FocusRequester.Cancel
railFocusReady -> firstRailFocus
else -> null
}
val providerChipFocusDown: FocusRequester? = when {
!hasRails -> FocusRequester.Cancel
railFocusReady -> firstRailFocus
else -> null
}
val providerChipFocusUp = when {
showSportChips && sportChipsFocusReady -> contentFocusRequester
else -> navFocusRequester
}
fun claimRailFocus(owner: String, ready: Boolean) {
if (ready) {
@ -183,18 +232,15 @@ fun EventsScreen(
}
// Filter-wissel: scroll omhoog + clear owner zodat down pas opnieuw linkt na re-bind.
// Focus blijft op chip; géén force requestFocus hier.
LaunchedEffect(providerFilter) {
LaunchedEffect(providerFilter, sportFilter) {
railFocusOwner = null
runCatching { listState.scrollToItem(0) }
}
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
// Geen hero/top-spacer meer: index 0 = chips of eerste rail.
var idx = 0
if (showProviderChips) idx++
idx
// Drop sport filter if bucket verdwijnt na provider-wissel.
LaunchedEffect(sportChips, sportFilter) {
if (sportFilter != null && sportChips.none { it.first == sportFilter }) {
sportFilter = null
}
}
val restoreInLive = remember(live, restoreEventId) {
@ -206,6 +252,7 @@ fun EventsScreen(
}
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
val restoreListIndex = remember(
showSportChips,
showProviderChips,
live,
upcomingRails,
@ -214,13 +261,13 @@ fun EventsScreen(
) {
if (!canRestoreCard) return@remember -1
var idx = 0
if (showSportChips) idx++
if (showProviderChips) idx++
if (restoreInLive) return@remember idx
if (live.isNotEmpty()) idx++
if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex
-1
}
// Eerste card van de eerste rail deelt firstRailFocus — géén tweede FocusRequester (dual-bind hang).
val restoreIsFirstOfFirstRail = remember(live, upcomingRails, restoreEventId, restoreInLive) {
if (restoreEventId.isNullOrBlank()) return@remember false
val firstRail = when {
@ -237,7 +284,7 @@ fun EventsScreen(
val activeRestoreFocus = when {
!canRestoreCard -> null
restoreIsFirstOfFirstRail -> when {
showProviderChips -> firstRailFocus
showAnyChips -> firstRailFocus
else -> contentFocusRequester
}
else -> restoreCardFocus
@ -255,14 +302,15 @@ fun EventsScreen(
}
val entryReady = when {
showProviderChips -> chipsFocusReady
showSportChips -> sportChipsFocusReady
showProviderChips -> providerChipsFocusReady
else -> railFocusReady
}
// TopNav.down moet altijd een gebonden target hebben (Home-patroon: sink tot owner klaar).
val needContentSink = when {
loading && events.isEmpty() -> true
error != null && events.isEmpty() -> true
showProviderChips -> !chipsFocusReady
showSportChips -> !sportChipsFocusReady
showProviderChips -> !providerChipsFocusReady
!hasRails -> true
else -> !railFocusReady
}
@ -286,7 +334,8 @@ fun EventsScreen(
if (restoredSuccessfully) return
val entryOk = awaitReady({
when {
showProviderChips -> chipsFocusReady
showSportChips -> sportChipsFocusReady
showProviderChips -> providerChipsFocusReady
else -> railFocusReady
}
}, timeoutMs = 400)
@ -383,17 +432,34 @@ fun EventsScreen(
) {
// Chips blijven altijd staan (ook bij lege filter) — anders unbindt
// contentFocusRequester en crasht TopNav/D-pad focusSearch.
if (showSportChips) {
item(key = "sports") {
SportChips(
sports = sportChips,
selected = sportFilter,
onSelect = { sportFilter = it },
firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester,
focusDown = sportChipFocusDown,
onFocusReady = { sportChipsFocusReady = it },
onFocus = onContentFocused,
)
}
}
if (showProviderChips) {
item(key = "providers") {
ProviderChips(
providers = providers,
selected = providerFilter,
onSelect = { providerFilter = it },
firstFocusRequester = contentFocusRequester,
focusUp = navFocusRequester,
// Alleen linken als rail gebonden is; Cancel bij geen rails.
focusDown = chipFocusDown,
onFocusReady = { chipsFocusReady = it },
firstFocusRequester = if (showSportChips) {
providerRowFocus
} else {
contentFocusRequester
},
focusUp = providerChipFocusUp,
focusDown = providerChipFocusDown,
onFocusReady = { providerChipsFocusReady = it },
onFocus = onContentFocused,
)
}
@ -407,11 +473,7 @@ fun EventsScreen(
contentAlignment = Alignment.Center,
) {
Text(
if (providerFilter != null) {
"Geen events voor ${prettyProvider(providerFilter!!)}"
} else {
"Geen events in de komende dagen"
},
emptyEventsMessage(sportFilter, providerFilter),
color = McColors.TextDim,
fontSize = 18.sp,
)
@ -426,7 +488,7 @@ fun EventsScreen(
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = if (showProviderChips) {
firstItemFocusRequester = if (showAnyChips) {
firstRailFocus
} else {
contentFocusRequester
@ -443,36 +505,71 @@ fun EventsScreen(
)
}
}
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
val isFirstUpcoming = live.isEmpty() && index == 0
val ownerId = "day-$label"
val railHasRestore = bindRestoreCardId != null &&
!restoreInLive &&
dayEvents.any { it.id == bindRestoreCardId }
item(key = "day-$label") {
EventRail(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showProviderChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = if (isFirstUpcoming) {
{ ready -> claimRailFocus(ownerId, ready) }
} else {
null
},
)
if (sportFilter == null) {
sportShelves.forEachIndexed { index, (bucket, label, items) ->
val isFirstUpcoming = live.isEmpty() && index == 0
val ownerId = "sport-$bucket"
val railHasRestore = bindRestoreCardId != null &&
!restoreInLive &&
items.any { it.id == bindRestoreCardId }
item(key = "sport-$bucket") {
EventRail(
title = label,
events = items,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showAnyChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = if (isFirstUpcoming) {
{ ready -> claimRailFocus(ownerId, ready) }
} else {
null
},
)
}
}
} else {
byDayRails.forEachIndexed { index, (label, dayEvents) ->
val isFirstUpcoming = live.isEmpty() && index == 0
val ownerId = "day-$label"
val railHasRestore = bindRestoreCardId != null &&
!restoreInLive &&
dayEvents.any { it.id == bindRestoreCardId }
item(key = "day-$label") {
EventRail(
title = label,
events = dayEvents,
nowMs = nowMs,
onOpen = onOpenEvent,
onFocus = onContentFocused,
firstItemFocusRequester = when {
!isFirstUpcoming -> null
showAnyChips -> firstRailFocus
else -> contentFocusRequester
},
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
onRestoreFocusReady = { ready ->
if (railHasRestore) restoreCardReady = ready
},
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
onFirstFocusReady = if (isFirstUpcoming) {
{ ready -> claimRailFocus(ownerId, ready) }
} else {
null
},
)
}
}
}
}
@ -915,6 +1012,49 @@ private fun EventCard(
}
}
@Composable
private fun SportChips(
sports: List<Pair<String, String>>,
selected: String?,
onSelect: (String?) -> Unit,
firstFocusRequester: FocusRequester? = null,
focusUp: FocusRequester? = null,
focusDown: FocusRequester? = null,
onFocusReady: ((Boolean) -> Unit)? = null,
onFocus: () -> Unit = {},
) {
LazyRow(
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
) {
item {
Chip(
label = "Alles",
active = selected == null,
focusRequester = firstFocusRequester,
focusUp = focusUp,
focusDown = focusDown,
onFocusReady = onFocusReady,
onFocus = onFocus,
onClick = { onSelect(null) },
)
}
items(sports, key = { it.first }) { (bucket, label) ->
Chip(
label = label,
active = selected == bucket,
focusUp = focusUp,
focusDown = focusDown,
onFocus = onFocus,
onClick = { onSelect(bucket) },
)
}
}
}
@Composable
private fun ProviderChips(
providers: List<String>,
@ -1327,6 +1467,23 @@ private fun enrichSingleEvent(
)
}
private fun emptyEventsMessage(sportFilter: String?, providerFilter: String?): String {
val sport = sportFilter?.let { sportLabelForBucket(it) }
val provider = providerFilter?.let { prettyProvider(it) }
return when {
sport != null && provider != null -> "Geen $sport-events voor $provider"
sport != null -> "Geen $sport-events in de komende dagen"
provider != null -> "Geen events voor $provider"
else -> "Geen events in de komende dagen"
}
}
/** Live eerst, daarna starttijd (oud → nieuw). */
private fun eventRailComparator(nowMs: Long): Comparator<ScheduleEvent> =
compareByDescending<ScheduleEvent> { eventIsLive(it, nowMs) }
.thenByDescending { it.playable }
.thenBy { parseZoned(it.startLocal, it.start)?.toInstant()?.toEpochMilli() ?: Long.MAX_VALUE }
private fun groupEventsByDay(events: List<ScheduleEvent>): List<Pair<String, List<ScheduleEvent>>> {
val today = LocalDate.now(ZoneNl)
val groups = linkedMapOf<String, MutableList<ScheduleEvent>>()

View file

@ -6,7 +6,7 @@
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",

View file

@ -0,0 +1,54 @@
import { resolveSportBucket, sportLabelFor } from "./schedule-events";
function assert(cond: unknown, msg: string) {
if (!cond) throw new Error(msg);
}
function expectBucket(
input: { category?: string; eventType?: string; name?: string; subtitle?: string },
bucket: string
) {
const r = resolveSportBucket(input);
assert(r.sportBucket === bucket, `expected ${bucket} got ${r.sportBucket} for ${JSON.stringify(input)}`);
assert(r.sportLabel === sportLabelFor(bucket), `label mismatch for ${bucket}`);
}
// 1) category first token
expectBucket({ category: "Voetbal" }, "football");
expectBucket({ category: "Voetbal, UEFA Europa League" }, "football");
expectBucket({ category: "Racing, MotoGP" }, "racing");
expectBucket({ category: "Tennis, ATP, ATP 500" }, "tennis");
expectBucket({ category: "Golf, USA PGA tour" }, "golf");
expectBucket({ category: "Waterpolo" }, "waterpolo");
// Noise categories → fall through / other
expectBucket({ category: "Documentaire", name: "The Iconic: Voetbal" }, "other");
expectBucket({ category: "Talkshow" }, "other");
expectBucket({ category: "all" }, "other");
expectBucket({ category: "Upcoming" }, "other");
expectBucket({ category: "Live" }, "other");
// 2) eventType / leagues
expectBucket({ category: "all", eventType: "Premier League", name: "Brentford - Chelsea" }, "football");
expectBucket({ category: "all", eventType: "Bundesliga" }, "football");
expectBucket({ category: "all", eventType: "PDC Darts" }, "darts");
expectBucket({ category: "all", eventType: "Formula 4 Italian Championship" }, "racing");
expectBucket({ category: "all", eventType: "Sail GP" }, "sailing");
expectBucket({ category: "all", eventType: "Ferrari Challenge UK" }, "racing");
expectBucket({ category: "all", eventType: "Goodwood Festival of Speed" }, "racing");
expectBucket({ category: "all", eventType: "ATP Tour" }, "tennis");
// Instant restart alone → other (unless name helps)
expectBucket({ category: "Live", eventType: "Instant restart", name: "ESPN: ESPN Vandaag" }, "other");
expectBucket(
{ category: "Live", eventType: "Instant restart", name: "Ziggo Sport: Ziggo Sport Race Café" },
"racing"
);
// 3) name keywords
expectBucket({ name: "NBA Today" }, "basketball");
expectBucket({ name: "Ligue 1: Monaco - Lens" }, "football");
expectBucket({ name: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
expectBucket({ name: "Some snooker final" }, "snooker");
console.log("schedule-events-sport tests ok");

View file

@ -26,6 +26,10 @@ export type ScheduleEventPublic = {
stop: string;
startLocal: string | null;
stopLocal: string | null;
/** Stable sport id for shelves/chips (`football`, `racing`, …, `other`). */
sportBucket: string;
/** Dutch label for sportBucket (Voetbal, Racing, Overig, …). */
sportLabel: string;
/** 16:9 card/cover art (Prime: BOXART-16X9 preferred). */
imageLandscape: string | null;
/** Full-bleed detail/hero backdrop (Prime: HERO-16X9); fall back to landscape on clients. */
@ -36,6 +40,191 @@ export type ScheduleEventPublic = {
audioLanguages: string[];
};
/** Canonical shelf/chip order (Overig last). */
export const SPORT_BUCKET_ORDER = [
"football",
"racing",
"tennis",
"golf",
"darts",
"snooker",
"basketball",
"waterpolo",
"sailing",
"hockey",
"cycling",
"other",
] as const;
export type SportBucketId = (typeof SPORT_BUCKET_ORDER)[number];
const SPORT_LABELS: Record<SportBucketId, string> = {
football: "Voetbal",
racing: "Racing",
tennis: "Tennis",
golf: "Golf",
darts: "Darts",
snooker: "Snooker",
basketball: "Basketbal",
waterpolo: "Waterpolo",
sailing: "Zeilen",
hockey: "Hockey",
cycling: "Wielrennen",
other: "Overig",
};
/** Category first-token → bucket (NL/EN). Noise tokens ignored. */
const CATEGORY_TOKEN_BUCKET: Record<string, SportBucketId> = {
voetbal: "football",
football: "football",
soccer: "football",
racing: "racing",
motorsport: "racing",
motorsports: "racing",
formule: "racing",
formula: "racing",
tennis: "tennis",
golf: "golf",
darts: "darts",
snooker: "snooker",
basketbal: "basketball",
basketball: "basketball",
nba: "basketball",
waterpolo: "waterpolo",
zeilen: "sailing",
sailing: "sailing",
sail: "sailing",
hockey: "hockey",
ijshockey: "hockey",
wielrennen: "cycling",
cycling: "cycling",
sport: "other", // generic — refine via type/name
};
const CATEGORY_NOISE = new Set([
"all",
"upcoming",
"live",
"overig",
"other",
"vod",
"documentaire",
"documentary",
"talkshow",
"talk",
"instant",
"restart",
]);
/** eventType / league → bucket (specific sports before broad "championship") */
const EVENT_TYPE_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(formula\s*[1-4e]|f1|f2|f3|f4|motogp|indycar|imsa|nascar|wec|dtm|rally|ferrari\s*challenge|goodwood|festival\s*of\s*speed)\b/i, bucket: "racing" },
{ re: /\b(atp|wta|davis\s*cup|billie\s*jean|grand\s*slam|wimbledon|roland\s*garros|us\s*open|australian\s*open)\b/i, bucket: "tennis" },
{ re: /\b(pga|lpga|european\s*(pga\s*)?tour|masters|ryder\s*cup|dp\s*world|liv\s*golf)\b/i, bucket: "golf" },
{ re: /\b(pdc|darts|world\s*matchplay|uk\s*open)\b/i, bucket: "darts" },
{ re: /\b(snooker|world\s*snooker|uk\s*championship)\b/i, bucket: "snooker" },
{ re: /\b(nba|ncaa|euroleague|basketball|basketbal)\b/i, bucket: "basketball" },
{ re: /\b(waterpolo|water\s*polo)\b/i, bucket: "waterpolo" },
{ re: /\b(sail\s*gp|sailing|zeilen)\b/i, bucket: "sailing" },
{ re: /\b(nhl|hockey|ijshockey|field\s*hockey)\b/i, bucket: "hockey" },
{ re: /\b(tour\s*de\s*france|giro|vuelta|uci|wielrennen|cycling)\b/i, bucket: "cycling" },
{ re: /\b(premier\s*league|bundesliga|ligue\s*[12]|la\s*liga|serie\s*a|eredivisie|fa\s*cup|uefa|champions\s*league|europa\s*league|conference\s*league|copa|mls|nwsl|womens?\s*super\s*league|damallsvenskan|frauen[- ]?bundesliga|2\.\s*bundesliga|championship)\b/i, bucket: "football" },
];
const NAME_KEYWORD_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(voetbal|football|soccer|eredivisie|premier\s*league|bundesliga|champions\s*league|europa\s*league)\b/i, bucket: "football" },
{ re: /\b(formule\s*1|formula\s*[1-4]|f1\b|motogp|nascar|indycar|imsa|race\s*caf|racing|motorsport|grand\s*prix)\b/i, bucket: "racing" },
{ re: /\b(tennis|atp|wta|davis\s*cup)\b/i, bucket: "tennis" },
{ re: /\b(golf|pga\b|lpga)\b/i, bucket: "golf" },
{ re: /\b(darts|pdc)\b/i, bucket: "darts" },
{ re: /\b(snooker)\b/i, bucket: "snooker" },
{ re: /\b(nba|basketbal|basketball)\b/i, bucket: "basketball" },
{ re: /\b(waterpolo)\b/i, bucket: "waterpolo" },
{ re: /\b(sail\s*gp|zeilen)\b/i, bucket: "sailing" },
{ re: /\b(hockey|ijshockey)\b/i, bucket: "hockey" },
{ re: /\b(wielrennen|tour\s*de\s*france|cycling)\b/i, bucket: "cycling" },
];
export function sportLabelFor(bucket: string): string {
return SPORT_LABELS[(bucket as SportBucketId)] ?? SPORT_LABELS.other;
}
function firstCategoryToken(category: string | null | undefined): string | null {
const raw = str(category);
if (!raw) return null;
const token = raw.split(/[,/|·•]/)[0]?.trim().toLowerCase() ?? "";
if (!token || CATEGORY_NOISE.has(token)) return null;
// "Voetbal, UEFA…" → voetbal
return token;
}
/**
* Resolve a stable sport bucket from category → eventType/leagues → name keywords.
* Documentaire/Talkshow and unknown → `other` / Overig.
*/
export function resolveSportBucket(input: {
category?: string | null;
eventType?: string | null;
name?: string | null;
subtitle?: string | null;
}): { sportBucket: SportBucketId; sportLabel: string } {
const category = str(input.category) || null;
const eventType = str(input.eventType) || null;
const name = str(input.name) || null;
const subtitle = str(input.subtitle) || null;
const hayName = [name, subtitle].filter(Boolean).join(" ");
// Explicit non-sport categories
const catLower = (category ?? "").toLowerCase();
if (
/\b(documentaire|documentary|talkshow|talk\s*show)\b/i.test(catLower) ||
firstCategoryToken(category) === "documentaire" ||
firstCategoryToken(category) === "talkshow"
) {
return { sportBucket: "other", sportLabel: SPORT_LABELS.other };
}
// 1) category first token
const token = firstCategoryToken(category);
if (token) {
const mapped = CATEGORY_TOKEN_BUCKET[token];
if (mapped && mapped !== "other") {
return { sportBucket: mapped, sportLabel: SPORT_LABELS[mapped] };
}
// Unknown but specific token (not noise) — keep scanning type/name; may still be a sport
}
// 2) eventType / leagues
if (eventType && !/\binstant\s*restart\b/i.test(eventType)) {
for (const { re, bucket } of EVENT_TYPE_BUCKET) {
if (re.test(eventType)) {
return { sportBucket: bucket, sportLabel: SPORT_LABELS[bucket] };
}
}
}
// 3) name / subtitle keywords
if (hayName) {
for (const { re, bucket } of NAME_KEYWORD_BUCKET) {
if (re.test(hayName)) {
return { sportBucket: bucket, sportLabel: SPORT_LABELS[bucket] };
}
}
// Match-style: "Ligue 1: Monaco - Lens" / "A vs B" without talk/doc noise
if (
(/^[A-Za-z0-9 .'+]+:\s+.+\s[-–—]\s+.+/i.test(hayName) ||
/\b(vs\.?|versus)\b/i.test(hayName)) &&
!/\b(documentaire|talkshow|show|hoogtepunten|highlights|vandaag|today|stories)\b/i.test(
hayName
)
) {
return { sportBucket: "football", sportLabel: SPORT_LABELS.football };
}
}
return { sportBucket: "other", sportLabel: SPORT_LABELS.other };
}
/** Viewer + globale provider-filter voor list/detail/play. */
export type LiveEventsAccessFilter = {
/** false → 403 op client-endpoints */
@ -382,6 +571,16 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
const channel = cleanChannelLabel(str(raw.epg?.channel) || str(meta.zender));
const network = cleanChannelLabel(str(raw.epg?.network));
const category = str(raw.category) || str(raw.epg?.category) || null;
const eventType = str(meta.type) || null;
const subtitle = str(meta["originele titel"]) || str(raw.epg?.subtitle) || null;
const { sportBucket, sportLabel } = resolveSportBucket({
category,
eventType,
name,
subtitle,
});
return {
id,
name,
@ -390,9 +589,9 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
str(raw.epg?.description) ||
null,
provider: (str(raw.provider) || "unknown").toLowerCase(),
category: str(raw.category) || str(raw.epg?.category) || null,
eventType: str(meta.type) || null,
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
category,
eventType,
subtitle,
channel,
network,
isLive: phase === "live",
@ -403,6 +602,8 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
stop,
startLocal: str(raw.start_local) || null,
stopLocal: str(raw.stop_local) || null,
sportBucket,
sportLabel,
imageLandscape: bestByScore(images, scoreLandscape),
imageBackdrop: bestByScore(images, scoreBackdrop),
imagePortrait: bestByScore(images, scorePortrait),