diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index b94075c..732c1bd 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 = 122 - versionName = "0.14.59" + versionCode = 123 + versionName = "0.14.60" 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/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index f596297..74019c8 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 @@ -73,6 +73,7 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ScheduleEvent import nl.vonas.mediacluster.tv.ui.components.HomeChrome @@ -133,6 +134,8 @@ fun EventsScreen( var restoreCardReady by remember { mutableStateOf(false) } var heroCtaFocused by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) } + // Houd carousel stil tijdens + kort na restore zodat Crossfade geen focus steelt. + var pauseCarouselAfterRestore by remember { mutableStateOf(false) } val listState = rememberLazyListState() val chipsFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() } @@ -335,7 +338,8 @@ fun EventsScreen( else -> railFocusReady } - // Detail → back: card-restore als we vanaf een rail openden; anders hero CTA (veilig). + // Detail → back: één restore-owner. Card eerst; hero-fallback alleen als card + // nog niet gelukt is (timeout-job wordt bij succes gecanceld). LaunchedEffect( pendingFocusRestore, focusRestoreNonce, @@ -345,53 +349,89 @@ fun EventsScreen( restoreIsFirstOfFirstRail, ) { if (!pendingFocusRestore || !hasContent) return@LaunchedEffect + pauseCarouselAfterRestore = true delay(60) onContentFocused() - var focusedCard = false - if (canRestoreCard && restoreListIndex >= 0 && activeRestoreFocus != null) { - runCatching { - listState.scrollToItem(restoreListIndex) - delay(50) - val ready = if (restoreIsFirstOfFirstRail) { - awaitReady({ railFocusReady }, timeoutMs = 500) - } else { - awaitReady({ restoreCardReady }, timeoutMs = 500) - } - if (ready) { - activeRestoreFocus.requestFocus() - focusedCard = true - } - } - } + var restoredSuccessfully = false - if (!focusedCard) { - // Hero/entry fallback — scroll naar top alleen als we géén card restoren. + suspend fun focusHeroFallback() { + if (restoredSuccessfully) return val entryOk = awaitReady({ when { showHero -> heroFocusReady showProviderChips -> chipsFocusReady else -> railFocusReady } - }, timeoutMs = 500) + }, timeoutMs = 400) + if (restoredSuccessfully) return if (entryOk) { runCatching { listState.scrollToItem(0) } delay(40) + if (restoredSuccessfully) return runCatching { contentFocusRequester.requestFocus() } } } - onFocusRestoreConsumed() + + // Timeout → hero alleen als card-restore nog niet slaagde. + val heroTimeoutJob = launch { + delay(550) + if (!restoredSuccessfully) { + focusHeroFallback() + if (!restoredSuccessfully) onFocusRestoreConsumed() + } + } + + if (canRestoreCard && restoreListIndex >= 0 && activeRestoreFocus != null) { + runCatching { + listState.scrollToItem(restoreListIndex) + delay(50) + yield() + val ready = if (restoreIsFirstOfFirstRail) { + awaitReady({ railFocusReady }, timeoutMs = 450) + } else { + awaitReady({ restoreCardReady }, timeoutMs = 450) + } + if (ready) { + activeRestoreFocus.requestFocus() + restoredSuccessfully = true + heroTimeoutJob.cancel() + // Clear pending IMMEDIATELY zodat late effects niets meer doen. + onFocusRestoreConsumed() + } + } + } else { + // Geen card-target (hero-open of onbekend id): meteen hero, cancel timeout. + heroTimeoutJob.cancel() + focusHeroFallback() + onFocusRestoreConsumed() + restoredSuccessfully = true + } + + if (!restoredSuccessfully) { + // Card-poging faalde vóór timeout — wacht op fallback-job. + if (heroTimeoutJob.isActive) heroTimeoutJob.join() + else { + focusHeroFallback() + onFocusRestoreConsumed() + } + } + + // Carousel nog ~2s stil na restore (Crossfade mag focus niet stelen). + delay(2_000) + pauseCarouselAfterRestore = false } - LaunchedEffect(entryReady, hasContent) { + LaunchedEffect(entryReady, hasContent, pendingFocusRestore) { if (!entryReady || !hasContent || !placeholderHadFocus) return@LaunchedEffect + if (pendingFocusRestore) return@LaunchedEffect delay(40) onContentFocused() runCatching { contentFocusRequester.requestFocus() } placeholderHadFocus = false } - val pauseCarousel = pendingFocusRestore || heroCtaFocused + val pauseCarousel = pendingFocusRestore || pauseCarouselAfterRestore || heroCtaFocused // NOOIT dual-bind: placeholder alleen als entry-target NIET in de tree hangt. val entryInTree = hasContent && (showHero || showProviderChips || hasRails) @@ -447,13 +487,18 @@ fun EventsScreen( onFocusReady = { heroFocusReady = it }, onFocus = { onContentFocused() - expandHeroFully() + // Niet tijdens card-restore: scroll/BringIntoView trekt focus terug naar hero. + if (!pendingFocusRestore && !pauseCarouselAfterRestore) { + expandHeroFully() + } }, onCtaFocusChanged = { focused -> heroCtaFocused = focused if (focused) { onContentFocused() - expandHeroFully() + if (!pendingFocusRestore && !pauseCarouselAfterRestore) { + expandHeroFully() + } } }, onOpen = { id -> diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index ba689fe..4109897 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -720,9 +720,12 @@ private fun MainHub( // Terug van EventDetail/Player: MainHub is opnieuw gemount — forceer content-focus // zodat TopNav niet “nav-only” blijft en D-pad down niet doodloopt. + // Bij pending restore is EventsScreen de enige owner (card of hero-fallback) — + // géén parallel hero requestFocus (race: hero→card→hero). LaunchedEffect(eventsReturnNonce) { if (eventsReturnNonce <= 0 || tab != MainTab.Events) return@LaunchedEffect navFocused = false + if (pendingEventsFocusRestore) return@LaunchedEffect delay(100) runCatching { eventsContentFocus.requestFocus() } delay(180)