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 0fbb3d41..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 {
- +
@@ -881,6 +894,10 @@ object AddonWebPage {
+
+ + +
${context.getString(R.string.web_no_catalogs)}
@@ -889,6 +906,10 @@ object AddonWebPage {
+
+ + +
@@ -1211,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) + '
' + @@ -1250,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; @@ -1264,6 +1306,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/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..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 ) } @@ -144,12 +151,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 ) @@ -177,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() } } @@ -189,12 +204,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 ) @@ -344,6 +366,7 @@ private fun TabbedGridContent( item = item, posterCardStyle = posterCardStyle, focusRequester = focusReq, + isWatched = isItemWatched(item), onFocus = { _ -> lastFocusedItemKey = itemKey }, onClick = { onNavigateToDetail( @@ -365,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( @@ -460,6 +484,7 @@ private fun RowsContent( showPosterLabels = true, showAddonName = false, showCatalogTypeSuffix = false, + isItemWatched = isItemWatched, listState = listState, focusedItemIndex = if ( focusState.hasSavedFocus && @@ -509,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( @@ -522,6 +550,7 @@ private fun FollowLayoutContent( onNavigateToCatalogSeeAll = noOpSeeAll, onNavigateToFolderDetail = noOpFolderDetail, onRemoveContinueWatching = noOpRemoveCw, + isCatalogItemWatched = isItemWatched, onRequestTrailerPreview = { }, onSaveFocusState = onSaveFocusState ) @@ -533,6 +562,7 @@ private fun FollowLayoutContent( onNavigateToCatalogSeeAll = noOpSeeAll, onNavigateToFolderDetail = noOpFolderDetail, onRemoveContinueWatching = noOpRemoveCw, + isCatalogItemWatched = isItemWatched, posterCardStyle = posterCardStyle, onSaveGridFocusState = onSaveGridFocusState ) @@ -546,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" 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..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 @@ -18,6 +18,8 @@ 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 import androidx.compose.ui.focus.focusRequester @@ -28,10 +30,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. */ @@ -39,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) @@ -145,12 +153,24 @@ fun ClassicHomeContent( val heroVisible = uiState.heroSectionEnabled && uiState.heroItems.isNotEmpty() - LaunchedEffect(shouldRequestInitialFocus, heroVisible, uiState.heroItems.size) { + val heroExpected = uiState.heroSectionEnabled + val heroResolved = !heroExpected || heroVisible + 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 - 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 +178,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 @@ -267,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 && @@ -320,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 @@ -336,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/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/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index 02480f5a..7ee44efc 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 @@ -500,6 +500,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, @@ -507,6 +512,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/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt index a687d4dc..37d7a0a2 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 @@ -151,7 +151,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 @@ -170,8 +179,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) { @@ -180,6 +220,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, @@ -326,11 +408,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() } @@ -370,8 +466,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 { @@ -503,13 +614,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..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 @@ -168,13 +168,24 @@ 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 || 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, @@ -198,7 +209,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..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 @@ -101,6 +101,8 @@ 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.LoadingIndicator import com.nuvio.tv.ui.components.ContinueWatchingOptionsDialog import com.nuvio.tv.ui.components.MonochromePosterPlaceholder import com.nuvio.tv.ui.components.TrailerPlayer @@ -343,7 +345,34 @@ fun ModernHomeContent( } } - if (carouselRows.isEmpty()) return + // 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()) { + 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) @@ -426,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) } @@ -544,7 +576,26 @@ 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. + // 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 && + !focusState.hasSavedFocus && + userStillOnAutoSelected + 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 +606,8 @@ 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)) { + initialAutoSelectedKey = resolvedActive.key pendingRowFocusKey = resolvedActive.key pendingRowFocusIndex = resolvedIndex pendingRowFocusNonce++ @@ -974,6 +1026,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 } 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