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:
commit
27a7843eed
16 changed files with 543 additions and 76 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -1013,7 +1013,7 @@ private fun ModernCarouselCard(
|
|||
}
|
||||
}
|
||||
|
||||
if (showLabels && !isBackdropExpanded) {
|
||||
if (showLabels && !isBackdropExpanded && item.title.isNotBlank()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue