Merge pull request #1289 from YaarPatandarAA/fix/collection-bugs

[Fix] Fix collection bugs: loading, focus, hero independence, watched badges, and WebUI
This commit is contained in:
skoruppa 2026-04-08 22:58:54 +02:00 committed by GitHub
commit 27a7843eed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 543 additions and 76 deletions

View file

@ -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 {
<div class="tabs">
<button class="tab active" type="button" onclick="switchTab('addons')">Addons</button>
<button class="tab" type="button" onclick="switchTab('catalogs')">Catalogs</button>
<button class="tab" type="button" onclick="switchTab('catalogs')">Home Layout</button>
<button class="tab" type="button" onclick="switchTab('collections')">Collections</button>
</div>
@ -881,6 +894,10 @@ object AddonWebPage {
<div class="tab-content" id="tab-catalogs">
<div class="section-block">
<div class="section-label">${context.getString(R.string.web_home_catalogs)}</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="enableAllCatalogs()" style="flex:1">Enable All</button>
<button class="btn" onclick="disableAllCatalogs()" style="flex:1">Disable All</button>
</div>
<ul class="addon-list" id="catalogList"></ul>
<div class="empty-state" id="catalogEmptyState">${context.getString(R.string.web_no_catalogs)}</div>
</div>
@ -889,6 +906,10 @@ object AddonWebPage {
<div class="tab-content" id="tab-collections">
<div class="section-block">
<div class="section-label">Collections</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="enableAllCollections()" style="flex:1">Show All</button>
<button class="btn" onclick="disableAllCollections()" style="flex:1">Hide All</button>
</div>
<div class="add-section" style="display:flex;gap:0.5rem">
<button class="btn" onclick="addCollection()" style="flex:1">+ New Collection</button>
<button class="btn" onclick="exportCollections()" style="flex:1">Export</button>
@ -1211,15 +1232,22 @@ function renderCatalogs() {
li.innerHTML =
'<div class="addon-order">' +
'<button class="btn-order" onclick="moveCatalog(' + i + ',-1)"' + (isFirst ? ' disabled' : '') + '>' +
'<button class="btn-order" onclick="moveCatalogToTop(' + i + ')"' + (isFirst ? ' disabled' : '') + ' title="Send to top">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11l-6-6-6 6"/><path d="M18 18l-6-6-6 6"/></svg>' +
'</button>' +
'<button class="btn-order" onclick="moveCatalog(' + i + ',-1)"' + (isFirst ? ' disabled' : '') + ' title="Move up">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>' +
'</button>' +
'<button class="btn-order" onclick="moveCatalog(' + i + ',1)"' + (isLast ? ' disabled' : '') + '>' +
'<button class="btn-order" onclick="moveCatalog(' + i + ',1)"' + (isLast ? ' disabled' : '') + ' title="Move down">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
'</button>' +
'<button class="btn-order" onclick="moveCatalogToBottom(' + i + ')"' + (isLast ? ' disabled' : '') + ' title="Send to bottom">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l6 6 6-6"/><path d="M6 13l6 6 6-6"/></svg>' +
'</button>' +
'</div>' +
'<div class="catalog-info">' +
'<div class="catalog-name">' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) +
(isCollection ? '<span class="badge-collection">Collection</span>' : '') +
(catalog.isDisabled ? '<span class="badge-disabled">${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}</span>' : '') +
'</div>' +
'<div class="catalog-meta">' + escapeHtml(catalog.addonName) + '</div>' +
@ -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');

View file

@ -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<String, FocusRequester>() }
var lastRequestedFocusKey by remember { mutableStateOf<String?>(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()

View file

@ -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"

View file

@ -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
)
}
}

View file

@ -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<String, Int>) -> Unit
onSaveFocusState: (Int, Int, Int, Int, Map<String, Int>) -> 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
)

View file

@ -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<FolderTab> = emptyList(),
val selectedTabIndex: Int = 0,
val isLoading: Boolean = true,
val followLayoutHomeState: HomeUiState? = null
val followLayoutHomeState: HomeUiState? = null,
val movieWatchedStatus: Map<String, Boolean> = 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<FolderDetailUiState> = _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<com.nuvio.tv.ui.screens.home.HomeScreenFocusState> = _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<String, String>()
val seriesIds = mutableMapOf<String, String>()
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<String, String> {
val typeLabel = when (source.type.lowercase()) {
"movie" -> "Movies"

View file

@ -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
}
)
}

View file

@ -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

View file

@ -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<String, Int>
) {
if (suppressFocusSave) {
suppressFocusSave = false
return
}
val nextState = HomeScreenFocusState(
verticalScrollIndex = verticalScrollIndex,
verticalScrollOffset = verticalScrollOffset,

View file

@ -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
)
}

View file

@ -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()
}
}
}
}

View file

@ -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<String, Int>(carouselRows.size)
val rowByKey = LinkedHashMap<String, HeroCarouselRow>(carouselRows.size)
@ -426,6 +455,9 @@ fun ModernHomeContent(
}
var activeRowKey by remember { mutableStateOf<String?>(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<String?>(null) }
var pendingRowFocusKey by remember { mutableStateOf<String?>(null) }
var pendingRowFocusIndex by remember { mutableStateOf<Int?>(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
}

View file

@ -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,

View file

@ -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(),

View file

@ -1013,7 +1013,7 @@ private fun ModernCarouselCard(
}
}
if (showLabels && !isBackdropExpanded) {
if (showLabels && !isBackdropExpanded && item.title.isNotBlank()) {
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -6,13 +6,13 @@
<!-- WebConfigServers -->
<string name="web_manage_addons_title">Manage Addons</string>
<string name="web_manage_addons_subtitle">Manage addons and home catalogs</string>
<string name="web_manage_addons_subtitle">Manage addons, catalogs, and collections</string>
<string name="web_add_addon_url">Add addon by URL</string>
<string name="web_placeholder_url">https://example.com/manifest.json</string>
<string name="web_btn_add">Add</string>
<string name="web_installed_addons">Installed Addons</string>
<string name="web_no_addons">No addons installed</string>
<string name="web_home_catalogs">Home Catalogs</string>
<string name="web_home_catalogs">Home Catalogs and Collections</string>
<string name="web_no_catalogs">No home catalogs available</string>
<string name="web_btn_save">Save Changes</string>
<string name="web_connection_lost">Connection to TV lost</string>