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:
parent
d1b627752c
commit
796edf36db
7 changed files with 647 additions and 90 deletions
|
|
@ -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(
|
return ScheduleEvent(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
description = optClean("description"),
|
description = optClean("description"),
|
||||||
provider = optClean("provider") ?: "unknown",
|
provider = optClean("provider") ?: "unknown",
|
||||||
category = optClean("category"),
|
category = category,
|
||||||
eventType = optClean("eventType"),
|
eventType = eventType,
|
||||||
subtitle = optClean("subtitle"),
|
subtitle = subtitle,
|
||||||
channel = optClean("channel"),
|
channel = optClean("channel"),
|
||||||
network = optClean("network"),
|
network = optClean("network"),
|
||||||
isLive = optBoolean("isLive"),
|
isLive = optBoolean("isLive"),
|
||||||
|
|
@ -689,6 +709,8 @@ class ApiClient(
|
||||||
stop = optClean("stop") ?: "",
|
stop = optClean("stop") ?: "",
|
||||||
startLocal = optClean("startLocal"),
|
startLocal = optClean("startLocal"),
|
||||||
stopLocal = optClean("stopLocal"),
|
stopLocal = optClean("stopLocal"),
|
||||||
|
sportBucket = sportBucket,
|
||||||
|
sportLabel = sportLabel,
|
||||||
imageLandscape = optClean("imageLandscape"),
|
imageLandscape = optClean("imageLandscape"),
|
||||||
imageBackdrop = optClean("imageBackdrop"),
|
imageBackdrop = optClean("imageBackdrop"),
|
||||||
imagePortrait = optClean("imagePortrait"),
|
imagePortrait = optClean("imagePortrait"),
|
||||||
|
|
@ -1028,6 +1050,8 @@ data class ScheduleEvent(
|
||||||
val stop: String = "",
|
val stop: String = "",
|
||||||
val startLocal: String? = null,
|
val startLocal: String? = null,
|
||||||
val stopLocal: String? = null,
|
val stopLocal: String? = null,
|
||||||
|
val sportBucket: String = "other",
|
||||||
|
val sportLabel: String = "Overig",
|
||||||
val imageLandscape: String? = null,
|
val imageLandscape: String? = null,
|
||||||
val imageBackdrop: String? = null,
|
val imageBackdrop: String? = null,
|
||||||
val imagePortrait: String? = null,
|
val imagePortrait: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 127
|
versionCode = 128
|
||||||
versionName = "0.14.64"
|
versionName = "0.14.65"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
return ScheduleEvent(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
description = optClean("description"),
|
description = optClean("description"),
|
||||||
provider = optClean("provider") ?: "unknown",
|
provider = optClean("provider") ?: "unknown",
|
||||||
category = optClean("category"),
|
category = category,
|
||||||
eventType = optClean("eventType"),
|
eventType = eventType,
|
||||||
subtitle = optClean("subtitle"),
|
subtitle = subtitle,
|
||||||
channel = optClean("channel"),
|
channel = optClean("channel"),
|
||||||
network = optClean("network"),
|
network = optClean("network"),
|
||||||
isLive = optBoolean("isLive"),
|
isLive = optBoolean("isLive"),
|
||||||
|
|
@ -726,6 +736,8 @@ class ApiClient(
|
||||||
stop = optClean("stop") ?: "",
|
stop = optClean("stop") ?: "",
|
||||||
startLocal = optClean("startLocal"),
|
startLocal = optClean("startLocal"),
|
||||||
stopLocal = optClean("stopLocal"),
|
stopLocal = optClean("stopLocal"),
|
||||||
|
sportBucket = resolved.first,
|
||||||
|
sportLabel = resolved.second,
|
||||||
imageLandscape = optClean("imageLandscape"),
|
imageLandscape = optClean("imageLandscape"),
|
||||||
imageBackdrop = optClean("imageBackdrop"),
|
imageBackdrop = optClean("imageBackdrop"),
|
||||||
imagePortrait = optClean("imagePortrait"),
|
imagePortrait = optClean("imagePortrait"),
|
||||||
|
|
@ -1114,6 +1126,9 @@ data class ScheduleEvent(
|
||||||
val stop: String = "",
|
val stop: String = "",
|
||||||
val startLocal: String? = null,
|
val startLocal: String? = null,
|
||||||
val stopLocal: 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 imageLandscape: String? = null,
|
||||||
val imageBackdrop: String? = null,
|
val imageBackdrop: String? = null,
|
||||||
val imagePortrait: String? = null,
|
val imagePortrait: String? = null,
|
||||||
|
|
@ -1122,6 +1137,112 @@ data class ScheduleEvent(
|
||||||
val audioLanguages: List<String> = emptyList(),
|
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(
|
data class IptvChannel(
|
||||||
val streamId: String,
|
val streamId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.ScheduleEvent
|
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.components.HomeChrome
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
@ -96,9 +98,11 @@ fun EventsScreen(
|
||||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var sportFilter by remember { mutableStateOf<String?>(null) }
|
||||||
var providerFilter by remember { mutableStateOf<String?>(null) }
|
var providerFilter by remember { mutableStateOf<String?>(null) }
|
||||||
var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
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. */
|
/** Owner-id van first-rail FocusRequester — voorkomt dispose-race live↔upcoming. */
|
||||||
var railFocusOwner by remember { mutableStateOf<String?>(null) }
|
var railFocusOwner by remember { mutableStateOf<String?>(null) }
|
||||||
val railFocusReady = railFocusOwner != null
|
val railFocusReady = railFocusOwner != null
|
||||||
|
|
@ -107,6 +111,7 @@ fun EventsScreen(
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val firstRailFocus = remember { FocusRequester() }
|
val firstRailFocus = remember { FocusRequester() }
|
||||||
val restoreCardFocus = remember { FocusRequester() }
|
val restoreCardFocus = remember { FocusRequester() }
|
||||||
|
val providerRowFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
suspend fun refresh() {
|
suspend fun refresh() {
|
||||||
runCatching { api.listScheduleEvents() }
|
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) {
|
val providers = remember(events) {
|
||||||
events.map { it.provider }.distinct().sorted()
|
events.map { it.provider }.distinct().sorted()
|
||||||
}
|
}
|
||||||
val showProviderChips = providers.size > 1
|
val showProviderChips = providers.size > 1
|
||||||
val upcomingRails = remember(byDay, nowMs) {
|
|
||||||
byDay.mapNotNull { (label, dayEvents) ->
|
// Provider filter first → sport chips only from that subset.
|
||||||
val upcoming = dayEvents.filter { !eventIsLive(it, nowMs) }
|
val providerFiltered = remember(events, providerFilter) {
|
||||||
if (upcoming.isEmpty()) null else label to upcoming
|
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 hasContent = filtered.isNotEmpty()
|
||||||
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
|
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
|
||||||
val firstRailOwnerId = when {
|
val firstRailOwnerId = when {
|
||||||
live.isNotEmpty() -> "live"
|
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
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus: TopNav → chips (optioneel) → first rail card.
|
// Focus: TopNav → sport chips → provider chips → first rail.
|
||||||
// Films/Home-patroon: focusProperties.down alleen naar een gebonden FocusRequester
|
val showAnyChips = showSportChips || showProviderChips
|
||||||
// (of Cancel bij lege filter) — nooit naar uninitialized firstRailFocus.
|
|
||||||
val firstRailUpTarget = when {
|
val firstRailUpTarget = when {
|
||||||
showProviderChips && chipsFocusReady -> contentFocusRequester
|
showProviderChips && providerChipsFocusReady -> providerRowFocus
|
||||||
|
showSportChips && sportChipsFocusReady -> contentFocusRequester
|
||||||
else -> navFocusRequester
|
else -> navFocusRequester
|
||||||
}
|
}
|
||||||
// Chip/TopNav down-target: ready rail, anders Cancel (geen spatial crash) of weglaten.
|
val sportChipFocusDown: FocusRequester? = when {
|
||||||
val chipFocusDown: FocusRequester? = when {
|
showProviderChips -> if (providerChipsFocusReady) providerRowFocus else null
|
||||||
!hasRails -> FocusRequester.Cancel
|
!hasRails -> FocusRequester.Cancel
|
||||||
railFocusReady -> firstRailFocus
|
railFocusReady -> firstRailFocus
|
||||||
else -> null
|
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) {
|
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
|
|
@ -183,18 +232,15 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter-wissel: scroll omhoog + clear owner zodat down pas opnieuw linkt na re-bind.
|
// 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, sportFilter) {
|
||||||
LaunchedEffect(providerFilter) {
|
|
||||||
railFocusOwner = null
|
railFocusOwner = null
|
||||||
runCatching { listState.scrollToItem(0) }
|
runCatching { listState.scrollToItem(0) }
|
||||||
}
|
}
|
||||||
|
// Drop sport filter if bucket verdwijnt na provider-wissel.
|
||||||
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
|
LaunchedEffect(sportChips, sportFilter) {
|
||||||
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
|
if (sportFilter != null && sportChips.none { it.first == sportFilter }) {
|
||||||
// Geen hero/top-spacer meer: index 0 = chips of eerste rail.
|
sportFilter = null
|
||||||
var idx = 0
|
}
|
||||||
if (showProviderChips) idx++
|
|
||||||
idx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val restoreInLive = remember(live, restoreEventId) {
|
val restoreInLive = remember(live, restoreEventId) {
|
||||||
|
|
@ -206,6 +252,7 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
||||||
val restoreListIndex = remember(
|
val restoreListIndex = remember(
|
||||||
|
showSportChips,
|
||||||
showProviderChips,
|
showProviderChips,
|
||||||
live,
|
live,
|
||||||
upcomingRails,
|
upcomingRails,
|
||||||
|
|
@ -214,13 +261,13 @@ fun EventsScreen(
|
||||||
) {
|
) {
|
||||||
if (!canRestoreCard) return@remember -1
|
if (!canRestoreCard) return@remember -1
|
||||||
var idx = 0
|
var idx = 0
|
||||||
|
if (showSportChips) idx++
|
||||||
if (showProviderChips) idx++
|
if (showProviderChips) idx++
|
||||||
if (restoreInLive) return@remember idx
|
if (restoreInLive) return@remember idx
|
||||||
if (live.isNotEmpty()) idx++
|
if (live.isNotEmpty()) idx++
|
||||||
if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex
|
if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex
|
||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
// Eerste card van de eerste rail deelt firstRailFocus — géén tweede FocusRequester (dual-bind hang).
|
|
||||||
val restoreIsFirstOfFirstRail = remember(live, upcomingRails, restoreEventId, restoreInLive) {
|
val restoreIsFirstOfFirstRail = remember(live, upcomingRails, restoreEventId, restoreInLive) {
|
||||||
if (restoreEventId.isNullOrBlank()) return@remember false
|
if (restoreEventId.isNullOrBlank()) return@remember false
|
||||||
val firstRail = when {
|
val firstRail = when {
|
||||||
|
|
@ -237,7 +284,7 @@ fun EventsScreen(
|
||||||
val activeRestoreFocus = when {
|
val activeRestoreFocus = when {
|
||||||
!canRestoreCard -> null
|
!canRestoreCard -> null
|
||||||
restoreIsFirstOfFirstRail -> when {
|
restoreIsFirstOfFirstRail -> when {
|
||||||
showProviderChips -> firstRailFocus
|
showAnyChips -> firstRailFocus
|
||||||
else -> contentFocusRequester
|
else -> contentFocusRequester
|
||||||
}
|
}
|
||||||
else -> restoreCardFocus
|
else -> restoreCardFocus
|
||||||
|
|
@ -255,14 +302,15 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val entryReady = when {
|
val entryReady = when {
|
||||||
showProviderChips -> chipsFocusReady
|
showSportChips -> sportChipsFocusReady
|
||||||
|
showProviderChips -> providerChipsFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
// TopNav.down moet altijd een gebonden target hebben (Home-patroon: sink tot owner klaar).
|
|
||||||
val needContentSink = when {
|
val needContentSink = when {
|
||||||
loading && events.isEmpty() -> true
|
loading && events.isEmpty() -> true
|
||||||
error != null && events.isEmpty() -> true
|
error != null && events.isEmpty() -> true
|
||||||
showProviderChips -> !chipsFocusReady
|
showSportChips -> !sportChipsFocusReady
|
||||||
|
showProviderChips -> !providerChipsFocusReady
|
||||||
!hasRails -> true
|
!hasRails -> true
|
||||||
else -> !railFocusReady
|
else -> !railFocusReady
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +334,8 @@ fun EventsScreen(
|
||||||
if (restoredSuccessfully) return
|
if (restoredSuccessfully) return
|
||||||
val entryOk = awaitReady({
|
val entryOk = awaitReady({
|
||||||
when {
|
when {
|
||||||
showProviderChips -> chipsFocusReady
|
showSportChips -> sportChipsFocusReady
|
||||||
|
showProviderChips -> providerChipsFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
}, timeoutMs = 400)
|
}, timeoutMs = 400)
|
||||||
|
|
@ -383,17 +432,34 @@ fun EventsScreen(
|
||||||
) {
|
) {
|
||||||
// Chips blijven altijd staan (ook bij lege filter) — anders unbindt
|
// Chips blijven altijd staan (ook bij lege filter) — anders unbindt
|
||||||
// contentFocusRequester en crasht TopNav/D-pad focusSearch.
|
// 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) {
|
if (showProviderChips) {
|
||||||
item(key = "providers") {
|
item(key = "providers") {
|
||||||
ProviderChips(
|
ProviderChips(
|
||||||
providers = providers,
|
providers = providers,
|
||||||
selected = providerFilter,
|
selected = providerFilter,
|
||||||
onSelect = { providerFilter = it },
|
onSelect = { providerFilter = it },
|
||||||
firstFocusRequester = contentFocusRequester,
|
firstFocusRequester = if (showSportChips) {
|
||||||
focusUp = navFocusRequester,
|
providerRowFocus
|
||||||
// Alleen linken als rail gebonden is; Cancel bij geen rails.
|
} else {
|
||||||
focusDown = chipFocusDown,
|
contentFocusRequester
|
||||||
onFocusReady = { chipsFocusReady = it },
|
},
|
||||||
|
focusUp = providerChipFocusUp,
|
||||||
|
focusDown = providerChipFocusDown,
|
||||||
|
onFocusReady = { providerChipsFocusReady = it },
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -407,11 +473,7 @@ fun EventsScreen(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (providerFilter != null) {
|
emptyEventsMessage(sportFilter, providerFilter),
|
||||||
"Geen events voor ${prettyProvider(providerFilter!!)}"
|
|
||||||
} else {
|
|
||||||
"Geen events in de komende dagen"
|
|
||||||
},
|
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
)
|
)
|
||||||
|
|
@ -426,7 +488,7 @@ fun EventsScreen(
|
||||||
nowMs = nowMs,
|
nowMs = nowMs,
|
||||||
onOpen = onOpenEvent,
|
onOpen = onOpenEvent,
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = if (showProviderChips) {
|
firstItemFocusRequester = if (showAnyChips) {
|
||||||
firstRailFocus
|
firstRailFocus
|
||||||
} else {
|
} else {
|
||||||
contentFocusRequester
|
contentFocusRequester
|
||||||
|
|
@ -443,36 +505,71 @@ fun EventsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
upcomingRails.forEachIndexed { index, (label, dayEvents) ->
|
if (sportFilter == null) {
|
||||||
val isFirstUpcoming = live.isEmpty() && index == 0
|
sportShelves.forEachIndexed { index, (bucket, label, items) ->
|
||||||
val ownerId = "day-$label"
|
val isFirstUpcoming = live.isEmpty() && index == 0
|
||||||
val railHasRestore = bindRestoreCardId != null &&
|
val ownerId = "sport-$bucket"
|
||||||
!restoreInLive &&
|
val railHasRestore = bindRestoreCardId != null &&
|
||||||
dayEvents.any { it.id == bindRestoreCardId }
|
!restoreInLive &&
|
||||||
item(key = "day-$label") {
|
items.any { it.id == bindRestoreCardId }
|
||||||
EventRail(
|
item(key = "sport-$bucket") {
|
||||||
title = label,
|
EventRail(
|
||||||
events = dayEvents,
|
title = label,
|
||||||
nowMs = nowMs,
|
events = items,
|
||||||
onOpen = onOpenEvent,
|
nowMs = nowMs,
|
||||||
onFocus = onContentFocused,
|
onOpen = onOpenEvent,
|
||||||
firstItemFocusRequester = when {
|
onFocus = onContentFocused,
|
||||||
!isFirstUpcoming -> null
|
firstItemFocusRequester = when {
|
||||||
showProviderChips -> firstRailFocus
|
!isFirstUpcoming -> null
|
||||||
else -> contentFocusRequester
|
showAnyChips -> firstRailFocus
|
||||||
},
|
else -> contentFocusRequester
|
||||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
},
|
||||||
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
|
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
||||||
onRestoreFocusReady = { ready ->
|
restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore },
|
||||||
if (railHasRestore) restoreCardReady = ready
|
onRestoreFocusReady = { ready ->
|
||||||
},
|
if (railHasRestore) restoreCardReady = ready
|
||||||
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
|
},
|
||||||
onFirstFocusReady = if (isFirstUpcoming) {
|
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
|
||||||
{ ready -> claimRailFocus(ownerId, ready) }
|
onFirstFocusReady = if (isFirstUpcoming) {
|
||||||
} else {
|
{ ready -> claimRailFocus(ownerId, ready) }
|
||||||
null
|
} 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
|
@Composable
|
||||||
private fun ProviderChips(
|
private fun ProviderChips(
|
||||||
providers: List<String>,
|
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>>> {
|
private fun groupEventsByDay(events: List<ScheduleEvent>): List<Pair<String, List<ScheduleEvent>>> {
|
||||||
val today = LocalDate.now(ZoneNl)
|
val today = LocalDate.now(ZoneNl)
|
||||||
val groups = linkedMapOf<String, MutableList<ScheduleEvent>>()
|
val groups = linkedMapOf<String, MutableList<ScheduleEvent>>()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/app.js",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
|
|
||||||
54
apps/master-api/src/viewer/schedule-events-sport.test.ts
Normal file
54
apps/master-api/src/viewer/schedule-events-sport.test.ts
Normal 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");
|
||||||
|
|
@ -26,6 +26,10 @@ export type ScheduleEventPublic = {
|
||||||
stop: string;
|
stop: string;
|
||||||
startLocal: string | null;
|
startLocal: string | null;
|
||||||
stopLocal: 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). */
|
/** 16:9 card/cover art (Prime: BOXART-16X9 preferred). */
|
||||||
imageLandscape: string | null;
|
imageLandscape: string | null;
|
||||||
/** Full-bleed detail/hero backdrop (Prime: HERO-16X9); fall back to landscape on clients. */
|
/** Full-bleed detail/hero backdrop (Prime: HERO-16X9); fall back to landscape on clients. */
|
||||||
|
|
@ -36,6 +40,191 @@ export type ScheduleEventPublic = {
|
||||||
audioLanguages: string[];
|
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. */
|
/** Viewer + globale provider-filter voor list/detail/play. */
|
||||||
export type LiveEventsAccessFilter = {
|
export type LiveEventsAccessFilter = {
|
||||||
/** false → 403 op client-endpoints */
|
/** 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 channel = cleanChannelLabel(str(raw.epg?.channel) || str(meta.zender));
|
||||||
const network = cleanChannelLabel(str(raw.epg?.network));
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -390,9 +589,9 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
|
||||||
str(raw.epg?.description) ||
|
str(raw.epg?.description) ||
|
||||||
null,
|
null,
|
||||||
provider: (str(raw.provider) || "unknown").toLowerCase(),
|
provider: (str(raw.provider) || "unknown").toLowerCase(),
|
||||||
category: str(raw.category) || str(raw.epg?.category) || null,
|
category,
|
||||||
eventType: str(meta.type) || null,
|
eventType,
|
||||||
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
|
subtitle,
|
||||||
channel,
|
channel,
|
||||||
network,
|
network,
|
||||||
isLive: phase === "live",
|
isLive: phase === "live",
|
||||||
|
|
@ -403,6 +602,8 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
|
||||||
stop,
|
stop,
|
||||||
startLocal: str(raw.start_local) || null,
|
startLocal: str(raw.start_local) || null,
|
||||||
stopLocal: str(raw.stop_local) || null,
|
stopLocal: str(raw.stop_local) || null,
|
||||||
|
sportBucket,
|
||||||
|
sportLabel,
|
||||||
imageLandscape: bestByScore(images, scoreLandscape),
|
imageLandscape: bestByScore(images, scoreLandscape),
|
||||||
imageBackdrop: bestByScore(images, scoreBackdrop),
|
imageBackdrop: bestByScore(images, scoreBackdrop),
|
||||||
imagePortrait: bestByScore(images, scorePortrait),
|
imagePortrait: bestByScore(images, scorePortrait),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue