@@ -1219,15 +1232,22 @@ function renderCatalogs() {
li.innerHTML =
'
' +
'
' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) +
+ (isCollection ? 'Collection' : '') +
(catalog.isDisabled ? '${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}' : '') +
'
' +
'
' + escapeHtml(catalog.addonName) + '
' +
@@ -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;
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt
index 08f3fd6c..c9eedb1d 100644
--- a/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/components/CollectionRowSection.kt
@@ -1,3 +1,8 @@
+@file:OptIn(
+ androidx.compose.ui.ExperimentalComposeUiApi::class,
+ androidx.tv.material3.ExperimentalTvMaterial3Api::class
+)
+
package com.nuvio.tv.ui.components
import androidx.compose.foundation.BorderStroke
@@ -17,12 +22,14 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -63,8 +70,42 @@ fun CollectionRowSection(
val currentOnItemFocused by rememberUpdatedState(onItemFocused)
val currentOnFolderFocused by rememberUpdatedState(onFolderFocused)
val rowFocusRequester = remember { FocusRequester() }
+ val itemFocusRequesters = remember { mutableMapOf
() }
+ var lastRequestedFocusKey by remember { mutableStateOf(null) }
var lastFocusedItemIndex by remember { mutableIntStateOf(-1) }
+ fun folderFocusKey(index: Int, folder: CollectionFolder): String {
+ return "collection_${collection.id}_folder_${folder.id}"
+ }
+
+ // Clean up stale focus requesters when folders change
+ LaunchedEffect(collection.folders) {
+ val validKeys = collection.folders.mapIndexedTo(mutableSetOf()) { index, folder ->
+ folderFocusKey(index, folder)
+ }
+ itemFocusRequesters.keys.retainAll(validKeys)
+ if (lastRequestedFocusKey !in validKeys) {
+ lastRequestedFocusKey = null
+ }
+ }
+
+ // Request focus on the target item when focusedItemIndex is set
+ LaunchedEffect(focusedItemIndex, collection.folders) {
+ if (focusedItemIndex >= 0 && focusedItemIndex < collection.folders.size) {
+ val targetFolder = collection.folders[focusedItemIndex]
+ val targetKey = folderFocusKey(focusedItemIndex, targetFolder)
+ if (lastRequestedFocusKey == targetKey) return@LaunchedEffect
+ val requester = itemFocusRequesters.getOrPut(targetKey) { FocusRequester() }
+ repeat(2) { withFrameNanos { } }
+ val focused = runCatching { requester.requestFocus() }.isSuccess
+ if (focused) {
+ lastRequestedFocusKey = targetKey
+ }
+ } else {
+ lastRequestedFocusKey = null
+ }
+ }
+
Column(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
@@ -87,13 +128,28 @@ fun CollectionRowSection(
modifier = Modifier
.fillMaxWidth()
.focusRequester(rowFocusRequester)
- .focusRestorer(),
+ .then(
+ if (focusedItemIndex < 0 && collection.folders.isNotEmpty()) {
+ Modifier.focusRestorer {
+ val fallbackIndex = listState.firstVisibleItemIndex
+ .coerceIn(0, (collection.folders.size - 1).coerceAtLeast(0))
+ val fallbackFolder = collection.folders.getOrNull(fallbackIndex)
+ if (fallbackFolder != null) {
+ itemFocusRequesters.getOrPut(folderFocusKey(fallbackIndex, fallbackFolder)) { FocusRequester() }
+ } else {
+ rowFocusRequester
+ }
+ }
+ } else {
+ Modifier.focusRestorer()
+ }
+ ),
contentPadding = PaddingValues(start = 48.dp, end = 200.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(
items = collection.folders,
- key = { _, folder -> "collection_${collection.id}_folder_${folder.id}" },
+ key = { index, folder -> folderFocusKey(index, folder) },
contentType = { _, _ -> "collection_folder" }
) { index, folder ->
FolderCard(
@@ -106,7 +162,10 @@ fun CollectionRowSection(
currentOnItemFocused(index)
}
currentOnFolderFocused(collection, folder)
- }
+ },
+ focusRequester = itemFocusRequesters.getOrPut(
+ folderFocusKey(index, folder)
+ ) { FocusRequester() }
)
}
}
@@ -120,7 +179,8 @@ private fun FolderCard(
collection: Collection,
onClick: () -> Unit,
onFocused: () -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ focusRequester: FocusRequester = remember { FocusRequester() }
) {
val tileWidth: Dp
val tileHeight: Dp
@@ -143,6 +203,7 @@ private fun FolderCard(
modifier = modifier
.width(tileWidth)
.height(tileHeight)
+ .focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused()
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt
index 1aecad15..d19c49b4 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementScreen.kt
@@ -102,7 +102,7 @@ fun CollectionManagementScreen(
onBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- val clipboardManager = LocalClipboardManager.current
+
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -127,10 +127,7 @@ fun CollectionManagementScreen(
importError = uiState.importError,
onTextChange = { viewModel.updateImportText(it) },
onImport = { viewModel.importCollections() },
- onPaste = {
- val clip = clipboardManager.getText()?.text ?: ""
- viewModel.updateImportText(clip)
- },
+ onPaste = {},
onBack = { viewModel.hideImportDialog() },
importMode = uiState.importMode,
onModeChange = { viewModel.setImportMode(it) },
@@ -171,13 +168,6 @@ fun CollectionManagementScreen(
repeat(3) { withFrameNanos { } }
try { newButtonFocusRequester.requestFocus() } catch (_: Exception) {}
}
- var showCopied by remember { mutableStateOf(false) }
- LaunchedEffect(showCopied) {
- if (showCopied) {
- kotlinx.coroutines.delay(2000)
- showCopied = false
- }
- }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (uiState.collections.isNotEmpty()) {
NuvioButton(onClick = {
@@ -209,13 +199,6 @@ fun CollectionManagementScreen(
}) {
Text(exportMessage ?: "Export File")
}
- NuvioButton(onClick = {
- val json = viewModel.exportCollections()
- clipboardManager.setText(AnnotatedString(json))
- showCopied = true
- }) {
- Text(if (showCopied) "Copied!" else "Copy JSON")
- }
}
NuvioButton(onClick = { viewModel.showImportDialog() }) {
Text("Import")
@@ -326,7 +309,7 @@ private fun ImportContent(
// Mode tabs
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- ImportMode.entries.forEach { mode ->
+ ImportMode.entries.filter { it != ImportMode.PASTE }.forEach { mode ->
val label = when (mode) {
ImportMode.PASTE -> "Paste JSON"
ImportMode.FILE -> "From File"
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt
index b4bea9a1..11cc99c3 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/CollectionManagementViewModel.kt
@@ -27,7 +27,7 @@ data class CollectionManagementUiState(
val importText: String = "",
val importError: String? = null,
val exportedJson: String? = null,
- val importMode: ImportMode = ImportMode.PASTE,
+ val importMode: ImportMode = ImportMode.FILE,
val importUrl: String = "",
val validationResult: ValidationResult? = null,
val validatedJson: String? = null,
@@ -95,7 +95,7 @@ class CollectionManagementViewModel @Inject constructor(
_uiState.update {
it.copy(
showImportDialog = true, importText = "", importError = null,
- importMode = ImportMode.PASTE, importUrl = "",
+ importMode = ImportMode.FILE, importUrl = "",
validationResult = null, validatedJson = null, isLoadingImport = false
)
}
@@ -105,7 +105,7 @@ class CollectionManagementViewModel @Inject constructor(
_uiState.update {
it.copy(
showImportDialog = false, importText = "", importError = null,
- importMode = ImportMode.PASTE, importUrl = "",
+ importMode = ImportMode.FILE, importUrl = "",
validationResult = null, validatedJson = null, isLoadingImport = false
)
}
@@ -232,7 +232,7 @@ class CollectionManagementViewModel @Inject constructor(
it.copy(
showImportDialog = false, importText = "", importError = null,
validationResult = null, validatedJson = null, importUrl = "",
- importMode = ImportMode.PASTE
+ importMode = ImportMode.FILE
)
}
}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt
index aae085e6..86f71812 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailScreen.kt
@@ -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
)
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt
index 2f7a97d5..26aed737 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ClassicHomeContent.kt
@@ -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
}
)
}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt
index bbe9595f..2711c789 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeHero.kt
@@ -341,7 +341,7 @@ private fun HeroTitleContent(
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart
)
- } else {
+ } else if (preview.title.isNotBlank()) {
Text(
text = preview.title,
style = scaledTitleStyle,
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt
index 9f259095..da242689 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeModels.kt
@@ -474,14 +474,14 @@ internal fun buildCollectionFolderItem(
return ModernCarouselItem(
key = "collection_${collection.id}_${folder.id}_$occurrence",
- title = folder.title,
- subtitle = collection.title,
+ title = if (folder.hideTitle) "" else folder.title,
+ subtitle = if (folder.hideTitle) null else collection.title,
imageUrl = heroImageUrl,
heroPreview = HeroPreview(
- title = title,
+ title = if (folder.hideTitle) "" else title,
logo = null,
description = null,
- contentTypeText = collection.title,
+ contentTypeText = if (folder.hideTitle) null else collection.title,
yearText = null,
imdbText = null,
genres = emptyList(),
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt
index e4ad0e83..2799725a 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeRows.kt
@@ -1013,7 +1013,7 @@ private fun ModernCarouselCard(
}
}
- if (showLabels && !isBackdropExpanded) {
+ if (showLabels && !isBackdropExpanded && item.title.isNotBlank()) {
Column(
modifier = Modifier
.fillMaxWidth()
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 907886b0..44dc7324 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,13 +6,13 @@
Manage Addons
- Manage addons and home catalogs
+ Manage addons, catalogs, and collections
Add addon by URL
https://example.com/manifest.json
Add
Installed Addons
No addons installed
- Home Catalogs
+ Home Catalogs and Collections
No home catalogs available
Save Changes
Connection to TV lost