diff --git a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt index 6f2e36c4..7b4edd80 100644 --- a/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt +++ b/app/src/main/java/com/nuvio/tv/core/server/AddonWebPage.kt @@ -192,9 +192,9 @@ object AddonWebPage { border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .addon-order { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.2rem; flex-shrink: 0; } .btn-order { @@ -317,6 +317,19 @@ object AddonWebPage { margin-left: 0.5rem; vertical-align: middle; } + .badge-collection { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(130, 170, 255, 0.95); + border: 1px solid rgba(130, 170, 255, 0.35); + padding: 0.12rem 0.45rem; + border-radius: 100px; + margin-left: 0.5rem; + vertical-align: middle; + } .status-overlay { position: fixed; top: 0; @@ -859,7 +872,7 @@ object AddonWebPage {
- +
@@ -894,8 +907,8 @@ object AddonWebPage {
Collections
- - + +
@@ -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