fix Events hero overlay/focus chain and skip imageless heroes
This commit is contained in:
parent
79f15719a3
commit
5115682427
2 changed files with 290 additions and 79 deletions
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 119
|
versionCode = 120
|
||||||
versionName = "0.14.56"
|
versionName = "0.14.57"
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -44,12 +45,15 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -90,7 +94,7 @@ fun EventsScreen(
|
||||||
contentFocusRequester: FocusRequester,
|
contentFocusRequester: FocusRequester,
|
||||||
navFocusRequester: FocusRequester,
|
navFocusRequester: FocusRequester,
|
||||||
focusRestoreNonce: Int = 0,
|
focusRestoreNonce: Int = 0,
|
||||||
restoreEventId: String? = null,
|
@Suppress("UNUSED_PARAMETER") restoreEventId: String? = null,
|
||||||
pendingFocusRestore: Boolean = false,
|
pendingFocusRestore: Boolean = false,
|
||||||
onFocusRestoreConsumed: () -> Unit = {},
|
onFocusRestoreConsumed: () -> Unit = {},
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
|
|
@ -103,11 +107,27 @@ fun EventsScreen(
|
||||||
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 heroFocusReady by remember { mutableStateOf(false) }
|
var heroFocusReady by remember { mutableStateOf(false) }
|
||||||
|
var chipsFocusReady by remember { mutableStateOf(false) }
|
||||||
var railFocusReady by remember { mutableStateOf(false) }
|
var railFocusReady by remember { mutableStateOf(false) }
|
||||||
var heroCtaFocused by remember { mutableStateOf(false) }
|
var heroCtaFocused by remember { mutableStateOf(false) }
|
||||||
|
var placeholderHadFocus by remember { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val chipsFocus = remember { FocusRequester() }
|
||||||
val firstRailFocus = remember { FocusRequester() }
|
val firstRailFocus = remember { FocusRequester() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val screenH = LocalConfiguration.current.screenHeightDp.dp
|
||||||
|
|
||||||
|
// Mirror Home: full-bleed overlay hero + spacer-driven collapse (no title stuck between rails).
|
||||||
|
val heroH = remember(screenH) { (screenH.value * 0.78f).dp.coerceIn(420.dp, screenH) }
|
||||||
|
val shelfStart = remember(screenH) { (screenH.value * 0.52f).dp.coerceIn(300.dp, screenH * 0.58f) }
|
||||||
|
val heroHpx = with(density) { heroH.toPx() }
|
||||||
|
val shelfStartPx = with(density) { shelfStart.toPx() }
|
||||||
|
val collapsePx = if (listState.firstVisibleItemIndex == 0) {
|
||||||
|
listState.firstVisibleItemScrollOffset.toFloat().coerceIn(0f, shelfStartPx)
|
||||||
|
} else {
|
||||||
|
shelfStartPx
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun refresh() {
|
suspend fun refresh() {
|
||||||
runCatching { api.listScheduleEvents() }
|
runCatching { api.listScheduleEvents() }
|
||||||
|
|
@ -119,9 +139,7 @@ fun EventsScreen(
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) { refresh() }
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(75_000)
|
delay(75_000)
|
||||||
|
|
@ -141,40 +159,93 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
|
val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } }
|
||||||
val liveHeroItems = remember(live) {
|
val liveHeroItems = remember(live) {
|
||||||
live.filter { it.playable }.ifEmpty { live }.take(8)
|
val withArt = live.filter { hasUsableHeroArt(it) }
|
||||||
|
withArt.filter { it.playable }.ifEmpty { withArt }.take(8)
|
||||||
}
|
}
|
||||||
val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) }
|
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 upcomingRails = remember(byDay) {
|
val upcomingRails = remember(byDay) {
|
||||||
byDay.mapNotNull { (label, dayEvents) ->
|
byDay.mapNotNull { (label, dayEvents) ->
|
||||||
val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive }
|
val upcoming = dayEvents.filter { it.phase != "live" && !it.isLive }
|
||||||
if (upcoming.isEmpty()) null else label to upcoming
|
if (upcoming.isEmpty()) null else label to upcoming
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasHeroContent = filtered.isNotEmpty()
|
val hasContent = filtered.isNotEmpty()
|
||||||
val heroItems = liveHeroItems.ifEmpty { listOfNotNull(filtered.firstOrNull()) }
|
val showHero = liveHeroItems.isNotEmpty()
|
||||||
val heroHeight = 480.dp
|
val hasRails = live.isNotEmpty() || upcomingRails.isNotEmpty()
|
||||||
// Rails starten onder de hero met lichte overlap (Home-achtig).
|
|
||||||
val railStart = 420.dp
|
// Focus chain: Hero CTA → ProviderChips → first rail card
|
||||||
val firstRailListIndex = remember(providers, live, upcomingRails) {
|
val heroDownTarget = when {
|
||||||
var idx = 1 // after spacer
|
showProviderChips && chipsFocusReady -> chipsFocus
|
||||||
if (providers.size > 1) idx++
|
railFocusReady -> firstRailFocus
|
||||||
if (live.isNotEmpty() || upcomingRails.isNotEmpty()) idx else -1
|
else -> null
|
||||||
|
}
|
||||||
|
val chipsUpTarget = contentFocusRequester.takeIf { showHero && heroFocusReady }
|
||||||
|
val chipsDownTarget = firstRailFocus.takeIf { railFocusReady }
|
||||||
|
val firstRailUpTarget = when {
|
||||||
|
showProviderChips && chipsFocusReady -> chipsFocus
|
||||||
|
showHero && heroFocusReady -> contentFocusRequester
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun awaitRailReady(timeoutMs: Long = 900): Boolean {
|
val firstRailListIndex = remember(showProviderChips, live, upcomingRails) {
|
||||||
|
if (live.isEmpty() && upcomingRails.isEmpty()) return@remember -1
|
||||||
|
var idx = 1 // after spacer
|
||||||
|
if (showProviderChips) idx++
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
val chipsListIndex = remember(showProviderChips) {
|
||||||
|
if (!showProviderChips) -1 else 1 // after spacer
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandHeroFully() {
|
||||||
|
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
|
||||||
|
scope.launch { listState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
snapshotFlow {
|
||||||
|
Triple(
|
||||||
|
listState.firstVisibleItemIndex,
|
||||||
|
listState.firstVisibleItemScrollOffset,
|
||||||
|
listState.isScrollInProgress,
|
||||||
|
)
|
||||||
|
}.collect { (index, offset, scrolling) ->
|
||||||
|
if (!scrolling && index == 0 && offset in 1..240) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun awaitReady(ready: () -> Boolean, timeoutMs: Long = 900): Boolean {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (!railFocusReady && System.currentTimeMillis() < deadline) {
|
while (!ready() && System.currentTimeMillis() < deadline) {
|
||||||
delay(40)
|
delay(40)
|
||||||
}
|
}
|
||||||
return railFocusReady
|
return ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusProviderChips() {
|
||||||
|
scope.launch {
|
||||||
|
if (!showProviderChips) return@launch
|
||||||
|
if (!awaitReady({ chipsFocusReady })) return@launch
|
||||||
|
runCatching {
|
||||||
|
if (chipsListIndex >= 0) {
|
||||||
|
listState.animateScrollToItem(chipsListIndex)
|
||||||
|
delay(40)
|
||||||
|
}
|
||||||
|
chipsFocus.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun focusFirstRail() {
|
fun focusFirstRail() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (!awaitRailReady()) return@launch
|
if (!awaitReady({ railFocusReady })) return@launch
|
||||||
runCatching {
|
runCatching {
|
||||||
if (firstRailListIndex >= 0) {
|
if (firstRailListIndex >= 0) {
|
||||||
listState.animateScrollToItem(firstRailListIndex)
|
listState.animateScrollToItem(firstRailListIndex)
|
||||||
|
|
@ -185,14 +256,27 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stabiele restore: altijd hero CTA — geen card-FocusRequester races.
|
fun focusAfterHero() {
|
||||||
|
if (showProviderChips) focusProviderChips() else focusFirstRail()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry focus target when there is no hero: chips or first rail bind contentFocusRequester.
|
||||||
|
val entryIsHero = showHero
|
||||||
|
val entryReady = when {
|
||||||
|
entryIsHero -> heroFocusReady
|
||||||
|
showProviderChips -> chipsFocusReady
|
||||||
|
else -> railFocusReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail → back / tab restore: expand + focus entry (hero CTA or chips/rail).
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
pendingFocusRestore,
|
pendingFocusRestore,
|
||||||
focusRestoreNonce,
|
focusRestoreNonce,
|
||||||
hasHeroContent,
|
hasContent,
|
||||||
heroFocusReady,
|
entryReady,
|
||||||
|
entryIsHero,
|
||||||
) {
|
) {
|
||||||
if (!pendingFocusRestore || !hasHeroContent || !heroFocusReady) return@LaunchedEffect
|
if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect
|
||||||
delay(60)
|
delay(60)
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
runCatching { listState.scrollToItem(0) }
|
runCatching { listState.scrollToItem(0) }
|
||||||
|
|
@ -201,10 +285,8 @@ fun EventsScreen(
|
||||||
onFocusRestoreConsumed()
|
onFocusRestoreConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder had focus tijdens laden → doorzetten naar hero CTA zodra klaar.
|
LaunchedEffect(entryReady, hasContent) {
|
||||||
var placeholderHadFocus by remember { mutableStateOf(false) }
|
if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect
|
||||||
LaunchedEffect(heroFocusReady, hasHeroContent) {
|
|
||||||
if (!heroFocusReady || !hasHeroContent || !placeholderHadFocus) return@LaunchedEffect
|
|
||||||
delay(40)
|
delay(40)
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
|
|
@ -212,11 +294,11 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val pauseCarousel = pendingFocusRestore || heroCtaFocused
|
val pauseCarousel = pendingFocusRestore || heroCtaFocused
|
||||||
|
// NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt.
|
||||||
|
val entryInTree = hasContent && (showHero || showProviderChips || hasRails)
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
// NOOIT dual-bind: placeholder alleen als hero NIET in de tree hangt.
|
if (!entryInTree) {
|
||||||
// (Home had dezelfde race: placeholder + CTA tegelijk → Shield hang.)
|
|
||||||
if (!hasHeroContent) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(1.dp)
|
.size(1.dp)
|
||||||
|
|
@ -240,51 +322,85 @@ fun EventsScreen(
|
||||||
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
!hasHeroContent -> {
|
!hasContent -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
|
Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Hero BUITEN LazyColumn — zelfde stabiele patroon als Home SpotlightHero.
|
if (showHero) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(heroHeight),
|
.height(heroH)
|
||||||
) {
|
.graphicsLayer {
|
||||||
EventHero(
|
translationY = -collapsePx
|
||||||
events = heroItems,
|
alpha = (1f - (collapsePx / heroHpx).coerceIn(0f, 1f) * 0.85f)
|
||||||
nowMs = nowMs,
|
},
|
||||||
focusRequester = contentFocusRequester,
|
) {
|
||||||
navFocusRequester = navFocusRequester,
|
EventHero(
|
||||||
railFocusRequester = firstRailFocus.takeIf { railFocusReady },
|
events = liveHeroItems,
|
||||||
pauseCarousel = pauseCarousel,
|
nowMs = nowMs,
|
||||||
onFocusReady = { heroFocusReady = it },
|
focusRequester = contentFocusRequester,
|
||||||
onFocus = onContentFocused,
|
navFocusRequester = navFocusRequester,
|
||||||
onCtaFocusChanged = { focused ->
|
nextDownFocusRequester = heroDownTarget,
|
||||||
heroCtaFocused = focused
|
pauseCarousel = pauseCarousel,
|
||||||
if (focused) onContentFocused()
|
onFocusReady = { heroFocusReady = it },
|
||||||
},
|
onFocus = {
|
||||||
onOpen = { id -> onOpenEvent(id) },
|
onContentFocused()
|
||||||
onPlay = { id -> onPlayEvent(id) },
|
expandHeroFully()
|
||||||
onRequestRailFocus = { focusFirstRail() },
|
},
|
||||||
)
|
onCtaFocusChanged = { focused ->
|
||||||
|
heroCtaFocused = focused
|
||||||
|
if (focused) {
|
||||||
|
onContentFocused()
|
||||||
|
expandHeroFully()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpen = { id -> onOpenEvent(id) },
|
||||||
|
onPlay = { id -> onPlayEvent(id) },
|
||||||
|
onRequestNextFocus = { focusAfterHero() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 48.dp),
|
contentPadding = PaddingValues(bottom = 48.dp),
|
||||||
) {
|
) {
|
||||||
item(key = "hero-spacer") {
|
item(key = "hero-spacer") {
|
||||||
Spacer(Modifier.height(railStart))
|
// With hero: spacer drives collapse. Without: clear TopNav.
|
||||||
|
Spacer(
|
||||||
|
Modifier.height(
|
||||||
|
if (showHero) shelfStart else HomeChrome.BadgeTop + 24.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (providers.size > 1) {
|
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 = if (showHero) {
|
||||||
|
chipsFocus
|
||||||
|
} else {
|
||||||
|
contentFocusRequester
|
||||||
|
},
|
||||||
|
focusUp = chipsUpTarget,
|
||||||
|
focusDown = chipsDownTarget,
|
||||||
|
onFocusReady = { chipsFocusReady = it },
|
||||||
|
onFocus = {
|
||||||
|
onContentFocused()
|
||||||
|
if (showHero) expandHeroFully()
|
||||||
|
},
|
||||||
|
onFocusDownFallback = if (chipsDownTarget == null) {
|
||||||
|
({ focusFirstRail() })
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -296,11 +412,12 @@ fun EventsScreen(
|
||||||
nowMs = nowMs,
|
nowMs = nowMs,
|
||||||
onOpen = onOpenEvent,
|
onOpen = onOpenEvent,
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = firstRailFocus.takeIf { live.isNotEmpty() },
|
firstItemFocusRequester = when {
|
||||||
focusUpTo = contentFocusRequester.takeIf { heroFocusReady },
|
showHero || showProviderChips -> firstRailFocus
|
||||||
onFirstFocusReady = { ready ->
|
else -> contentFocusRequester
|
||||||
if (live.isNotEmpty()) railFocusReady = ready
|
|
||||||
},
|
},
|
||||||
|
focusUpTo = firstRailUpTarget,
|
||||||
|
onFirstFocusReady = { railFocusReady = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -313,8 +430,12 @@ fun EventsScreen(
|
||||||
nowMs = nowMs,
|
nowMs = nowMs,
|
||||||
onOpen = onOpenEvent,
|
onOpen = onOpenEvent,
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = firstRailFocus.takeIf { isFirstUpcoming },
|
firstItemFocusRequester = when {
|
||||||
focusUpTo = contentFocusRequester.takeIf { heroFocusReady && isFirstUpcoming },
|
!isFirstUpcoming -> null
|
||||||
|
showHero || showProviderChips -> firstRailFocus
|
||||||
|
else -> contentFocusRequester
|
||||||
|
},
|
||||||
|
focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming },
|
||||||
onFirstFocusReady = { ready ->
|
onFirstFocusReady = { ready ->
|
||||||
if (isFirstUpcoming) railFocusReady = ready
|
if (isFirstUpcoming) railFocusReady = ready
|
||||||
},
|
},
|
||||||
|
|
@ -674,14 +795,14 @@ private fun EventHero(
|
||||||
nowMs: Long,
|
nowMs: Long,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
navFocusRequester: FocusRequester,
|
navFocusRequester: FocusRequester,
|
||||||
railFocusRequester: FocusRequester?,
|
nextDownFocusRequester: FocusRequester?,
|
||||||
pauseCarousel: Boolean = false,
|
pauseCarousel: Boolean = false,
|
||||||
onFocusReady: (Boolean) -> Unit,
|
onFocusReady: (Boolean) -> Unit,
|
||||||
onFocus: () -> Unit,
|
onFocus: () -> Unit,
|
||||||
onCtaFocusChanged: (Boolean) -> Unit = {},
|
onCtaFocusChanged: (Boolean) -> Unit = {},
|
||||||
onOpen: (String) -> Unit,
|
onOpen: (String) -> Unit,
|
||||||
onPlay: (String) -> Unit,
|
onPlay: (String) -> Unit,
|
||||||
onRequestRailFocus: () -> Unit = {},
|
onRequestNextFocus: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
var heroIndex by remember { mutableIntStateOf(0) }
|
var heroIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
@ -701,8 +822,7 @@ private fun EventHero(
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.height(480.dp)
|
|
||||||
.clip(RectangleShape)
|
.clip(RectangleShape)
|
||||||
.background(Color(0xFF0B0B0B)),
|
.background(Color(0xFF0B0B0B)),
|
||||||
) {
|
) {
|
||||||
|
|
@ -800,11 +920,11 @@ private fun EventHero(
|
||||||
primary = true,
|
primary = true,
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
focusUp = navFocusRequester,
|
focusUp = navFocusRequester,
|
||||||
focusDown = railFocusRequester,
|
focusDown = nextDownFocusRequester,
|
||||||
onFocus = onFocus,
|
onFocus = onFocus,
|
||||||
onFocusChanged = onCtaFocusChanged,
|
onFocusChanged = onCtaFocusChanged,
|
||||||
onFocusDownFallback = if (railFocusRequester == null) {
|
onFocusDownFallback = if (nextDownFocusRequester == null) {
|
||||||
({ onRequestRailFocus() })
|
({ onRequestNextFocus() })
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|
@ -816,10 +936,10 @@ private fun EventHero(
|
||||||
McDetailButton(
|
McDetailButton(
|
||||||
"Meer info",
|
"Meer info",
|
||||||
focusUp = navFocusRequester,
|
focusUp = navFocusRequester,
|
||||||
focusDown = railFocusRequester,
|
focusDown = nextDownFocusRequester,
|
||||||
onFocus = onFocus,
|
onFocus = onFocus,
|
||||||
onFocusDownFallback = if (railFocusRequester == null) {
|
onFocusDownFallback = if (nextDownFocusRequester == null) {
|
||||||
({ onRequestRailFocus() })
|
({ onRequestNextFocus() })
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|
@ -1028,25 +1148,97 @@ private fun ProviderChips(
|
||||||
providers: List<String>,
|
providers: List<String>,
|
||||||
selected: String?,
|
selected: String?,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
|
firstFocusRequester: FocusRequester? = null,
|
||||||
|
focusUp: FocusRequester? = null,
|
||||||
|
focusDown: FocusRequester? = null,
|
||||||
|
onFocusReady: ((Boolean) -> Unit)? = null,
|
||||||
|
onFocus: () -> Unit = {},
|
||||||
|
onFocusDownFallback: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp),
|
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Chip("Alles", selected == null) { onSelect(null) }
|
Chip(
|
||||||
|
label = "Alles",
|
||||||
|
active = selected == null,
|
||||||
|
focusRequester = firstFocusRequester,
|
||||||
|
focusUp = focusUp,
|
||||||
|
focusDown = focusDown,
|
||||||
|
onFocusReady = onFocusReady,
|
||||||
|
onFocus = onFocus,
|
||||||
|
onFocusDownFallback = onFocusDownFallback,
|
||||||
|
onClick = { onSelect(null) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
items(providers) { p ->
|
items(providers) { p ->
|
||||||
Chip(prettyProvider(p), selected.equals(p, ignoreCase = true)) { onSelect(p) }
|
Chip(
|
||||||
|
label = prettyProvider(p),
|
||||||
|
active = selected.equals(p, ignoreCase = true),
|
||||||
|
focusUp = focusUp,
|
||||||
|
focusDown = focusDown,
|
||||||
|
onFocus = onFocus,
|
||||||
|
onFocusDownFallback = onFocusDownFallback,
|
||||||
|
onClick = { onSelect(p) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun Chip(label: String, active: Boolean, onClick: () -> Unit) {
|
private fun Chip(
|
||||||
|
label: String,
|
||||||
|
active: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
focusUp: FocusRequester? = null,
|
||||||
|
focusDown: FocusRequester? = null,
|
||||||
|
onFocusReady: ((Boolean) -> Unit)? = null,
|
||||||
|
onFocus: (() -> Unit)? = null,
|
||||||
|
onFocusDownFallback: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (focusRequester != null) {
|
||||||
|
DisposableEffect(focusRequester) {
|
||||||
|
onFocusReady?.invoke(true)
|
||||||
|
onDispose { onFocusReady?.invoke(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mod = Modifier
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
|
.then(
|
||||||
|
if (focusUp != null || (focusDown != null && onFocusDownFallback == null)) {
|
||||||
|
Modifier.focusProperties {
|
||||||
|
focusUp?.let { up = it }
|
||||||
|
if (onFocusDownFallback == null) {
|
||||||
|
focusDown?.let { down = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (onFocusDownFallback != null) {
|
||||||
|
Modifier.onPreviewKeyEvent { ev ->
|
||||||
|
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) {
|
||||||
|
onFocusDownFallback()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) onFocus?.invoke()
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
modifier = mod,
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (active) McColors.Accent else McColors.Surface,
|
containerColor = if (active) McColors.Accent else McColors.Surface,
|
||||||
focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus,
|
focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus,
|
||||||
|
|
@ -1240,9 +1432,28 @@ private fun metaLine(event: ScheduleEvent): String =
|
||||||
|
|
||||||
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 { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
|
?: event.images.firstOrNull { looksLikeArtwork(it) }
|
||||||
?: event.imagePortrait?.takeIf { it.isNotBlank() }
|
?: event.imagePortrait?.takeIf { it.isNotBlank() }
|
||||||
?: event.images.firstOrNull()
|
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
|
||||||
|
|
||||||
|
/** 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.endsWith(".svg")) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasUsableHeroArt(event: ScheduleEvent): Boolean {
|
||||||
|
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.
|
||||||
|
val portrait = event.imagePortrait?.takeIf { it.isNotBlank() } ?: return false
|
||||||
|
return looksLikeArtwork(portrait)
|
||||||
|
}
|
||||||
|
|
||||||
private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
|
private fun prettyProvider(raw: String): String = when (raw.lowercase()) {
|
||||||
"viaplay" -> "Viaplay"
|
"viaplay" -> "Viaplay"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue