From 58a830d257f45923673de863ae0aa933c59a24a8 Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Mon, 11 May 2026 17:12:33 +0200 Subject: [PATCH 1/3] fix: preserve heroItem during loadHomeData reloads to prevent Continue Watching hero override --- .../arflix/tv/ui/screens/home/HomeScreen.kt | 42 +++++++++++++++---- .../tv/ui/screens/home/HomeViewModel.kt | 37 +++++++++++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 9beed6a9f..99453ec2b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -569,7 +569,16 @@ fun HomeScreen( preloadedCategories } val displayHeroItem = uiState.heroItem ?: preloadedHeroItem - ?: displayCategories.firstOrNull()?.items?.firstOrNull() + ?: if (uiState.categories.isEmpty()) { + // Only fall through to first-row hero during the initial-load window + // when both uiState and preloaded data are empty. Once categories are + // populated, the hero-update LaunchedEffect drives heroItem via focus, + // so this fallback should never be reached — if it is, it means the + // hero vanished unexpectedly and we silently show wrong item #1. + displayCategories.firstOrNull()?.items?.firstOrNull() + } else { + null + } val displayHeroLogo = uiState.heroLogoUrl ?: preloadedHeroLogoUrl val displayHeroOverview = uiState.heroOverviewOverride val latestDisplayCategories by rememberUpdatedState(displayCategories) @@ -705,24 +714,38 @@ fun HomeScreen( .distinctUntilChanged() .collectLatest { focusSnapshot -> val categoriesSnapshot = latestDisplayCategories - if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused) return@collectLatest - if (focusSnapshot.focusedItemKey.isBlank()) return@collectLatest - if (focusSnapshot.focusedItemKey == focusSnapshot.heroItemKey) return@collectLatest - categoriesSnapshot + if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 708 (empty=${categoriesSnapshot.isEmpty()}, sidebar=${focusState.isSidebarFocused})") + return@collectLatest + } + if (focusSnapshot.focusedItemKey.isBlank()) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 709 (blank focusedKey)") + return@collectLatest + } + if (focusSnapshot.focusedItemKey == focusSnapshot.heroItemKey) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 710 (hero already correct: ${focusSnapshot.heroItemKey})") + return@collectLatest + } + val itemAtPosition = categoriesSnapshot .getOrNull(focusSnapshot.rowIndex) ?.items ?.getOrNull(focusSnapshot.itemIndex) - ?: return@collectLatest + if (itemAtPosition == null) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 715 (no item at row=${focusSnapshot.rowIndex}, col=${focusSnapshot.itemIndex})") + return@collectLatest + } val now = SystemClock.elapsedRealtime() val isFastScrolling = now - focusState.lastNavEventTime < fastScrollThresholdMs if (isFastScrolling) { + android.util.Log.d("HeroDebug", "LaunchedEffect: fast scroll detected, delaying 360ms. focusedKey=${focusSnapshot.focusedItemKey}, heroKey=${focusSnapshot.heroItemKey}") delay(360L) if ( focusState.currentRowIndex != focusSnapshot.rowIndex || focusState.currentItemIndex != focusSnapshot.itemIndex || focusState.isSidebarFocused ) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 726 (focus moved during delay)") return@collectLatest } } @@ -730,10 +753,15 @@ fun HomeScreen( .getOrNull(focusSnapshot.rowIndex) ?.items ?.getOrNull(focusSnapshot.itemIndex) - ?: return@collectLatest + ?: run { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 733 (item vanished after delay)") + return@collectLatest + } if (homeRowItemKey(latestFocusedItem) != focusSnapshot.focusedItemKey) { + android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 735 (item key changed: ${homeRowItemKey(latestFocusedItem)} vs ${focusSnapshot.focusedItemKey})") return@collectLatest } + android.util.Log.d("HeroDebug", "LaunchedEffect: CORRECTING hero from key=${focusSnapshot.heroItemKey} to ${latestFocusedItem.title} (key=${homeRowItemKey(latestFocusedItem)})") viewModel.onFocusChanged(focusSnapshot.rowIndex, focusSnapshot.itemIndex, shouldPrefetch = true) viewModel.updateHeroItem(latestFocusedItem) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index dd0c3c6b3..66a73297d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -177,10 +177,11 @@ class HomeViewModel @Inject constructor( } private fun isActionableMediaItem(item: MediaItem): Boolean { - // Synthetic collection tiles (Netflix/Disney/HBO service cards) carry - // a hash-based id in MediaItem.id that is not a real TMDB id — treating - // them as actionable triggers TMDB /images 404s in the logo preloader. - return item.id > 0 && !item.isPlaceholder && !isCollectionItem(item) + val result = item.id > 0 && !item.isPlaceholder && !isCollectionItem(item) + if (!result) { + android.util.Log.d("HeroDebug", "isActionableMediaItem: FALSE for ${item.title} (id=${item.id}, placeholder=${item.isPlaceholder}, isCollection=${isCollectionItem(item)})") + } + return result } private fun continueWatchingKey(mediaType: MediaType, id: Int): String { @@ -1922,7 +1923,28 @@ class HomeViewModel @Inject constructor( // Launch the independent CW fetch launchContinueWatchingFetch() - val heroItem = chooseInitialHero(categories) + // DEV-NOTE: Preservation during reload is critical. During catalog-triggered + // reloads, chooseInitialHero picks the first Continue Watching row item, which + // unconditionally overwrites whatever item the user is currently focused on. + // The hero-update LaunchedEffect in HomeScreen.kt attempts to correct this, but + // the key-based guard at line ~734 can bail out early if the categories were + // rebuilt and the item at the focused position now has a different instance key. + // Result: the hero gets permanently stuck on the first CW item. + val heroItem = if (_uiState.value.heroItem != null) { + val currentHero = _uiState.value.heroItem!! + // Preserve current hero during reload. Try to find it in the fresh + // categories; if the same id/mediaType still exists, use the fresh + // instance to ensure reference consistency. If not found, keep the + // old hero — it's still valid UI and the hero-update LaunchedEffect + // will correct it when the user moves focus. + categories.asSequence() + .flatMap { it.items.asSequence() } + .firstOrNull { it.id == currentHero.id && it.mediaType == currentHero.mediaType } + ?: currentHero + } else { + chooseInitialHero(categories) + } + android.util.Log.d("HeroDebug", "loadHomeData: heroItem=${heroItem?.title} (id=${heroItem?.id}), preserved=${_uiState.value.heroItem != null}, prevHero=${_uiState.value.heroItem?.title}") // Preload logos for the first visible rows so card overlays appear immediately. // Skip IPTV items — their channel logo is already in item.image. @@ -1961,6 +1983,8 @@ class HomeViewModel @Inject constructor( batchLimit = if (isLowRamDevice) 4 else 6 ) } + val previousHero = _uiState.value.heroItem + android.util.Log.d("HeroDebug", "loadHomeData: PUBLISHING heroItem=${heroItem?.title} (prev=${previousHero?.title}), overwriting previous hero") _uiState.value = _uiState.value.copy( isLoading = _uiState.value.isLoading, categories = categories, @@ -3006,6 +3030,7 @@ class HomeViewModel @Inject constructor( } fun updateHeroItem(item: MediaItem) { + android.util.Log.d("HeroDebug", "updateHeroItem called: title=${item.title}, id=${item.id}, type=${item.mediaType}, isCollection=${isCollectionItem(item)}, isActionable=${isActionableMediaItem(item)}") if (isCollectionItem(item)) { if (item.isPlaceholder) return heroUpdateJob?.cancel() @@ -3110,9 +3135,11 @@ class HomeViewModel @Inject constructor( currentState.heroLogoUrl == logoUrl && !currentState.isHeroTransitioning ) { + android.util.Log.d("HeroDebug", "performHeroUpdate: SKIP - already showing ${item.title}") return } + android.util.Log.d("HeroDebug", "performHeroUpdate: SETTING hero from ${currentHero?.title} to ${item.title}") // Save previous hero for crossfade animation, clear trailer for new hero _uiState.value = currentState.copy( previousHeroItem = currentState.heroItem, From 7f0f918ad5fa08dcd200cdde2fd63df755f67d15 Mon Sep 17 00:00:00 2001 From: Sage Gavin Davids Date: Mon, 11 May 2026 17:50:37 +0200 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 66a73297d..9348b03c6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -177,11 +177,9 @@ class HomeViewModel @Inject constructor( } private fun isActionableMediaItem(item: MediaItem): Boolean { - val result = item.id > 0 && !item.isPlaceholder && !isCollectionItem(item) - if (!result) { - android.util.Log.d("HeroDebug", "isActionableMediaItem: FALSE for ${item.title} (id=${item.id}, placeholder=${item.isPlaceholder}, isCollection=${isCollectionItem(item)})") - } - return result + // Non-actionable items are expected during filtering: invalid IDs cannot be opened, + // placeholders are synthetic UI entries, and collection tiles use their own handling. + return item.id > 0 && !item.isPlaceholder && !isCollectionItem(item) } private fun continueWatchingKey(mediaType: MediaType, id: Int): String { From bf04e875548f39f7d91cad7474f6acb838e1eea0 Mon Sep 17 00:00:00 2001 From: Arvin Date: Thu, 14 May 2026 12:25:19 +0200 Subject: [PATCH 3/3] Clean up home hero reload diagnostics --- .../arflix/tv/ui/screens/home/HomeScreen.kt | 40 +++++-------------- .../tv/ui/screens/home/HomeViewModel.kt | 17 ++------ 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 99453ec2b..fe0d94e9f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -570,11 +570,9 @@ fun HomeScreen( } val displayHeroItem = uiState.heroItem ?: preloadedHeroItem ?: if (uiState.categories.isEmpty()) { - // Only fall through to first-row hero during the initial-load window - // when both uiState and preloaded data are empty. Once categories are - // populated, the hero-update LaunchedEffect drives heroItem via focus, - // so this fallback should never be reached — if it is, it means the - // hero vanished unexpectedly and we silently show wrong item #1. + // Only fall through to first-row hero while the ViewModel is still + // publishing its initial categories. Once categories are populated, + // the hero-update LaunchedEffect drives heroItem from focused cards. displayCategories.firstOrNull()?.items?.firstOrNull() } else { null @@ -714,38 +712,23 @@ fun HomeScreen( .distinctUntilChanged() .collectLatest { focusSnapshot -> val categoriesSnapshot = latestDisplayCategories - if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 708 (empty=${categoriesSnapshot.isEmpty()}, sidebar=${focusState.isSidebarFocused})") - return@collectLatest - } - if (focusSnapshot.focusedItemKey.isBlank()) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 709 (blank focusedKey)") - return@collectLatest - } - if (focusSnapshot.focusedItemKey == focusSnapshot.heroItemKey) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 710 (hero already correct: ${focusSnapshot.heroItemKey})") - return@collectLatest - } - val itemAtPosition = categoriesSnapshot - .getOrNull(focusSnapshot.rowIndex) + if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused) return@collectLatest + if (focusSnapshot.focusedItemKey.isBlank()) return@collectLatest + if (focusSnapshot.focusedItemKey == focusSnapshot.heroItemKey) return@collectLatest + categoriesSnapshot.getOrNull(focusSnapshot.rowIndex) ?.items ?.getOrNull(focusSnapshot.itemIndex) - if (itemAtPosition == null) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 715 (no item at row=${focusSnapshot.rowIndex}, col=${focusSnapshot.itemIndex})") - return@collectLatest - } + ?: return@collectLatest val now = SystemClock.elapsedRealtime() val isFastScrolling = now - focusState.lastNavEventTime < fastScrollThresholdMs if (isFastScrolling) { - android.util.Log.d("HeroDebug", "LaunchedEffect: fast scroll detected, delaying 360ms. focusedKey=${focusSnapshot.focusedItemKey}, heroKey=${focusSnapshot.heroItemKey}") delay(360L) if ( focusState.currentRowIndex != focusSnapshot.rowIndex || focusState.currentItemIndex != focusSnapshot.itemIndex || focusState.isSidebarFocused ) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 726 (focus moved during delay)") return@collectLatest } } @@ -753,15 +736,10 @@ fun HomeScreen( .getOrNull(focusSnapshot.rowIndex) ?.items ?.getOrNull(focusSnapshot.itemIndex) - ?: run { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 733 (item vanished after delay)") - return@collectLatest - } + ?: return@collectLatest if (homeRowItemKey(latestFocusedItem) != focusSnapshot.focusedItemKey) { - android.util.Log.d("HeroDebug", "LaunchedEffect: EXIT at line 735 (item key changed: ${homeRowItemKey(latestFocusedItem)} vs ${focusSnapshot.focusedItemKey})") return@collectLatest } - android.util.Log.d("HeroDebug", "LaunchedEffect: CORRECTING hero from key=${focusSnapshot.heroItemKey} to ${latestFocusedItem.title} (key=${homeRowItemKey(latestFocusedItem)})") viewModel.onFocusChanged(focusSnapshot.rowIndex, focusSnapshot.itemIndex, shouldPrefetch = true) viewModel.updateHeroItem(latestFocusedItem) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 9348b03c6..3ce39e1d3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1921,13 +1921,10 @@ class HomeViewModel @Inject constructor( // Launch the independent CW fetch launchContinueWatchingFetch() - // DEV-NOTE: Preservation during reload is critical. During catalog-triggered - // reloads, chooseInitialHero picks the first Continue Watching row item, which - // unconditionally overwrites whatever item the user is currently focused on. - // The hero-update LaunchedEffect in HomeScreen.kt attempts to correct this, but - // the key-based guard at line ~734 can bail out early if the categories were - // rebuilt and the item at the focused position now has a different instance key. - // Result: the hero gets permanently stuck on the first CW item. + // During catalog-triggered reloads, chooseInitialHero can pick + // the first Continue Watching item and overwrite the currently + // focused hero. Preserve the existing hero when possible, and + // let the focus watcher correct it on the next focus change. val heroItem = if (_uiState.value.heroItem != null) { val currentHero = _uiState.value.heroItem!! // Preserve current hero during reload. Try to find it in the fresh @@ -1942,7 +1939,6 @@ class HomeViewModel @Inject constructor( } else { chooseInitialHero(categories) } - android.util.Log.d("HeroDebug", "loadHomeData: heroItem=${heroItem?.title} (id=${heroItem?.id}), preserved=${_uiState.value.heroItem != null}, prevHero=${_uiState.value.heroItem?.title}") // Preload logos for the first visible rows so card overlays appear immediately. // Skip IPTV items — their channel logo is already in item.image. @@ -1981,8 +1977,6 @@ class HomeViewModel @Inject constructor( batchLimit = if (isLowRamDevice) 4 else 6 ) } - val previousHero = _uiState.value.heroItem - android.util.Log.d("HeroDebug", "loadHomeData: PUBLISHING heroItem=${heroItem?.title} (prev=${previousHero?.title}), overwriting previous hero") _uiState.value = _uiState.value.copy( isLoading = _uiState.value.isLoading, categories = categories, @@ -3028,7 +3022,6 @@ class HomeViewModel @Inject constructor( } fun updateHeroItem(item: MediaItem) { - android.util.Log.d("HeroDebug", "updateHeroItem called: title=${item.title}, id=${item.id}, type=${item.mediaType}, isCollection=${isCollectionItem(item)}, isActionable=${isActionableMediaItem(item)}") if (isCollectionItem(item)) { if (item.isPlaceholder) return heroUpdateJob?.cancel() @@ -3133,11 +3126,9 @@ class HomeViewModel @Inject constructor( currentState.heroLogoUrl == logoUrl && !currentState.isHeroTransitioning ) { - android.util.Log.d("HeroDebug", "performHeroUpdate: SKIP - already showing ${item.title}") return } - android.util.Log.d("HeroDebug", "performHeroUpdate: SETTING hero from ${currentHero?.title} to ${item.title}") // Save previous hero for crossfade animation, clear trailer for new hero _uiState.value = currentState.copy( previousHeroItem = currentState.heroItem,