From ab3f13837224d255ea8406083cd98cd663b9bd68 Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Tue, 7 Apr 2026 01:43:10 -0700 Subject: [PATCH 1/6] Fix collections-only and hero-only home screen loading, focus, and WebUI - Fix infinite spinner when only collections enabled (no catalogs) - Load hero-selected catalogs independently of home row settings - Fix hero startup race condition (300ms preference debounce) - Fix Classic layout hero focus stolen by collections - Fix Modern layout focus landing on collections instead of first catalog - Modern layout HeroCarousel fallback when no carousel rows - Add Enable All / Disable All buttons for catalogs and collections in WebUI - Clear stale error state when content arrives after initial load Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/nuvio/tv/core/server/AddonWebPage.kt | 52 +++++++ .../tv/ui/screens/home/ClassicHomeContent.kt | 38 +++++- .../nuvio/tv/ui/screens/home/HomeScreen.kt | 17 ++- .../home/HomeViewModelCatalogPipeline.kt | 128 +++++++++++++++++- .../home/HomeViewModelPresentationPipeline.kt | 12 +- .../tv/ui/screens/home/ModernHomeContent.kt | 34 ++++- 6 files changed, 258 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt index 28c65501..d409be6a 100644 --- a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt +++ b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt @@ -867,12 +867,20 @@ object AddonWebPage {
+
+ + +
    ${context.getString(R.string.web_no_catalogs)}
    +
    + + +
    @@ -1247,6 +1255,50 @@ function toggleCatalog(index) { renderCatalogs(); } +function enableAllCatalogs() { + catalogs.forEach(function(item) { + item.isDisabled = false; + if (item.isCollection) { + var key = 'collection_' + item.collectionId; + var idx = disabledCollectionKeys.indexOf(key); + if (idx >= 0) disabledCollectionKeys.splice(idx, 1); + } + }); + renderCatalogs(); +} + +function disableAllCatalogs() { + catalogs.forEach(function(item) { + item.isDisabled = true; + if (item.isCollection) { + var key = 'collection_' + item.collectionId; + if (disabledCollectionKeys.indexOf(key) < 0) disabledCollectionKeys.push(key); + } + }); + renderCatalogs(); +} + +function enableAllCollections() { + disabledCollectionKeys = []; + catalogs.forEach(function(item) { + if (item.isCollection) item.isDisabled = false; + }); + renderCatalogs(); + renderCollections(); +} + +function disableAllCollections() { + collections.forEach(function(col) { + var key = 'collection_' + col.id; + if (disabledCollectionKeys.indexOf(key) < 0) disabledCollectionKeys.push(key); + }); + catalogs.forEach(function(item) { + if (item.isCollection) item.isDisabled = true; + }); + renderCatalogs(); + renderCollections(); +} + async function addAddon() { const input = document.getElementById('addonUrl'); const errorEl = document.getElementById('addError'); diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt index 1dfd5b12..d7367c33 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos + import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -28,10 +29,15 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.Collection +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment import com.nuvio.tv.ui.components.CatalogRowSection import com.nuvio.tv.ui.components.CollectionRowSection import com.nuvio.tv.ui.components.ContinueWatchingSection import com.nuvio.tv.ui.components.HeroCarousel +import com.nuvio.tv.ui.components.LoadingIndicator import com.nuvio.tv.ui.components.PosterCardStyle /** Minimum interval between processed key repeat events to prevent HWUI overload. */ @@ -145,12 +151,22 @@ fun ClassicHomeContent( val heroVisible = uiState.heroSectionEnabled && uiState.heroItems.isNotEmpty() - LaunchedEffect(shouldRequestInitialFocus, heroVisible, uiState.heroItems.size) { + // Hero is "expected" when the user has explicitly selected hero catalogs — + // those load late via loadHeroCatalogsPipeline (after the 300ms preference + // debounce). Until the hero resolves, we defer content focus so rows + // can't steal it. + val heroExpected = uiState.heroSectionEnabled && uiState.heroCatalogKeys.isNotEmpty() + val heroResolved = !heroExpected || heroVisible + val deferContentFocus = shouldRequestInitialFocus && !heroResolved + + LaunchedEffect(shouldRequestInitialFocus, heroVisible) { if (!shouldRequestInitialFocus || !heroVisible) return@LaunchedEffect - repeat(2) { withFrameNanos { } } - try { - heroFocusRequester.requestFocus() - } catch (_: IllegalStateException) { + columnListState.scrollToItem(0) + repeat(8) { + withFrameNanos { } + val focused = runCatching { heroFocusRequester.requestFocus(); true } + .getOrDefault(false) + if (focused) return@LaunchedEffect } } @@ -158,6 +174,18 @@ fun ClassicHomeContent( var lastKeyRepeatTime by remember { mutableStateOf(0L) } val contentFocusRequester = LocalContentFocusRequester.current + if (deferContentFocus) { + // Show spinner while waiting for hero data to arrive — prevents + // content rows from claiming focus before the hero is ready. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + return + } + LazyColumn( state = columnListState, modifier = Modifier diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt index f304ab7a..6a116e6f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt @@ -89,6 +89,8 @@ fun HomeScreen( initialValue = false ) val hasCatalogContent = uiState.catalogRows.any { it.items.isNotEmpty() } + val hasCollectionContent = uiState.homeRows.any { it is HomeRow.CollectionRow } + val hasHeroContent = uiState.heroItems.isNotEmpty() var hasEnteredCatalogContent by rememberSaveable { mutableStateOf(false) } var showHomeContentWithAnimation by rememberSaveable { mutableStateOf(false) } var hasReleasedStartupCwGate by rememberSaveable { mutableStateOf(false) } @@ -107,8 +109,8 @@ fun HomeScreen( { item, addonBaseUrl -> posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl) } } - LaunchedEffect(hasCatalogContent) { - if (hasCatalogContent) { + LaunchedEffect(hasCatalogContent, hasCollectionContent, hasHeroContent) { + if (hasCatalogContent || hasCollectionContent || hasHeroContent) { hasEnteredCatalogContent = true } } @@ -118,9 +120,9 @@ fun HomeScreen( startupCwGateTimedOut = true } - LaunchedEffect(uiState.continueWatchingItems.isNotEmpty(), startupCwGateTimedOut) { + LaunchedEffect(uiState.continueWatchingItems.isNotEmpty(), startupCwGateTimedOut, uiState.isLoading) { if (!hasReleasedStartupCwGate && - (uiState.continueWatchingItems.isNotEmpty() || startupCwGateTimedOut) + (uiState.continueWatchingItems.isNotEmpty() || startupCwGateTimedOut || !uiState.isLoading) ) { hasReleasedStartupCwGate = true } @@ -151,7 +153,8 @@ fun HomeScreen( ) { val hasAnyContent = uiState.catalogRows.isNotEmpty() || uiState.continueWatchingItems.isNotEmpty() || - uiState.heroItems.isNotEmpty() + uiState.heroItems.isNotEmpty() || + hasCollectionContent when { uiState.isLoading && !hasAnyContent -> { @@ -176,7 +179,7 @@ fun HomeScreen( } } - uiState.error == "No catalog addons installed" && uiState.catalogRows.isEmpty() -> { + uiState.error == "No catalog addons installed" && uiState.catalogRows.isEmpty() && !hasCollectionContent && !hasHeroContent -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -199,7 +202,7 @@ fun HomeScreen( else -> { val shouldShowLoadingGate = !hasReleasedStartupCwGate || - (!hasEnteredCatalogContent && !hasCatalogContent) + (!hasEnteredCatalogContent && !hasCatalogContent && !hasCollectionContent && !hasHeroContent) LaunchedEffect(shouldShowLoadingGate) { if (shouldShowLoadingGate) { showHomeContentWithAnimation = false diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt index bb24dfd9..85b9ff0c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt @@ -142,7 +142,16 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline( rebuildCatalogOrder(addons) - if (catalogOrder.isEmpty()) { + // Hero has its own catalog sources (heroCatalogKeys) configured + // independently in Layout Settings. When the user has explicitly + // selected hero catalogs, load those even if they are disabled from + // home rows. When no hero catalogs are selected, the hero simply + // piggybacks on whatever home catalogs are loaded — if none are + // loaded, the hero has no data and won't render. + val heroCatalogSet = currentHeroCatalogKeys.toSet() + val hasHeroSelections = heroCatalogSet.isNotEmpty() + + if (catalogOrder.isEmpty() && !hasHeroSelections) { catalogsLoadInProgress = false _uiState.update { it.copy(isLoading = false, error = appContext.getString(R.string.home_error_no_catalog_addons)) } return @@ -161,8 +170,39 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline( } .map { catalog -> addon to catalog } } - pendingCatalogLoads = catalogsToLoad.size - catalogsToLoad.forEach { (addon, catalog) -> + + // Load hero-selected catalogs even if disabled from home rows — + // the hero has its own catalog source independent of home rows. + val alreadyLoadingKeys = catalogsToLoad.map { (addon, catalog) -> + catalogKey(addonId = addon.id, type = catalog.apiType, catalogId = catalog.id) + }.toSet() + val heroOnlyCatalogs = if (hasHeroSelections) { + addons.flatMap { addon -> + addon.catalogs + .filter { catalog -> + val key = catalogKey(addonId = addon.id, type = catalog.apiType, catalogId = catalog.id) + key in heroCatalogSet && key !in alreadyLoadingKeys && !catalog.isSearchOnlyCatalog() + } + .map { catalog -> addon to catalog } + } + } else { + emptyList() + } + + val allCatalogsToLoad = catalogsToLoad + heroOnlyCatalogs + pendingCatalogLoads = allCatalogsToLoad.size + if (allCatalogsToLoad.isEmpty()) { + // No home catalogs and no hero catalogs to load — + // but collections may still exist to render. + catalogsLoadInProgress = false + if (catalogOrder.isNotEmpty()) { + scheduleUpdateCatalogRows() + } else { + _uiState.update { it.copy(isLoading = false, error = appContext.getString(R.string.home_error_no_catalog_addons)) } + } + return + } + allCatalogsToLoad.forEach { (addon, catalog) -> loadCatalogPipeline(addon, catalog, generation) } } catch (e: Exception) { @@ -171,6 +211,48 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline( } } +/** + * Additively loads hero-selected catalogs that are not already in [catalogsMap]. + * Unlike [loadAllCatalogsPipeline] this does NOT clear existing state — it only + * fills in missing hero catalog data so the hero section can render. + * + * Called from the presentation pipeline when [currentHeroCatalogKeys] arrives + * after the initial catalog load (due to the layout preference debounce). + */ +internal fun HomeViewModel.loadHeroCatalogsPipeline() { + val heroCatalogKeys = currentHeroCatalogKeys + if (heroCatalogKeys.isEmpty() || addonsCache.isEmpty()) return + + val heroCatalogSet = heroCatalogKeys.toSet() + val alreadyLoadedKeys = catalogsMap.keys.toSet() + val missingHeroKeys = heroCatalogSet - alreadyLoadedKeys + if (missingHeroKeys.isEmpty()) { + // All hero catalogs already loaded — just refresh presentation + scheduleUpdateCatalogRows() + return + } + + val heroToLoad = addonsCache.flatMap { addon -> + addon.catalogs + .filter { catalog -> + val key = catalogKey(addonId = addon.id, type = catalog.apiType, catalogId = catalog.id) + key in missingHeroKeys && !catalog.isSearchOnlyCatalog() + } + .map { catalog -> addon to catalog } + } + + if (heroToLoad.isEmpty()) { + scheduleUpdateCatalogRows() + return + } + + val generation = catalogLoadGeneration + pendingCatalogLoads += heroToLoad.size + heroToLoad.forEach { (addon, catalog) -> + loadCatalogPipeline(addon, catalog, generation) + } +} + internal fun HomeViewModel.loadCatalogPipeline( addon: Addon, catalog: CatalogDescriptor, @@ -312,11 +394,25 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { rawRows } val selectedHeroCatalogSet = heroCatalogKeys.toSet() + val orderedKeySet = orderedKeys.toSet() val selectedHeroRows = if (selectedHeroCatalogSet.isNotEmpty()) { - orderedRows.filter { row -> + // Include hero catalogs from ordered rows + val fromOrdered = orderedRows.filter { row -> val key = "${row.addonId}_${row.apiType}_${row.catalogId}" key in selectedHeroCatalogSet } + // Also include hero catalogs loaded but not in catalog order + // (e.g., catalogs disabled from home rows but selected for hero) + val heroOnlyRows = selectedHeroCatalogSet + .filter { it !in orderedKeySet } + .mapNotNull { catalogSnapshot[it] } + val heroOnlyFiltered = if (hideUnreleased) { + val today = LocalDate.now() + heroOnlyRows.map { it.filterReleasedItems(today) } + } else { + heroOnlyRows + } + fromOrdered + heroOnlyFiltered } else { emptyList() } @@ -356,8 +452,23 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { val fallbackHeroItemsFromSelectedCatalogs = slotShuffled( selectedHeroRows, { true }, currentHeroOrder ) + // When orderedRows is empty (all catalogs disabled), include any + // hero-only loaded catalogs as fallback hero sources. + val allHeroFallbackRows = if (orderedRows.isNotEmpty()) { + orderedRows + } else { + val nonOrderedRows = catalogSnapshot.keys + .filter { it !in orderedKeySet } + .mapNotNull { catalogSnapshot[it] } + if (hideUnreleased) { + val today = LocalDate.now() + nonOrderedRows.map { it.filterReleasedItems(today) } + } else { + nonOrderedRows + } + } val fallbackHeroItemsWithArtwork = slotShuffled( - orderedRows, { it.hasHeroArtwork() }, currentHeroOrder + allHeroFallbackRows, { it.hasHeroArtwork() }, currentHeroOrder ) val computedHeroItems = when { @@ -487,13 +598,18 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { currentGridItems } + // Clear any stale error when content is now available (e.g., hero + // catalogs loaded after the initial startup race set an error). + val hasContent = computedHomeRows.isNotEmpty() || baseHeroItems.isNotEmpty() || displayRows.isNotEmpty() + _uiState.update { state -> state.copy( catalogRows = if (state.catalogRows == displayRows) state.catalogRows else displayRows, heroItems = if (state.heroItems == baseHeroItems) state.heroItems else baseHeroItems, gridItems = if (state.gridItems == nextGridItems) state.gridItems else nextGridItems, homeRows = if (state.homeRows == computedHomeRows) state.homeRows else computedHomeRows, - isLoading = false + isLoading = false, + error = if (hasContent) null else state.error ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt index 1e7cd415..69f4e335 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt @@ -168,8 +168,9 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { prefs.posterLabelsEnabled } val previousState = _uiState.value + val heroKeysChanged = currentHeroCatalogKeys != prefs.heroCatalogKeys val shouldRefreshCatalogPresentation = - currentHeroCatalogKeys != prefs.heroCatalogKeys || + heroKeysChanged || previousState.heroSectionEnabled != prefs.heroSectionEnabled || previousState.homeLayout != prefs.layout || previousState.hideUnreleasedContent != prefs.hideUnreleasedContent || @@ -198,7 +199,14 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { ) } if (shouldRefreshCatalogPresentation) { - scheduleUpdateCatalogRows() + // When hero catalog keys change, load any hero catalogs + // not yet in catalogsMap (e.g., after startup race or + // when user changes hero selection in settings). + if (heroKeysChanged && prefs.heroCatalogKeys.isNotEmpty()) { + loadHeroCatalogsPipeline() + } else { + scheduleUpdateCatalogRows() + } } } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index 8c756e06..12ae2217 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -101,6 +101,7 @@ import androidx.compose.ui.res.stringResource import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.ui.components.ContinueWatchingCard +import com.nuvio.tv.ui.components.HeroCarousel import com.nuvio.tv.ui.components.ContinueWatchingOptionsDialog import com.nuvio.tv.ui.components.MonochromePosterPlaceholder import com.nuvio.tv.ui.components.TrailerPlayer @@ -343,7 +344,21 @@ fun ModernHomeContent( } } - if (carouselRows.isEmpty()) return + if (carouselRows.isEmpty()) { + // No carousel rows but hero items may exist — show standalone hero + if (uiState.heroSectionEnabled && uiState.heroItems.isNotEmpty()) { + Box(modifier = Modifier.fillMaxSize()) { + HeroCarousel( + items = uiState.heroItems, + onItemClick = { item -> + onNavigateToDetail(item.id, item.apiType, "") + }, + onItemFocus = onItemFocus + ) + } + } + return + } val carouselLookups = remember(carouselRows) { val rowIndexByKey = LinkedHashMap(carouselRows.size) val rowByKey = LinkedHashMap(carouselRows.size) @@ -544,7 +559,20 @@ fun ModernHomeContent( val hadActiveRow = focusHolder.activeRowKey != null val existingActive = focusHolder.activeRowKey?.let(rowByKey::get) - val resolvedActive = existingActive ?: carouselRows.first() + val firstRow = carouselRows.first() + // When new rows appear before the auto-selected row (e.g., catalogs + // load after collections), move focus to the new first row — but only + // if the user hasn't manually navigated away from the initial position. + val autoSelectedStale = hadActiveRow && existingActive != null && + existingActive.key != firstRow.key && + rowIndexByKey.getOrDefault(existingActive.key, 0) > 0 && + !restoredFromSavedState && + pendingRowFocusKey == existingActive.key + val resolvedActive = when { + autoSelectedStale -> firstRow + existingActive != null -> existingActive + else -> firstRow + } val resolvedIndex = focusedItemByRow[resolvedActive.key] ?.coerceIn(0, (resolvedActive.items.size - 1).coerceAtLeast(0)) ?: 0 @@ -555,7 +583,7 @@ fun ModernHomeContent( focusedItemByRow[resolvedActive.key] = resolvedIndex heroItem = resolvedActive.items.getOrNull(resolvedIndex)?.heroPreview ?: resolvedActive.items.firstOrNull()?.heroPreview - if (!focusState.hasSavedFocus && (!hadActiveRow || existingActive == null)) { + if (!focusState.hasSavedFocus && (!hadActiveRow || existingActive == null || autoSelectedStale)) { pendingRowFocusKey = resolvedActive.key pendingRowFocusIndex = resolvedIndex pendingRowFocusNonce++ From d6e409574185b157c0e7e1265e0338ace7a5d437 Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Wed, 8 Apr 2026 07:30:18 -0700 Subject: [PATCH 2/6] Fix Classic hero focus lost on app reopen via app switcher The default layout (MODERN) composes first on fresh start, then preferences load and switch to CLASSIC. Modern's onDispose was saving focus state (hasSavedFocus=true) which poisoned Classic's initial focus logic, preventing the hero from receiving focus. - Add suppressFocusSave flag to block outgoing layout's dispose save - Defer Classic content focus with spinner until hero resolves - Use heroSectionEnabled (default true) for deferral since heroCatalogKeys arrives late via 300ms preference debounce - 2s safety timeout prevents infinite spinner if hero has no data Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tv/ui/screens/home/ClassicHomeContent.kt | 15 +++++++++------ .../com/nuvio/tv/ui/screens/home/HomeViewModel.kt | 9 +++++++++ .../home/HomeViewModelPresentationPipeline.kt | 10 ++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt index d7367c33..2f7a97d5 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.delay import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -151,13 +152,15 @@ fun ClassicHomeContent( val heroVisible = uiState.heroSectionEnabled && uiState.heroItems.isNotEmpty() - // Hero is "expected" when the user has explicitly selected hero catalogs — - // those load late via loadHeroCatalogsPipeline (after the 300ms preference - // debounce). Until the hero resolves, we defer content focus so rows - // can't steal it. - val heroExpected = uiState.heroSectionEnabled && uiState.heroCatalogKeys.isNotEmpty() + val heroExpected = uiState.heroSectionEnabled val heroResolved = !heroExpected || heroVisible - val deferContentFocus = shouldRequestInitialFocus && !heroResolved + var heroDeferTimedOut by remember { mutableStateOf(false) } + LaunchedEffect(shouldRequestInitialFocus, heroExpected) { + if (!shouldRequestInitialFocus || !heroExpected) return@LaunchedEffect + delay(2000) + heroDeferTimedOut = true + } + val deferContentFocus = shouldRequestInitialFocus && !heroResolved && !heroDeferTimedOut LaunchedEffect(shouldRequestInitialFocus, heroVisible) { if (!shouldRequestInitialFocus || !heroVisible) return@LaunchedEffect diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index e44d7c2a..335014a2 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -484,6 +484,11 @@ class HomeViewModel @Inject constructor( /** * Saves the current focus and scroll state for restoration when returning to this screen. */ + // When true, the next saveFocusState call is suppressed and the flag + // is reset. Used during layout switches to prevent the outgoing + // layout's onDispose from poisoning the incoming layout's focus state. + internal var suppressFocusSave: Boolean = false + fun saveFocusState( verticalScrollIndex: Int, verticalScrollOffset: Int, @@ -491,6 +496,10 @@ class HomeViewModel @Inject constructor( focusedItemIndex: Int, catalogRowScrollStates: Map ) { + if (suppressFocusSave) { + suppressFocusSave = false + return + } val nextState = HomeScreenFocusState( verticalScrollIndex = verticalScrollIndex, verticalScrollOffset = verticalScrollOffset, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt index 69f4e335..51b354da 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt @@ -176,6 +176,16 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { previousState.hideUnreleasedContent != prefs.hideUnreleasedContent || previousState.posterCardWidthDp != prefs.posterCardWidthDp currentHeroCatalogKeys = prefs.heroCatalogKeys + // Reset focus state when layout changes so the outgoing + // layout's onDispose doesn't poison the incoming layout + // (e.g., Modern dispose saves hasSavedFocus=true right + // before Classic composes, preventing hero initial focus). + if (previousState.homeLayout != prefs.layout) { + // Suppress the outgoing layout's onDispose from saving + // stale focus state before the incoming layout composes. + suppressFocusSave = true + clearFocusState() + } _uiState.update { it.copy( homeLayout = prefs.layout, From 09c5480593ac33c3fb4dcafee13111aa85ff2ba5 Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Wed, 8 Apr 2026 09:19:11 -0700 Subject: [PATCH 3/6] Collection UI fixes: focus tracking, WebUI improvements, hideTitle in Modern - Fix collection row focus restoration using key-based matching instead of positional index (survives row shifts during catalog loading) - Add per-item FocusRequesters to CollectionRowSection for proper focus restore when navigating back from folder detail - Remove Copy JSON and Paste import from Android TV (keep in WebUI) - Default import mode to File instead of Paste - WebUI: add Collection badge pill in Home Layout tab, send to top/bottom buttons, tab renamed to Home Layout, 2x2 grid for order buttons - Update subtitle to "Manage addons, catalogs, and collections" - Collections tab: rename Enable/Disable to Show/Hide All - Folder detail header icon respects tileShape aspect ratio - Respect hideTitle in Modern layout hero and card labels Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/nuvio/tv/core/server/AddonWebPage.kt | 50 +++++++++++--- .../tv/ui/components/CollectionRowSection.kt | 69 +++++++++++++++++-- .../collection/CollectionManagementScreen.kt | 23 +------ .../CollectionManagementViewModel.kt | 8 +-- .../screens/collection/FolderDetailScreen.kt | 22 ++++-- .../tv/ui/screens/home/ClassicHomeContent.kt | 18 ++++- .../tv/ui/screens/home/ModernHomeHero.kt | 2 +- .../tv/ui/screens/home/ModernHomeModels.kt | 8 +-- .../tv/ui/screens/home/ModernHomeRows.kt | 2 +- app/src/main/res/values/strings.xml | 4 +- 10 files changed, 156 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt index 6f2e36c4..7b4edd80 100644 --- a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt +++ b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt @@ -192,9 +192,9 @@ object AddonWebPage { border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .addon-order { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.2rem; flex-shrink: 0; } .btn-order { @@ -317,6 +317,19 @@ object AddonWebPage { margin-left: 0.5rem; vertical-align: middle; } + .badge-collection { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(130, 170, 255, 0.95); + border: 1px solid rgba(130, 170, 255, 0.35); + padding: 0.12rem 0.45rem; + border-radius: 100px; + margin-left: 0.5rem; + vertical-align: middle; + } .status-overlay { position: fixed; top: 0; @@ -859,7 +872,7 @@ object AddonWebPage {
    - +
    @@ -894,8 +907,8 @@ object AddonWebPage {
    - - + +
    @@ -1219,15 +1232,22 @@ function renderCatalogs() { li.innerHTML = '
    ' + - '' + + '' + - '' + + '' + '
    ' + '
    ' + '
    ' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) + + (isCollection ? 'Collection' : '') + (catalog.isDisabled ? '${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}' : '') + '
    ' + '
    ' + escapeHtml(catalog.addonName) + '
    ' + @@ -1258,6 +1278,20 @@ function moveCatalog(index, direction) { renderCatalogs(); } +function moveCatalogToTop(index) { + if (index <= 0) return; + var item = catalogs.splice(index, 1)[0]; + catalogs.unshift(item); + renderCatalogs(); +} + +function moveCatalogToBottom(index) { + if (index >= catalogs.length - 1) return; + var item = catalogs.splice(index, 1)[0]; + catalogs.push(item); + renderCatalogs(); +} + function toggleCatalog(index) { var item = catalogs[index]; if (!item) return; diff --git a/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt index 08f3fd6c..c9eedb1d 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt @@ -1,3 +1,8 @@ +@file:OptIn( + androidx.compose.ui.ExperimentalComposeUiApi::class, + androidx.tv.material3.ExperimentalTvMaterial3Api::class +) + package com.nuvio.tv.ui.components import androidx.compose.foundation.BorderStroke @@ -17,12 +22,14 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -63,8 +70,42 @@ fun CollectionRowSection( val currentOnItemFocused by rememberUpdatedState(onItemFocused) val currentOnFolderFocused by rememberUpdatedState(onFolderFocused) val rowFocusRequester = remember { FocusRequester() } + val itemFocusRequesters = remember { mutableMapOf() } + var lastRequestedFocusKey by remember { mutableStateOf(null) } var lastFocusedItemIndex by remember { mutableIntStateOf(-1) } + fun folderFocusKey(index: Int, folder: CollectionFolder): String { + return "collection_${collection.id}_folder_${folder.id}" + } + + // Clean up stale focus requesters when folders change + LaunchedEffect(collection.folders) { + val validKeys = collection.folders.mapIndexedTo(mutableSetOf()) { index, folder -> + folderFocusKey(index, folder) + } + itemFocusRequesters.keys.retainAll(validKeys) + if (lastRequestedFocusKey !in validKeys) { + lastRequestedFocusKey = null + } + } + + // Request focus on the target item when focusedItemIndex is set + LaunchedEffect(focusedItemIndex, collection.folders) { + if (focusedItemIndex >= 0 && focusedItemIndex < collection.folders.size) { + val targetFolder = collection.folders[focusedItemIndex] + val targetKey = folderFocusKey(focusedItemIndex, targetFolder) + if (lastRequestedFocusKey == targetKey) return@LaunchedEffect + val requester = itemFocusRequesters.getOrPut(targetKey) { FocusRequester() } + repeat(2) { withFrameNanos { } } + val focused = runCatching { requester.requestFocus() }.isSuccess + if (focused) { + lastRequestedFocusKey = targetKey + } + } else { + lastRequestedFocusKey = null + } + } + Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -87,13 +128,28 @@ fun CollectionRowSection( modifier = Modifier .fillMaxWidth() .focusRequester(rowFocusRequester) - .focusRestorer(), + .then( + if (focusedItemIndex < 0 && collection.folders.isNotEmpty()) { + Modifier.focusRestorer { + val fallbackIndex = listState.firstVisibleItemIndex + .coerceIn(0, (collection.folders.size - 1).coerceAtLeast(0)) + val fallbackFolder = collection.folders.getOrNull(fallbackIndex) + if (fallbackFolder != null) { + itemFocusRequesters.getOrPut(folderFocusKey(fallbackIndex, fallbackFolder)) { FocusRequester() } + } else { + rowFocusRequester + } + } + } else { + Modifier.focusRestorer() + } + ), contentPadding = PaddingValues(start = 48.dp, end = 200.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { itemsIndexed( items = collection.folders, - key = { _, folder -> "collection_${collection.id}_folder_${folder.id}" }, + key = { index, folder -> folderFocusKey(index, folder) }, contentType = { _, _ -> "collection_folder" } ) { index, folder -> FolderCard( @@ -106,7 +162,10 @@ fun CollectionRowSection( currentOnItemFocused(index) } currentOnFolderFocused(collection, folder) - } + }, + focusRequester = itemFocusRequesters.getOrPut( + folderFocusKey(index, folder) + ) { FocusRequester() } ) } } @@ -120,7 +179,8 @@ private fun FolderCard( collection: Collection, onClick: () -> Unit, onFocused: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } ) { val tileWidth: Dp val tileHeight: Dp @@ -143,6 +203,7 @@ private fun FolderCard( modifier = modifier .width(tileWidth) .height(tileHeight) + .focusRequester(focusRequester) .onFocusChanged { isFocused = it.isFocused if (it.isFocused) onFocused() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt index 1aecad15..d19c49b4 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt @@ -102,7 +102,7 @@ fun CollectionManagementScreen( onBack: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current val scope = rememberCoroutineScope() @@ -127,10 +127,7 @@ fun CollectionManagementScreen( importError = uiState.importError, onTextChange = { viewModel.updateImportText(it) }, onImport = { viewModel.importCollections() }, - onPaste = { - val clip = clipboardManager.getText()?.text ?: "" - viewModel.updateImportText(clip) - }, + onPaste = {}, onBack = { viewModel.hideImportDialog() }, importMode = uiState.importMode, onModeChange = { viewModel.setImportMode(it) }, @@ -171,13 +168,6 @@ fun CollectionManagementScreen( repeat(3) { withFrameNanos { } } try { newButtonFocusRequester.requestFocus() } catch (_: Exception) {} } - var showCopied by remember { mutableStateOf(false) } - LaunchedEffect(showCopied) { - if (showCopied) { - kotlinx.coroutines.delay(2000) - showCopied = false - } - } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (uiState.collections.isNotEmpty()) { NuvioButton(onClick = { @@ -209,13 +199,6 @@ fun CollectionManagementScreen( }) { Text(exportMessage ?: "Export File") } - NuvioButton(onClick = { - val json = viewModel.exportCollections() - clipboardManager.setText(AnnotatedString(json)) - showCopied = true - }) { - Text(if (showCopied) "Copied!" else "Copy JSON") - } } NuvioButton(onClick = { viewModel.showImportDialog() }) { Text("Import") @@ -326,7 +309,7 @@ private fun ImportContent( // Mode tabs Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ImportMode.entries.forEach { mode -> + ImportMode.entries.filter { it != ImportMode.PASTE }.forEach { mode -> val label = when (mode) { ImportMode.PASTE -> "Paste JSON" ImportMode.FILE -> "From File" diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt index b4bea9a1..11cc99c3 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt @@ -27,7 +27,7 @@ data class CollectionManagementUiState( val importText: String = "", val importError: String? = null, val exportedJson: String? = null, - val importMode: ImportMode = ImportMode.PASTE, + val importMode: ImportMode = ImportMode.FILE, val importUrl: String = "", val validationResult: ValidationResult? = null, val validatedJson: String? = null, @@ -95,7 +95,7 @@ class CollectionManagementViewModel @Inject constructor( _uiState.update { it.copy( showImportDialog = true, importText = "", importError = null, - importMode = ImportMode.PASTE, importUrl = "", + importMode = ImportMode.FILE, importUrl = "", validationResult = null, validatedJson = null, isLoadingImport = false ) } @@ -105,7 +105,7 @@ class CollectionManagementViewModel @Inject constructor( _uiState.update { it.copy( showImportDialog = false, importText = "", importError = null, - importMode = ImportMode.PASTE, importUrl = "", + importMode = ImportMode.FILE, importUrl = "", validationResult = null, validatedJson = null, isLoadingImport = false ) } @@ -232,7 +232,7 @@ class CollectionManagementViewModel @Inject constructor( it.copy( showImportDialog = false, importText = "", importError = null, validationResult = null, validatedJson = null, importUrl = "", - importMode = ImportMode.PASTE + importMode = ImportMode.FILE ) } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt index aae085e6..86f71812 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt @@ -144,12 +144,19 @@ private fun FolderHeader(folder: com.nuvio.tv.domain.model.CollectionFolder) { horizontalArrangement = Arrangement.spacedBy(16.dp) ) { if (!folder.coverImageUrl.isNullOrBlank()) { + val iconWidth: androidx.compose.ui.unit.Dp + val iconHeight: androidx.compose.ui.unit.Dp + when (folder.tileShape) { + com.nuvio.tv.domain.model.PosterShape.POSTER -> { iconWidth = 32.dp; iconHeight = 48.dp } + com.nuvio.tv.domain.model.PosterShape.LANDSCAPE -> { iconWidth = 64.dp; iconHeight = 36.dp } + com.nuvio.tv.domain.model.PosterShape.SQUARE -> { iconWidth = 48.dp; iconHeight = 48.dp } + } AsyncImage( model = folder.coverImageUrl, contentDescription = folder.title, modifier = Modifier - .width(48.dp) - .height(48.dp) + .width(iconWidth) + .height(iconHeight) .clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.FillBounds ) @@ -189,12 +196,19 @@ private fun TabbedGridContent( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { if (!folder.coverImageUrl.isNullOrBlank()) { + val iconWidth: androidx.compose.ui.unit.Dp + val iconHeight: androidx.compose.ui.unit.Dp + when (folder.tileShape) { + com.nuvio.tv.domain.model.PosterShape.POSTER -> { iconWidth = 32.dp; iconHeight = 48.dp } + com.nuvio.tv.domain.model.PosterShape.LANDSCAPE -> { iconWidth = 64.dp; iconHeight = 36.dp } + com.nuvio.tv.domain.model.PosterShape.SQUARE -> { iconWidth = 48.dp; iconHeight = 48.dp } + } AsyncImage( model = folder.coverImageUrl, contentDescription = folder.title, modifier = Modifier - .width(48.dp) - .height(48.dp) + .width(iconWidth) + .height(iconHeight) .clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.FillBounds ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt index 2f7a97d5..26aed737 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt @@ -46,7 +46,8 @@ private const val KEY_REPEAT_THROTTLE_MS = 80L private class FocusSnapshot( var rowIndex: Int, - var itemIndex: Int + var itemIndex: Int, + var rowKey: String? = null ) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @@ -298,7 +299,9 @@ fun ClassicHomeContent( is HomeRow.Catalog -> { val catalogRow = homeRow.row val catalogKey = "${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}" - val shouldRestoreFocus = restoringFocus && index == focusState.focusedRowIndex + // Match by saved row key first, fall back to index + val shouldRestoreFocus = restoringFocus && + (currentFocusSnapshot.rowKey == catalogKey || index == focusState.focusedRowIndex) val shouldInitialFocusFirstCatalogRow = shouldRequestInitialFocus && !heroVisible && @@ -351,12 +354,21 @@ fun ClassicHomeContent( if (restoringFocus) restoringFocus = false currentFocusSnapshot.rowIndex = index currentFocusSnapshot.itemIndex = itemIndex + currentFocusSnapshot.rowKey = catalogKey } ) } is HomeRow.CollectionRow -> { val collectionKey = "collection_${homeRow.collection.id}" + // Match by saved row key first, fall back to index + val shouldRestoreCollectionFocus = restoringFocus && + (currentFocusSnapshot.rowKey == collectionKey || index == focusState.focusedRowIndex) + val collectionFocusedItemIndex = if (shouldRestoreCollectionFocus) { + focusState.focusedItemIndex + } else { + -1 + } val listState = rowStates.getOrPut(collectionKey) { LazyListState( firstVisibleItemIndex = focusState.catalogRowScrollStates[collectionKey] ?: 0 @@ -367,10 +379,12 @@ fun ClassicHomeContent( collection = homeRow.collection, onFolderClick = onNavigateToFolderDetail, listState = listState, + focusedItemIndex = collectionFocusedItemIndex, onItemFocused = { itemIndex -> if (restoringFocus) restoringFocus = false currentFocusSnapshot.rowIndex = index currentFocusSnapshot.itemIndex = itemIndex + currentFocusSnapshot.rowKey = collectionKey } ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt index bbe9595f..2711c789 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt @@ -341,7 +341,7 @@ private fun HeroTitleContent( contentScale = ContentScale.Fit, alignment = Alignment.CenterStart ) - } else { + } else if (preview.title.isNotBlank()) { Text( text = preview.title, style = scaledTitleStyle, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt index 9f259095..da242689 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt @@ -474,14 +474,14 @@ internal fun buildCollectionFolderItem( return ModernCarouselItem( key = "collection_${collection.id}_${folder.id}_$occurrence", - title = folder.title, - subtitle = collection.title, + title = if (folder.hideTitle) "" else folder.title, + subtitle = if (folder.hideTitle) null else collection.title, imageUrl = heroImageUrl, heroPreview = HeroPreview( - title = title, + title = if (folder.hideTitle) "" else title, logo = null, description = null, - contentTypeText = collection.title, + contentTypeText = if (folder.hideTitle) null else collection.title, yearText = null, imdbText = null, genres = emptyList(), diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt index e4ad0e83..2799725a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt @@ -1013,7 +1013,7 @@ private fun ModernCarouselCard( } } - if (showLabels && !isBackdropExpanded) { + if (showLabels && !isBackdropExpanded && item.title.isNotBlank()) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 907886b0..44dc7324 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,13 +6,13 @@ Manage Addons - Manage addons and home catalogs + Manage addons, catalogs, and collections Add addon by URL https://example.com/manifest.json Add Installed Addons No addons installed - Home Catalogs + Home Catalogs and Collections No home catalogs available Save Changes Connection to TV lost From 76a28c8fae376317c6179e7bb541a309fe0556f6 Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Wed, 8 Apr 2026 09:33:31 -0700 Subject: [PATCH 4/6] Fix stale trailer playing in hero when focusing collection folders Clear focusedCatalogSelection when a collection folder gets focus, not just continue watching rows. Previously the old catalog item's trailer request stayed active and played in the hero area after navigating to a collection folder. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index 12ae2217..2cfaa9c1 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -1002,6 +1002,11 @@ fun ModernHomeContent( if (lastFocusedContinueWatchingIndexRef.get() != index) { lastFocusedContinueWatchingIndexRef.set(index) } + } + // Clear catalog selection when focusing any + // non-catalog row (CW, collection) so stale + // trailer requests don't fire in the hero. + if (isContinueWatchingRow || row.items.getOrNull(index)?.payload is ModernPayload.CollectionFolder) { if (focusedCatalogSelection != null) { focusedCatalogSelection = null } From 247aa8e51850e882a6aaf6327481c343c504c5bf Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Wed, 8 Apr 2026 10:06:18 -0700 Subject: [PATCH 5/6] Add watched badges to collection folder detail views - Inject WatchProgressRepository and WatchedSeriesStateHolder into FolderDetailViewModel to observe movie and series watched status - Add movieWatchedStatus to FolderDetailUiState - Pass isItemWatched callback to all three view modes: Tabbed Grid (ContentCard), Rows (CatalogRowSection), and Follow Layout (Classic/Grid/Modern home content renderers) - Rebuild Follow Layout state when watched status changes so badges update reactively Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screens/collection/FolderDetailScreen.kt | 21 +++++- .../collection/FolderDetailViewModel.kt | 64 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt index 86f71812..aaceb25f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt @@ -59,6 +59,7 @@ import com.nuvio.tv.ui.screens.home.ClassicHomeContent import com.nuvio.tv.ui.screens.home.ContinueWatchingItem import com.nuvio.tv.ui.screens.home.GridHomeContent import com.nuvio.tv.ui.screens.home.HomeScreenFocusState +import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.ui.screens.home.ModernHomeContent import com.nuvio.tv.ui.theme.NuvioColors @@ -89,6 +90,10 @@ fun FolderDetailScreen( return } + val isItemWatched: (MetaPreview) -> Boolean = remember(uiState.movieWatchedStatus) { + { item -> uiState.movieWatchedStatus[com.nuvio.tv.ui.screens.home.homeItemStatusKey(item.id, item.apiType)] == true } + } + if (uiState.viewMode == FolderViewMode.FOLLOW_LAYOUT) { FollowLayoutContent( uiState = uiState, @@ -110,6 +115,7 @@ fun FolderDetailScreen( tabFocusState = tabFocusStates[uiState.selectedTabIndex] ?: FolderDetailGridFocusState(), onSelectTab = viewModel::selectTab, onNavigateToDetail = onNavigateToDetail, + isItemWatched = isItemWatched, onSaveFocusState = { verticalIndex, verticalOffset, focusedItemKey -> viewModel.saveTabFocusState( tabIndex = uiState.selectedTabIndex, @@ -125,6 +131,7 @@ fun FolderDetailScreen( uiState = uiState, focusState = rowsFocusState, onNavigateToDetail = onNavigateToDetail, + isItemWatched = isItemWatched, onSaveFocusState = viewModel::saveRowsFocusState ) } @@ -184,7 +191,8 @@ private fun TabbedGridContent( tabFocusState: FolderDetailGridFocusState, onSelectTab: (Int) -> Unit, onNavigateToDetail: (String, String, String) -> Unit, - onSaveFocusState: (Int, Int, String?) -> Unit + onSaveFocusState: (Int, Int, String?) -> Unit, + isItemWatched: (MetaPreview) -> Boolean = { false } ) { val tabFocusRequesters = remember(uiState.tabs.size) { uiState.tabs.indices.map { FocusRequester() } } @@ -358,6 +366,7 @@ private fun TabbedGridContent( item = item, posterCardStyle = posterCardStyle, focusRequester = focusReq, + isWatched = isItemWatched(item), onFocus = { _ -> lastFocusedItemKey = itemKey }, onClick = { onNavigateToDetail( @@ -379,7 +388,8 @@ private fun RowsContent( uiState: FolderDetailUiState, focusState: HomeScreenFocusState, onNavigateToDetail: (String, String, String) -> Unit, - onSaveFocusState: (Int, Int, Int, Int, Map) -> Unit + onSaveFocusState: (Int, Int, Int, Int, Map) -> Unit, + isItemWatched: (MetaPreview) -> Boolean = { false } ) { val sourceTabs = uiState.tabs.filter { !it.isAllTab } val columnListState = rememberLazyListState( @@ -474,6 +484,7 @@ private fun RowsContent( showPosterLabels = true, showAddonName = false, showCatalogTypeSuffix = false, + isItemWatched = isItemWatched, listState = listState, focusedItemIndex = if ( focusState.hasSavedFocus && @@ -523,6 +534,9 @@ private fun FollowLayoutContent( val noOpRemoveCw: (String, Int?, Int?, Boolean) -> Unit = remember { { _, _, _, _ -> } } val noOpSeeAll: (String, String, String) -> Unit = remember { { _, _, _ -> } } val noOpFolderDetail: (String, String) -> Unit = remember { { _, _ -> } } + val isItemWatched: (MetaPreview) -> Boolean = remember(homeState.movieWatchedStatus) { + { item -> homeState.movieWatchedStatus[com.nuvio.tv.ui.screens.home.homeItemStatusKey(item.id, item.apiType)] == true } + } when (uiState.homeLayout) { HomeLayout.CLASSIC -> ClassicHomeContent( @@ -536,6 +550,7 @@ private fun FollowLayoutContent( onNavigateToCatalogSeeAll = noOpSeeAll, onNavigateToFolderDetail = noOpFolderDetail, onRemoveContinueWatching = noOpRemoveCw, + isCatalogItemWatched = isItemWatched, onRequestTrailerPreview = { }, onSaveFocusState = onSaveFocusState ) @@ -547,6 +562,7 @@ private fun FollowLayoutContent( onNavigateToCatalogSeeAll = noOpSeeAll, onNavigateToFolderDetail = noOpFolderDetail, onRemoveContinueWatching = noOpRemoveCw, + isCatalogItemWatched = isItemWatched, posterCardStyle = posterCardStyle, onSaveGridFocusState = onSaveGridFocusState ) @@ -560,6 +576,7 @@ private fun FollowLayoutContent( onRequestTrailerPreview = { _, _, _, _ -> }, onLoadMoreCatalog = noOpSeeAll, onRemoveContinueWatching = noOpRemoveCw, + isCatalogItemWatched = isItemWatched, onNavigateToFolderDetail = noOpFolderDetail, onSaveFocusState = onSaveFocusState ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt index 5b4ce73c..4bac975c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt @@ -13,14 +13,18 @@ import com.nuvio.tv.domain.model.FolderViewMode import com.nuvio.tv.domain.model.HomeLayout import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.repository.AddonRepository +import com.nuvio.tv.domain.repository.WatchProgressRepository import com.nuvio.tv.ui.screens.home.GridItem import com.nuvio.tv.ui.screens.home.HomeRow import com.nuvio.tv.ui.screens.home.HomeUiState +import com.nuvio.tv.ui.screens.home.homeItemStatusKey import com.nuvio.tv.domain.repository.CatalogRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -36,7 +40,8 @@ data class FolderDetailUiState( val tabs: List = emptyList(), val selectedTabIndex: Int = 0, val isLoading: Boolean = true, - val followLayoutHomeState: HomeUiState? = null + val followLayoutHomeState: HomeUiState? = null, + val movieWatchedStatus: Map = emptyMap() ) data class FolderTab( @@ -61,7 +66,9 @@ class FolderDetailViewModel @Inject constructor( private val collectionsDataStore: CollectionsDataStore, private val addonRepository: AddonRepository, private val catalogRepository: CatalogRepository, - private val layoutPreferenceDataStore: LayoutPreferenceDataStore + private val layoutPreferenceDataStore: LayoutPreferenceDataStore, + private val watchProgressRepository: WatchProgressRepository, + private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder ) : ViewModel() { private val collectionId: String = savedStateHandle["collectionId"] ?: "" @@ -70,6 +77,9 @@ class FolderDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(FolderDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var movieWatchedJob: Job? = null + private var seriesWatchedJob: Job? = null + private val _rowsFocusState = MutableStateFlow(com.nuvio.tv.ui.screens.home.HomeScreenFocusState()) val rowsFocusState: StateFlow = _rowsFocusState.asStateFlow() @@ -219,7 +229,8 @@ class FolderDetailViewModel @Inject constructor( modernHeroFullScreenBackdropEnabled = s.modernHeroFullScreenBackdropEnabled, catalogAddonNameEnabled = false, catalogTypeSuffixEnabled = true, - posterLabelsEnabled = true + posterLabelsEnabled = true, + movieWatchedStatus = s.movieWatchedStatus )) } } @@ -281,6 +292,7 @@ class FolderDetailViewModel @Inject constructor( } rebuildAllTab() rebuildFollowLayoutState() + observeWatchedStatus() } is NetworkResult.Error -> { _uiState.update { state -> @@ -376,6 +388,52 @@ class FolderDetailViewModel @Inject constructor( } } + private fun observeWatchedStatus() { + val allItems = _uiState.value.tabs + .mapNotNull { it.catalogRow } + .flatMap { it.items } + + val movieIds = mutableMapOf() + val seriesIds = mutableMapOf() + allItems.forEach { item -> + val key = homeItemStatusKey(item.id, item.apiType) + if (item.apiType.equals("movie", ignoreCase = true)) { + movieIds[key] = item.id + } else if (item.apiType.equals("series", ignoreCase = true) || item.apiType.equals("tv", ignoreCase = true)) { + seriesIds[key] = item.id + } + } + + movieWatchedJob?.cancel() + if (movieIds.isNotEmpty()) { + movieWatchedJob = viewModelScope.launch { + watchProgressRepository.observeWatchedMovieIds() + .collectLatest { watchedIds -> + val status = movieIds.mapValues { (_, contentId) -> contentId in watchedIds } + _uiState.update { s -> + val merged = s.movieWatchedStatus.filterKeys { it !in movieIds } + status + if (s.movieWatchedStatus == merged) s else s.copy(movieWatchedStatus = merged) + } + rebuildFollowLayoutState() + } + } + } + + seriesWatchedJob?.cancel() + if (seriesIds.isNotEmpty()) { + seriesWatchedJob = viewModelScope.launch { + watchedSeriesStateHolder.fullyWatchedSeriesIds.collectLatest { fullyWatched -> + val status = seriesIds.mapValues { (_, contentId) -> contentId in fullyWatched } + _uiState.update { s -> + val merged = s.movieWatchedStatus.filterKeys { it !in seriesIds } + status + if (s.movieWatchedStatus == merged) s else s.copy(movieWatchedStatus = merged) + } + rebuildFollowLayoutState() + } + } + } + } + private fun buildTabLabels(source: CollectionCatalogSource, catalogName: String?): Pair { val typeLabel = when (source.type.lowercase()) { "movie" -> "Movies" From 80d38244e6b849a19d99a3b2d250ad704f7ed284 Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Wed, 8 Apr 2026 10:38:20 -0700 Subject: [PATCH 6/6] Fix Modern layout focus race when catalogs load after collections Show spinner when collections are ready but catalogs haven't arrived yet. Prevents collections from grabbing focus before catalogs appear above them. Also tracks initial auto-selected row to properly detect when new rows appear before the focused row during loading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tv/ui/screens/home/ModernHomeContent.kt | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index 2cfaa9c1..8a35ea3b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -102,6 +102,7 @@ import com.nuvio.tv.domain.model.FocusedPosterTrailerPlaybackTarget import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.ui.components.ContinueWatchingCard import com.nuvio.tv.ui.components.HeroCarousel +import com.nuvio.tv.ui.components.LoadingIndicator import com.nuvio.tv.ui.components.ContinueWatchingOptionsDialog import com.nuvio.tv.ui.components.MonochromePosterPlaceholder import com.nuvio.tv.ui.components.TrailerPlayer @@ -344,6 +345,19 @@ fun ModernHomeContent( } } + // Show spinner when collections are ready but catalogs haven't arrived + // yet — prevents collections from grabbing focus before catalogs + // appear above them. Only waits when addons are installed (meaning + // catalogs are expected to load). + val hasCollections = visibleHomeRows.any { it is HomeRow.CollectionRow } + val hasCatalogs = uiState.catalogRows.isNotEmpty() + if (hasCollections && !hasCatalogs && uiState.installedAddonsCount > 0) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + return + } + if (carouselRows.isEmpty()) { // No carousel rows but hero items may exist — show standalone hero if (uiState.heroSectionEnabled && uiState.heroItems.isNotEmpty()) { @@ -441,6 +455,9 @@ fun ModernHomeContent( } var activeRowKey by remember { mutableStateOf(null) } var activeItemIndex by remember { mutableIntStateOf(0) } + // Tracks the row key that was auto-selected on initial load. + // Used to detect if the user has manually navigated away. + var initialAutoSelectedKey by remember { mutableStateOf(null) } var pendingRowFocusKey by remember { mutableStateOf(null) } var pendingRowFocusIndex by remember { mutableStateOf(null) } var pendingRowFocusNonce by remember { mutableIntStateOf(0) } @@ -563,11 +580,17 @@ fun ModernHomeContent( // When new rows appear before the auto-selected row (e.g., catalogs // load after collections), move focus to the new first row — but only // if the user hasn't manually navigated away from the initial position. + // Detect if the auto-selected row is stale: new rows appeared + // above it (e.g., catalogs loaded after collections) and the user + // hasn't manually navigated away from the initial selection. + val userStillOnAutoSelected = initialAutoSelectedKey != null && + focusHolder.activeRowKey == initialAutoSelectedKey val autoSelectedStale = hadActiveRow && existingActive != null && existingActive.key != firstRow.key && rowIndexByKey.getOrDefault(existingActive.key, 0) > 0 && !restoredFromSavedState && - pendingRowFocusKey == existingActive.key + !focusState.hasSavedFocus && + userStillOnAutoSelected val resolvedActive = when { autoSelectedStale -> firstRow existingActive != null -> existingActive @@ -584,6 +607,7 @@ fun ModernHomeContent( heroItem = resolvedActive.items.getOrNull(resolvedIndex)?.heroPreview ?: resolvedActive.items.firstOrNull()?.heroPreview if (!focusState.hasSavedFocus && (!hadActiveRow || existingActive == null || autoSelectedStale)) { + initialAutoSelectedKey = resolvedActive.key pendingRowFocusKey = resolvedActive.key pendingRowFocusIndex = resolvedIndex pendingRowFocusNonce++