Collection UI fixes: focus tracking, WebUI improvements, hideTitle in Modern

- Fix collection row focus restoration using key-based matching instead
  of positional index (survives row shifts during catalog loading)
- Add per-item FocusRequesters to CollectionRowSection for proper focus
  restore when navigating back from folder detail
- Remove Copy JSON and Paste import from Android TV (keep in WebUI)
- Default import mode to File instead of Paste
- WebUI: add Collection badge pill in Home Layout tab, send to top/bottom
  buttons, tab renamed to Home Layout, 2x2 grid for order buttons
- Update subtitle to "Manage addons, catalogs, and collections"
- Collections tab: rename Enable/Disable to Show/Hide All
- Folder detail header icon respects tileShape aspect ratio
- Respect hideTitle in Modern layout hero and card labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amarjit Singh 2026-04-08 09:19:11 -07:00
parent 47aba50061
commit 09c5480593
10 changed files with 156 additions and 50 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>
@ -894,8 +907,8 @@ object AddonWebPage {
<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">Enable All</button>
<button class="btn" onclick="disableAllCollections()" style="flex:1">Disable All</button>
<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>
@ -1219,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>' +
@ -1258,6 +1278,20 @@ function moveCatalog(index, direction) {
renderCatalogs();
}
function moveCatalogToTop(index) {
if (index <= 0) return;
var item = catalogs.splice(index, 1)[0];
catalogs.unshift(item);
renderCatalogs();
}
function moveCatalogToBottom(index) {
if (index >= catalogs.length - 1) return;
var item = catalogs.splice(index, 1)[0];
catalogs.push(item);
renderCatalogs();
}
function toggleCatalog(index) {
var item = catalogs[index];
if (!item) return;

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

@ -144,12 +144,19 @@ private fun FolderHeader(folder: com.nuvio.tv.domain.model.CollectionFolder) {
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (!folder.coverImageUrl.isNullOrBlank()) {
val iconWidth: androidx.compose.ui.unit.Dp
val iconHeight: androidx.compose.ui.unit.Dp
when (folder.tileShape) {
com.nuvio.tv.domain.model.PosterShape.POSTER -> { iconWidth = 32.dp; iconHeight = 48.dp }
com.nuvio.tv.domain.model.PosterShape.LANDSCAPE -> { iconWidth = 64.dp; iconHeight = 36.dp }
com.nuvio.tv.domain.model.PosterShape.SQUARE -> { iconWidth = 48.dp; iconHeight = 48.dp }
}
AsyncImage(
model = folder.coverImageUrl,
contentDescription = folder.title,
modifier = Modifier
.width(48.dp)
.height(48.dp)
.width(iconWidth)
.height(iconHeight)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.FillBounds
)
@ -189,12 +196,19 @@ private fun TabbedGridContent(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!folder.coverImageUrl.isNullOrBlank()) {
val iconWidth: androidx.compose.ui.unit.Dp
val iconHeight: androidx.compose.ui.unit.Dp
when (folder.tileShape) {
com.nuvio.tv.domain.model.PosterShape.POSTER -> { iconWidth = 32.dp; iconHeight = 48.dp }
com.nuvio.tv.domain.model.PosterShape.LANDSCAPE -> { iconWidth = 64.dp; iconHeight = 36.dp }
com.nuvio.tv.domain.model.PosterShape.SQUARE -> { iconWidth = 48.dp; iconHeight = 48.dp }
}
AsyncImage(
model = folder.coverImageUrl,
contentDescription = folder.title,
modifier = Modifier
.width(48.dp)
.height(48.dp)
.width(iconWidth)
.height(iconHeight)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.FillBounds
)

View file

@ -46,7 +46,8 @@ private const val KEY_REPEAT_THROTTLE_MS = 80L
private class FocusSnapshot(
var rowIndex: Int,
var itemIndex: Int
var itemIndex: Int,
var rowKey: String? = null
)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@ -298,7 +299,9 @@ fun ClassicHomeContent(
is HomeRow.Catalog -> {
val catalogRow = homeRow.row
val catalogKey = "${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}"
val shouldRestoreFocus = restoringFocus && index == focusState.focusedRowIndex
// Match by saved row key first, fall back to index
val shouldRestoreFocus = restoringFocus &&
(currentFocusSnapshot.rowKey == catalogKey || index == focusState.focusedRowIndex)
val shouldInitialFocusFirstCatalogRow =
shouldRequestInitialFocus &&
!heroVisible &&
@ -351,12 +354,21 @@ fun ClassicHomeContent(
if (restoringFocus) restoringFocus = false
currentFocusSnapshot.rowIndex = index
currentFocusSnapshot.itemIndex = itemIndex
currentFocusSnapshot.rowKey = catalogKey
}
)
}
is HomeRow.CollectionRow -> {
val collectionKey = "collection_${homeRow.collection.id}"
// Match by saved row key first, fall back to index
val shouldRestoreCollectionFocus = restoringFocus &&
(currentFocusSnapshot.rowKey == collectionKey || index == focusState.focusedRowIndex)
val collectionFocusedItemIndex = if (shouldRestoreCollectionFocus) {
focusState.focusedItemIndex
} else {
-1
}
val listState = rowStates.getOrPut(collectionKey) {
LazyListState(
firstVisibleItemIndex = focusState.catalogRowScrollStates[collectionKey] ?: 0
@ -367,10 +379,12 @@ fun ClassicHomeContent(
collection = homeRow.collection,
onFolderClick = onNavigateToFolderDetail,
listState = listState,
focusedItemIndex = collectionFocusedItemIndex,
onItemFocused = { itemIndex ->
if (restoringFocus) restoringFocus = false
currentFocusSnapshot.rowIndex = index
currentFocusSnapshot.itemIndex = itemIndex
currentFocusSnapshot.rowKey = collectionKey
}
)
}

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>