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 {
${context.getString(R.string.web_home_catalogs)}
+
+
+
+
${context.getString(R.string.web_no_catalogs)}
@@ -889,6 +906,10 @@ object AddonWebPage {
Collections
+
+
+
+
@@ -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