diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index c9d1c94..054e4e0 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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, diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 340c2fa..7c07fe2 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index e82b706..f92bb37 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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 = 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 { + 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, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index 8264e7f..dade6ce 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -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>(emptyList()) } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } + var sportFilter by remember { mutableStateOf(null) } var providerFilter by remember { mutableStateOf(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(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>, + 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, @@ -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 = + compareByDescending { eventIsLive(it, nowMs) } + .thenByDescending { it.playable } + .thenBy { parseZoned(it.startLocal, it.start)?.toInstant()?.toEpochMilli() ?: Long.MAX_VALUE } + private fun groupEventsByDay(events: List): List>> { val today = LocalDate.now(ZoneNl) val groups = linkedMapOf>() diff --git a/apps/master-api/package.json b/apps/master-api/package.json index efa673a..ce6b678 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -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", diff --git a/apps/master-api/src/viewer/schedule-events-sport.test.ts b/apps/master-api/src/viewer/schedule-events-sport.test.ts new file mode 100644 index 0000000..f061dfe --- /dev/null +++ b/apps/master-api/src/viewer/schedule-events-sport.test.ts @@ -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"); diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index e1689a7..ef7d78a 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -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 = { + 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 = { + 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),