fix Prime/Amazon event art: BOXART cover, HERO backdrop, real logos.

Add imageBackdrop and retune scoring so card rails use BOXART-16X9, detail/hero prefer HERO-16X9, and channels-logo PNGs no longer win as cover or logo. TV 0.14.61.
This commit is contained in:
Jos Vooges | STH 2026-09-19 01:51:29 +02:00
parent d549609dbe
commit 7ace6ab214
4 changed files with 81 additions and 11 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 123 versionCode = 124
versionName = "0.14.60" versionName = "0.14.61"
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")

View file

@ -727,6 +727,7 @@ class ApiClient(
startLocal = optClean("startLocal"), startLocal = optClean("startLocal"),
stopLocal = optClean("stopLocal"), stopLocal = optClean("stopLocal"),
imageLandscape = optClean("imageLandscape"), imageLandscape = optClean("imageLandscape"),
imageBackdrop = optClean("imageBackdrop"),
imagePortrait = optClean("imagePortrait"), imagePortrait = optClean("imagePortrait"),
logoUrl = optClean("logoUrl"), logoUrl = optClean("logoUrl"),
images = images, images = images,
@ -1114,6 +1115,7 @@ data class ScheduleEvent(
val startLocal: String? = null, val startLocal: String? = null,
val stopLocal: String? = null, val stopLocal: String? = null,
val imageLandscape: String? = null, val imageLandscape: String? = null,
val imageBackdrop: String? = null,
val imagePortrait: String? = null, val imagePortrait: String? = null,
val logoUrl: String? = null, val logoUrl: String? = null,
val images: List<String> = emptyList(), val images: List<String> = emptyList(),

View file

@ -685,7 +685,7 @@ fun EventDetailScreen(
// Full-bleed landscape backdrop // Full-bleed landscape backdrop
AsyncImage( AsyncImage(
model = eventArtUrl(ev), model = eventBackdropUrl(ev),
contentDescription = ev.name, contentDescription = ev.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter, alignment = Alignment.TopCenter,
@ -917,7 +917,7 @@ private fun EventHero(
val event = events.find { it.id == id } ?: current val event = events.find { it.id == id } ?: current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = eventArtUrl(event), model = eventBackdropUrl(event),
contentDescription = event.name, contentDescription = event.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
alignment = Alignment.TopCenter, alignment = Alignment.TopCenter,
@ -1568,24 +1568,38 @@ private fun detailMetaLine(event: ScheduleEvent): String =
displayChannel(event), displayChannel(event),
).distinct().joinToString(" · ") ).distinct().joinToString(" · ")
/** Card/rail cover art — prefer API imageLandscape (BOXART-16X9). */
private fun eventArtUrl(event: ScheduleEvent): String? = private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() } event.imageLandscape?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { looksLikeArtwork(it) && !it.contains("hero-16x9", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) } ?: event.images.firstOrNull { looksLikeArtwork(it) }
?: event.imagePortrait?.takeIf { it.isNotBlank() } ?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) } ?: 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). */ /** True when artwork is suitable for a full-bleed hero (no bare logos / channel PNGs). */
private fun looksLikeArtwork(url: String): Boolean { private fun looksLikeArtwork(url: String): Boolean {
val u = url.trim() val u = url.trim()
if (u.isEmpty()) return false if (u.isEmpty()) return false
val lower = u.lowercase() val lower = u.lowercase()
if (lower.contains("assets/channels")) return false 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 if (lower.endsWith(".svg")) return false
return true return true
} }
private fun hasUsableHeroArt(event: ScheduleEvent): Boolean { private fun hasUsableHeroArt(event: ScheduleEvent): Boolean {
if (!event.imageBackdrop.isNullOrBlank()) return true
if (!event.imageLandscape.isNullOrBlank()) return true if (!event.imageLandscape.isNullOrBlank()) return true
if (event.images.any { looksLikeArtwork(it) }) 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. // 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 { private fun donorImageScore(e: ScheduleEvent): Int {
var s = 0 var s = 0
if (!e.imageLandscape.isNullOrBlank()) s += 20 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.images.any { it.contains("thumb", true) || it.contains("akamaized.net/live", true) }) s += 30
if (!e.logoUrl.isNullOrBlank()) s += 5 if (!e.logoUrl.isNullOrBlank()) s += 5
s += e.images.size.coerceAtMost(5) s += e.images.size.coerceAtMost(5)
@ -1674,6 +1689,7 @@ private fun enrichSingleEvent(
if (donor == null) return cleaned if (donor == null) return cleaned
return cleaned.copy( return cleaned.copy(
imageLandscape = cleaned.imageLandscape ?: donor.imageLandscape, imageLandscape = cleaned.imageLandscape ?: donor.imageLandscape,
imageBackdrop = cleaned.imageBackdrop ?: donor.imageBackdrop,
imagePortrait = cleaned.imagePortrait ?: donor.imagePortrait, imagePortrait = cleaned.imagePortrait ?: donor.imagePortrait,
logoUrl = cleaned.logoUrl ?: donor.logoUrl, logoUrl = cleaned.logoUrl ?: donor.logoUrl,
images = cleaned.images.ifEmpty { donor.images }, images = cleaned.images.ifEmpty { donor.images },

View file

@ -26,7 +26,10 @@ export type ScheduleEventPublic = {
stop: string; stop: string;
startLocal: string | null; startLocal: string | null;
stopLocal: string | null; stopLocal: string | null;
/** 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. */
imageBackdrop: string | null;
imagePortrait: string | null; imagePortrait: string | null;
logoUrl: string | null; logoUrl: string | null;
images: string[]; images: string[];
@ -125,9 +128,24 @@ function pickImages(raw: RawEvent): string[] {
return out; 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 { function scoreLandscape(url: string): number {
const u = url.toLowerCase(); const u = url.toLowerCase();
let score = 0; 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) // Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
if (u.includes("thumb")) score += 55; if (u.includes("thumb")) score += 55;
if (u.includes("akamaized.net/live")) score += 50; 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=960") && u.includes("height=540")) score += 40;
if (u.includes("width=1280") || u.includes("height=720")) score += 30; if (u.includes("width=1280") || u.includes("height=720")) score += 30;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5; if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
// Channel logo PNGs are not landscape artwork // Pure logos are never card art
if (u.includes("assets/channels")) score -= 70; if (isPureLogoUrl(url)) score -= 120;
if (u.includes("/channels/") && u.includes(".png")) score -= 50; else if (u.includes("assets/channels")) score -= 70;
if (u.includes("logo")) score -= 40; 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("_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; return score;
} }
function scorePortrait(url: string): number { function scorePortrait(url: string): number {
const u = url.toLowerCase(); const u = url.toLowerCase();
let score = 0; 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("_c.") || u.includes("_c?")) score += 50;
if (u.includes("width=199") || u.includes("height=298")) score += 30; 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("thumb") || u.includes("akamaized.net/live")) score -= 20;
if (u.includes("_l.")) score -= 10; if (u.includes("_l.")) score -= 10;
return score; return score;
@ -160,6 +205,10 @@ function scorePortrait(url: string): number {
function scoreLogo(url: string): number { function scoreLogo(url: string): number {
const u = url.toLowerCase(); const u = url.toLowerCase();
let score = 0; 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("assets/channels")) score += 80;
if (u.includes("ziggo_sport")) score += 45; if (u.includes("ziggo_sport")) score += 45;
if (u.includes("/channels/") && u.includes(".png")) score += 55; 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 { function donorImageScore(e: ScheduleEventPublic): number {
let s = 0; let s = 0;
if (e.imageLandscape) s += 20; 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.images.some((u) => /thumb|akamaized\.net\/live|scale=/i.test(u))) s += 30;
if (e.logoUrl) s += 5; if (e.logoUrl) s += 5;
if (e.images.length) s += Math.min(e.images.length, 5); if (e.images.length) s += Math.min(e.images.length, 5);
@ -236,6 +286,7 @@ function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[]
return { return {
...e, ...e,
imageLandscape: e.imageLandscape || donor.imageLandscape, imageLandscape: e.imageLandscape || donor.imageLandscape,
imageBackdrop: e.imageBackdrop || donor.imageBackdrop,
imagePortrait: e.imagePortrait || donor.imagePortrait, imagePortrait: e.imagePortrait || donor.imagePortrait,
logoUrl: e.logoUrl || donor.logoUrl, logoUrl: e.logoUrl || donor.logoUrl,
images: e.images.length ? e.images : [...donor.images], 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, startLocal: str(raw.start_local) || null,
stopLocal: str(raw.stop_local) || null, stopLocal: str(raw.stop_local) || null,
imageLandscape: bestByScore(images, scoreLandscape), imageLandscape: bestByScore(images, scoreLandscape),
imageBackdrop: bestByScore(images, scoreBackdrop),
imagePortrait: bestByScore(images, scorePortrait), imagePortrait: bestByScore(images, scorePortrait),
logoUrl: bestByScore(images, scoreLogo), logoUrl: bestByScore(images, scoreLogo),
images, images,