diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index aa4f403..b94075c 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 = 121 - versionName = "0.14.58" + versionCode = 122 + versionName = "0.14.59" 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 2535bca..f596297 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 @@ -113,11 +113,13 @@ fun EventsScreen( contentFocusRequester: FocusRequester, navFocusRequester: FocusRequester, focusRestoreNonce: Int = 0, - @Suppress("UNUSED_PARAMETER") restoreEventId: String? = null, + restoreEventId: String? = null, + restoreFromHero: Boolean = false, pendingFocusRestore: Boolean = false, onFocusRestoreConsumed: () -> Unit = {}, onOpenEvent: (String) -> Unit, onPlayEvent: (String) -> Unit, + onSetOpenedFromHero: (Boolean) -> Unit = {}, onContentFocused: () -> Unit = {}, ) { var events by remember { mutableStateOf>(emptyList()) } @@ -128,11 +130,13 @@ fun EventsScreen( var heroFocusReady by remember { mutableStateOf(false) } var chipsFocusReady by remember { mutableStateOf(false) } var railFocusReady by remember { mutableStateOf(false) } + var restoreCardReady by remember { mutableStateOf(false) } var heroCtaFocused by remember { mutableStateOf(false) } var placeholderHadFocus by remember { mutableStateOf(false) } val listState = rememberLazyListState() val chipsFocus = remember { FocusRequester() } val firstRailFocus = remember { FocusRequester() } + val restoreCardFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() val density = LocalDensity.current val screenH = LocalConfiguration.current.screenHeightDp.dp @@ -217,6 +221,58 @@ fun EventsScreen( idx } + val restoreInLive = remember(live, restoreEventId, restoreFromHero) { + !restoreFromHero && + !restoreEventId.isNullOrBlank() && + live.any { it.id == restoreEventId } + } + val restoreUpcomingIndex = remember(upcomingRails, restoreEventId, restoreFromHero, restoreInLive) { + if (restoreFromHero || restoreInLive || restoreEventId.isNullOrBlank()) return@remember -1 + upcomingRails.indexOfFirst { (_, day) -> day.any { it.id == restoreEventId } } + } + val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 + val restoreListIndex = remember( + showProviderChips, + live, + upcomingRails, + restoreInLive, + restoreUpcomingIndex, + ) { + if (!canRestoreCard) return@remember -1 + var idx = 1 // after spacer + if (showProviderChips) idx++ + if (restoreInLive) return@remember idx + if (live.isNotEmpty()) idx++ + if (restoreUpcomingIndex >= 0) return@remember idx + restoreUpcomingIndex + -1 + } + // Eerste card van de eerste rail deelt firstRailFocus — géén tweede FocusRequester (dual-bind hang). + val restoreIsFirstOfFirstRail = remember(live, upcomingRails, restoreEventId, restoreInLive) { + if (restoreEventId.isNullOrBlank()) return@remember false + val firstRail = when { + live.isNotEmpty() -> live + upcomingRails.isNotEmpty() -> upcomingRails.first().second + else -> return@remember false + } + val isFirstCard = firstRail.firstOrNull()?.id == restoreEventId + isFirstCard && ( + (restoreInLive && live.isNotEmpty()) || + (!restoreInLive && live.isEmpty()) + ) + } + val activeRestoreFocus = when { + !canRestoreCard -> null + restoreIsFirstOfFirstRail -> when { + showHero || showProviderChips -> firstRailFocus + else -> contentFocusRequester + } + else -> restoreCardFocus + } + // Alleen tijdens pending restore binden — en nooit dual-bind met firstRailFocus. + val bindRestoreCardId = restoreEventId.takeIf { + pendingFocusRestore && canRestoreCard && !restoreIsFirstOfFirstRail + } + fun expandHeroFully() { if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { scope.launch { listState.animateScrollToItem(0) } @@ -279,20 +335,51 @@ fun EventsScreen( else -> railFocusReady } - // Detail → back / tab restore: expand + focus entry (hero CTA or chips/rail). + // Detail → back: card-restore als we vanaf een rail openden; anders hero CTA (veilig). LaunchedEffect( pendingFocusRestore, focusRestoreNonce, hasContent, - entryReady, - entryIsHero, + canRestoreCard, + restoreListIndex, + restoreIsFirstOfFirstRail, ) { - if (!pendingFocusRestore || !hasContent || !entryReady) return@LaunchedEffect + if (!pendingFocusRestore || !hasContent) return@LaunchedEffect delay(60) onContentFocused() - runCatching { listState.scrollToItem(0) } - delay(40) - runCatching { contentFocusRequester.requestFocus() } + + 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 + } + } + } + + if (!focusedCard) { + // Hero/entry fallback — scroll naar top alleen als we géén card restoren. + val entryOk = awaitReady({ + when { + showHero -> heroFocusReady + showProviderChips -> chipsFocusReady + else -> railFocusReady + } + }, timeoutMs = 500) + if (entryOk) { + runCatching { listState.scrollToItem(0) } + delay(40) + runCatching { contentFocusRequester.requestFocus() } + } + } onFocusRestoreConsumed() } @@ -369,8 +456,14 @@ fun EventsScreen( expandHeroFully() } }, - onOpen = { id -> onOpenEvent(id) }, - onPlay = { id -> onPlayEvent(id) }, + onOpen = { id -> + onSetOpenedFromHero(true) + onOpenEvent(id) + }, + onPlay = { id -> + onSetOpenedFromHero(true) + onPlayEvent(id) + }, onRequestNextFocus = { focusAfterHero() }, ) } @@ -423,12 +516,20 @@ fun EventsScreen( title = "Nu live", events = live, nowMs = nowMs, - onOpen = onOpenEvent, + onOpen = { id -> + onSetOpenedFromHero(false) + onOpenEvent(id) + }, onFocus = onContentFocused, firstItemFocusRequester = when { showHero || showProviderChips -> firstRailFocus else -> contentFocusRequester }, + restoreEventId = bindRestoreCardId.takeIf { restoreInLive }, + restoreFocusRequester = restoreCardFocus.takeIf { + bindRestoreCardId != null && restoreInLive + }, + onRestoreFocusReady = { restoreCardReady = it }, focusUpTo = firstRailUpTarget, onFirstFocusReady = { railFocusReady = it }, ) @@ -436,18 +537,29 @@ fun EventsScreen( } upcomingRails.forEachIndexed { index, (label, dayEvents) -> val isFirstUpcoming = live.isEmpty() && index == 0 + val railHasRestore = bindRestoreCardId != null && + !restoreInLive && + dayEvents.any { it.id == bindRestoreCardId } item(key = "day-$label") { EventRail( title = label, events = dayEvents, nowMs = nowMs, - onOpen = onOpenEvent, + onOpen = { id -> + onSetOpenedFromHero(false) + onOpenEvent(id) + }, onFocus = onContentFocused, firstItemFocusRequester = when { !isFirstUpcoming -> null showHero || showProviderChips -> firstRailFocus else -> contentFocusRequester }, + restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, + restoreFocusRequester = restoreCardFocus.takeIf { railHasRestore }, + onRestoreFocusReady = { ready -> + if (railHasRestore) restoreCardReady = ready + }, focusUpTo = firstRailUpTarget.takeIf { isFirstUpcoming }, onFirstFocusReady = { ready -> if (isFirstUpcoming) railFocusReady = ready @@ -924,10 +1036,23 @@ private fun EventRail( onOpen: (String) -> Unit, onFocus: () -> Unit, firstItemFocusRequester: FocusRequester? = null, + restoreEventId: String? = null, + restoreFocusRequester: FocusRequester? = null, + onRestoreFocusReady: ((Boolean) -> Unit)? = null, focusUpTo: FocusRequester? = null, onFirstFocusReady: ((Boolean) -> Unit)? = null, ) { if (events.isEmpty()) return + val rowState = rememberLazyListState() + val restoreIndex = remember(events, restoreEventId) { + if (restoreEventId.isNullOrBlank()) -1 + else events.indexOfFirst { it.id == restoreEventId } + } + LaunchedEffect(restoreIndex) { + if (restoreIndex > 0) { + runCatching { rowState.scrollToItem(restoreIndex) } + } + } Column(Modifier.padding(top = 20.dp)) { Text( title, @@ -938,24 +1063,38 @@ private fun EventRail( ) Spacer(Modifier.height(12.dp)) LazyRow( + state = rowState, contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), horizontalArrangement = Arrangement.spacedBy(14.dp), ) { items(events, key = { it.id }) { ev -> val isFirst = ev.id == events.first().id - if (isFirst && firstItemFocusRequester != null) { + val isRestore = restoreEventId != null && ev.id == restoreEventId + // Één FocusRequester per card — nooit dual-bind (extraFocusRequester hang). + val cardRequester = when { + isRestore && restoreFocusRequester != null -> restoreFocusRequester + isFirst -> firstItemFocusRequester + else -> null + } + if (isFirst && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) { DisposableEffect(firstItemFocusRequester) { onFirstFocusReady?.invoke(true) onDispose { onFirstFocusReady?.invoke(false) } } } + if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) { + DisposableEffect(restoreFocusRequester) { + onRestoreFocusReady?.invoke(true) + onDispose { onRestoreFocusReady?.invoke(false) } + } + } EventCard( event = ev, nowMs = nowMs, onOpen = { onOpen(ev.id) }, onFocus = onFocus, - focusRequester = firstItemFocusRequester.takeIf { isFirst }, - focusUp = focusUpTo.takeIf { isFirst }, + focusRequester = cardRequester, + focusUp = focusUpTo.takeIf { isFirst || isRestore }, ) } } 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 8cbb62d..ba689fe 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 @@ -130,6 +130,7 @@ fun McNav() { var tvFocusNonce by remember { mutableIntStateOf(0) } var eventsReturnNonce by remember { mutableIntStateOf(0) } var lastOpenedEventId by rememberSaveable { mutableStateOf(null) } + var lastOpenedFromHero by rememberSaveable { mutableStateOf(false) } var pendingEventsFocusRestore by remember { mutableStateOf(false) } val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet()) val hubTab = remember(hubTabName) { @@ -326,8 +327,10 @@ fun McNav() { onTvFocusContent = { tvFocusNonce++ }, eventsReturnNonce = eventsReturnNonce, restoreEventId = lastOpenedEventId, + restoreFromHero = lastOpenedFromHero, pendingEventsFocusRestore = pendingEventsFocusRestore, onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false }, + onSetOpenedFromHero = { lastOpenedFromHero = it }, profileAvatarId = profileAvatarId, profileShowAvatar = profileShowAvatar, profileAvatarUrl = profileAvatarUrl, @@ -547,8 +550,10 @@ private fun MainHub( onTvFocusContent: () -> Unit, eventsReturnNonce: Int = 0, restoreEventId: String? = null, + restoreFromHero: Boolean = false, pendingEventsFocusRestore: Boolean = false, onEventsFocusRestoreConsumed: () -> Unit = {}, + onSetOpenedFromHero: (Boolean) -> Unit = {}, profileAvatarId: String?, profileShowAvatar: Boolean = true, profileAvatarUrl: String? = null, @@ -931,10 +936,12 @@ private fun MainHub( navFocusRequester = navFocus, focusRestoreNonce = eventsReturnNonce, restoreEventId = restoreEventId, + restoreFromHero = restoreFromHero, pendingFocusRestore = pendingEventsFocusRestore, onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onOpenEvent = onOpenEvent, onPlayEvent = onPlayEvent, + onSetOpenedFromHero = onSetOpenedFromHero, onContentFocused = { navFocused = false }, ) MainTab.Search -> SearchScreen(