diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 732c1bd..7a65a91 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 = 123 - versionName = "0.14.60" + versionCode = 124 + versionName = "0.14.61" 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 9f2c1a4..e82b706 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 @@ -727,6 +727,7 @@ class ApiClient( startLocal = optClean("startLocal"), stopLocal = optClean("stopLocal"), imageLandscape = optClean("imageLandscape"), + imageBackdrop = optClean("imageBackdrop"), imagePortrait = optClean("imagePortrait"), logoUrl = optClean("logoUrl"), images = images, @@ -1114,6 +1115,7 @@ data class ScheduleEvent( val startLocal: String? = null, val stopLocal: String? = null, val imageLandscape: String? = null, + val imageBackdrop: String? = null, val imagePortrait: String? = null, val logoUrl: String? = null, val images: List = emptyList(), 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 74019c8..28dd261 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 @@ -685,7 +685,7 @@ fun EventDetailScreen( // Full-bleed landscape backdrop AsyncImage( - model = eventArtUrl(ev), + model = eventBackdropUrl(ev), contentDescription = ev.name, contentScale = ContentScale.Crop, alignment = Alignment.TopCenter, @@ -917,7 +917,7 @@ private fun EventHero( val event = events.find { it.id == id } ?: current Box(Modifier.fillMaxSize()) { AsyncImage( - model = eventArtUrl(event), + model = eventBackdropUrl(event), contentDescription = event.name, contentScale = ContentScale.Crop, alignment = Alignment.TopCenter, @@ -1568,24 +1568,38 @@ private fun detailMetaLine(event: ScheduleEvent): String = displayChannel(event), ).distinct().joinToString(" · ") +/** Card/rail cover art — prefer API imageLandscape (BOXART-16X9). */ private fun eventArtUrl(event: ScheduleEvent): String? = event.imageLandscape?.takeIf { it.isNotBlank() } + ?: event.images.firstOrNull { looksLikeArtwork(it) && !it.contains("hero-16x9", ignoreCase = true) } ?: event.images.firstOrNull { looksLikeArtwork(it) } ?: event.imagePortrait?.takeIf { it.isNotBlank() } ?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) } +/** Detail/hero full-bleed — prefer HERO backdrop, then card landscape. */ +private fun eventBackdropUrl(event: ScheduleEvent): String? = + event.imageBackdrop?.takeIf { it.isNotBlank() } + ?: event.images.firstOrNull { it.contains("hero-16x9", ignoreCase = true) } + ?: eventArtUrl(event) + /** True when artwork is suitable for a full-bleed hero (no bare logos / channel PNGs). */ private fun looksLikeArtwork(url: String): Boolean { val u = url.trim() if (u.isEmpty()) return false val lower = u.lowercase() if (lower.contains("assets/channels")) return false - if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) return false + if (lower.contains("channels-logo")) return false + if (lower.contains("/logos/") && lower.endsWith(".png")) return false + if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) { + // Composite BOXART URLs embed logo overlay paths — still real artwork. + if (!lower.contains("boxart") && !lower.contains("hero-")) return false + } if (lower.endsWith(".svg")) return false return true } private fun hasUsableHeroArt(event: ScheduleEvent): Boolean { + if (!event.imageBackdrop.isNullOrBlank()) return true if (!event.imageLandscape.isNullOrBlank()) return true if (event.images.any { looksLikeArtwork(it) }) return true // Portrait can still fill a hero better than a black void — only if not a channel logo path. @@ -1612,6 +1626,7 @@ private fun normalizeChannelKey(value: String?): String = private fun donorImageScore(e: ScheduleEvent): Int { var s = 0 if (!e.imageLandscape.isNullOrBlank()) s += 20 + if (!e.imageBackdrop.isNullOrBlank()) s += 10 if (e.images.any { it.contains("thumb", true) || it.contains("akamaized.net/live", true) }) s += 30 if (!e.logoUrl.isNullOrBlank()) s += 5 s += e.images.size.coerceAtMost(5) @@ -1674,6 +1689,7 @@ private fun enrichSingleEvent( if (donor == null) return cleaned return cleaned.copy( imageLandscape = cleaned.imageLandscape ?: donor.imageLandscape, + imageBackdrop = cleaned.imageBackdrop ?: donor.imageBackdrop, imagePortrait = cleaned.imagePortrait ?: donor.imagePortrait, logoUrl = cleaned.logoUrl ?: donor.logoUrl, images = cleaned.images.ifEmpty { donor.images }, diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 43b69ba..e1689a7 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -26,7 +26,10 @@ export type ScheduleEventPublic = { stop: string; startLocal: string | null; stopLocal: string | null; + /** 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. */ + imageBackdrop: string | null; imagePortrait: string | null; logoUrl: string | null; images: string[]; @@ -125,9 +128,24 @@ function pickImages(raw: RawEvent): string[] { return out; } +/** Standalone channel/brand logo (not composite boxart that embeds a logo overlay). */ +function isPureLogoUrl(url: string): boolean { + const u = url.toLowerCase(); + if (u.includes("boxart-") || u.includes("hero-") || u.includes("poster")) return false; + if (u.includes("channels-logo") || u.includes("/logos/")) return true; + if (u.includes("assets/channels")) return true; + // Bare logo PNG without Amazon content-art path segments + if (u.includes("logo") && u.includes(".png") && !u.includes("le-target-images")) return true; + return false; +} + function scoreLandscape(url: string): number { const u = url.toLowerCase(); let score = 0; + // Prime/Amazon: card cover = BOXART-16X9 (composite with corner logo overlay is OK) + if (u.includes("boxart-16x9") || u.includes("boxart_16x9")) score += 90; + if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 35; + if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 25; // Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360) if (u.includes("thumb")) score += 55; if (u.includes("akamaized.net/live")) score += 50; @@ -137,21 +155,48 @@ function scoreLandscape(url: string): number { if (u.includes("width=960") && u.includes("height=540")) score += 40; if (u.includes("width=1280") || u.includes("height=720")) score += 30; if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5; - // Channel logo PNGs are not landscape artwork - if (u.includes("assets/channels")) score -= 70; - if (u.includes("/channels/") && u.includes(".png")) score -= 50; - if (u.includes("logo")) score -= 40; + // Pure logos are never card art + if (isPureLogoUrl(url)) score -= 120; + else if (u.includes("assets/channels")) score -= 70; + else if (u.includes("/channels/") && u.includes(".png") && !u.includes("boxart")) score -= 50; + // Only penalize "logo" when it is not part of a BOXART/HERO content composite + if (u.includes("logo") && !u.includes("boxart") && !u.includes("hero-") && !u.includes("hero_")) { + score -= 40; + if (u.includes(".png")) score -= 50; + } if (u.includes("_c.") || u.includes("_c?")) score -= 20; - if (u.includes(".png") && u.includes("logo")) score -= 50; + return score; +} + +/** Detail/hero full-bleed: prefer clean HERO-16X9 over card BOXART. */ +function scoreBackdrop(url: string): number { + const u = url.toLowerCase(); + let score = 0; + if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 95; + if (u.includes("boxart-16x9") || u.includes("boxart_16x9")) score += 40; + if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 20; + if (u.includes("thumb")) score += 45; + if (u.includes("akamaized.net/live")) score += 40; + if (u.includes("scale=")) score += 25; + if (u.includes("_l.") || u.includes("_l?")) score += 40; + if (u.includes("width=1280") || u.includes("height=720") || u.includes("1920")) score += 15; + if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5; + if (isPureLogoUrl(url)) score -= 120; + else if (u.includes("logo") && !u.includes("boxart") && !u.includes("hero-") && !u.includes("hero_")) { + score -= 50; + } return score; } function scorePortrait(url: string): number { const u = url.toLowerCase(); let score = 0; + if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score += 80; if (u.includes("_c.") || u.includes("_c?")) score += 50; if (u.includes("width=199") || u.includes("height=298")) score += 30; - if (u.includes("assets/channels") || u.includes("logo")) score -= 40; + if (u.includes("boxart-16x9") || u.includes("hero-16x9")) score -= 30; + if (isPureLogoUrl(url) || u.includes("assets/channels")) score -= 80; + else if (u.includes("logo") && !u.includes("boxart")) score -= 40; if (u.includes("thumb") || u.includes("akamaized.net/live")) score -= 20; if (u.includes("_l.")) score -= 10; return score; @@ -160,6 +205,10 @@ function scorePortrait(url: string): number { function scoreLogo(url: string): number { const u = url.toLowerCase(); let score = 0; + // Content art must never win as logo (BOXART composites embed logo overlay paths) + if (u.includes("boxart-") || u.includes("hero-") || u.includes("le-target-images")) score -= 200; + if (isPureLogoUrl(url)) score += 100; + if (u.includes("channels-logo")) score += 40; if (u.includes("assets/channels")) score += 80; if (u.includes("ziggo_sport")) score += 45; if (u.includes("/channels/") && u.includes(".png")) score += 55; @@ -192,6 +241,7 @@ function normalizeChannelKey(value: string | null | undefined): string { function donorImageScore(e: ScheduleEventPublic): number { let s = 0; if (e.imageLandscape) s += 20; + if (e.imageBackdrop) s += 10; if (e.images.some((u) => /thumb|akamaized\.net\/live|scale=/i.test(u))) s += 30; if (e.logoUrl) s += 5; if (e.images.length) s += Math.min(e.images.length, 5); @@ -236,6 +286,7 @@ function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] return { ...e, imageLandscape: e.imageLandscape || donor.imageLandscape, + imageBackdrop: e.imageBackdrop || donor.imageBackdrop, imagePortrait: e.imagePortrait || donor.imagePortrait, logoUrl: e.logoUrl || donor.logoUrl, images: e.images.length ? e.images : [...donor.images], @@ -353,6 +404,7 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null { startLocal: str(raw.start_local) || null, stopLocal: str(raw.stop_local) || null, imageLandscape: bestByScore(images, scoreLandscape), + imageBackdrop: bestByScore(images, scoreBackdrop), imagePortrait: bestByScore(images, scorePortrait), logoUrl: bestByScore(images, scoreLogo), images,