From 07f113b0f669ce55051e67916a46b94785fd3151 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 12:30:15 +0200 Subject: [PATCH 01/11] Don't TMDB enrich dates when it is turned off --- .../java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt | 2 +- .../nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt index 375af58f..8997359c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt @@ -1144,7 +1144,7 @@ class MetaDetailsViewModel @Inject constructor( video.copy( title = ep?.title ?: video.title, overview = ep?.overview ?: video.overview, - released = ep?.airDate ?: video.released, + released = if (settings.useReleaseDates) ep?.airDate ?: video.released else video.released, thumbnail = ep?.thumbnail ?: video.thumbnail, runtime = ep?.runtimeMinutes ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt index 559df0e3..ea801d0c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt @@ -1274,7 +1274,7 @@ private suspend fun HomeViewModel.enrichNextUpItem( } else { null } - val released = tmdbData?.airDate + val released = (if (currentTmdbSettings.useReleaseDates) tmdbData?.airDate else null) ?: video?.released?.trim()?.takeIf { it.isNotEmpty() } ?: item.info.released val releaseDate = parseEpisodeReleaseDate(released) From 2a7d836eeaca25b0679e96ffcafc8cfc2539e711 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 12:40:44 +0200 Subject: [PATCH 02/11] Add missing class stubs after the Cloudstream merge --- app/proguard-rules.pro | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b7b19aa9..45a8e851 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -100,3 +100,10 @@ # Native code reflects into multiple classes/methods under is.xyz.mpv, # so keep the whole package to avoid JNI lookup crashes after R8. -keep class is.xyz.mpv.** { *; } + +# ── Missing class stubs (referenced by cloudstream3 / jsoup / newpipe) ──────── +-dontwarn org.mozilla.javascript.** +-dontwarn com.google.re2j.** +-dontwarn javax.script.** +-dontwarn okhttp3.internal.sse.** +-dontwarn org.jsoup.helper.Re2jRegex From 8874ebc07d5faab72f71a886ca435e3023aef81b Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 12:41:08 +0200 Subject: [PATCH 03/11] Fix Modern Home TMDB enrichment after Collections introduction --- .../com/nuvio/tv/ui/screens/home/ModernHomeContent.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index 8c756e06..238d09e5 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -172,7 +172,16 @@ fun ModernHomeContent( trailerPlaybackTarget == FocusedPosterTrailerPlaybackTarget.HERO_MEDIA) val visibleHomeRows = remember(uiState.homeRows, uiState.catalogRows) { if (uiState.homeRows.isNotEmpty()) { - uiState.homeRows + val latestCatalogByKey = uiState.catalogRows.associateBy { catalogRowKey(it) } + uiState.homeRows.map { homeRow -> + when (homeRow) { + is HomeRow.Catalog -> { + val latest = latestCatalogByKey[catalogRowKey(homeRow.row)] + if (latest != null && latest !== homeRow.row) HomeRow.Catalog(latest) else homeRow + } + else -> homeRow + } + } } else { uiState.catalogRows.filter { it.items.isNotEmpty() }.map { HomeRow.Catalog(it) } } From 4103a450d4bb55cd3bd29f44dbfcd508d72e5f32 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 14:18:17 +0200 Subject: [PATCH 04/11] If same timestamp, order latestEpisodeByContent by season/episode & change next up to use watched items instead of watch progress --- .../tv/core/sync/WatchProgressSyncService.kt | 6 ++- .../repository/WatchProgressRepositoryImpl.kt | 45 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/core/sync/WatchProgressSyncService.kt b/app/src/main/java/com/nuvio/tv/core/sync/WatchProgressSyncService.kt index f1c69a83..6431ebfe 100644 --- a/app/src/main/java/com/nuvio/tv/core/sync/WatchProgressSyncService.kt +++ b/app/src/main/java/com/nuvio/tv/core/sync/WatchProgressSyncService.kt @@ -286,7 +286,11 @@ class WatchProgressSyncService @Inject constructor( } } .groupBy { it.contentId } - .mapValues { (_, episodes) -> episodes.maxByOrNull { it.lastWatched } } + .mapValues { (_, episodes) -> episodes.maxWithOrNull( + compareBy { it.lastWatched } + .thenBy { it.season ?: 0 } + .thenBy { it.episode ?: 0 } + ) } latestEpisodeByContent.forEach { (contentId, latestEpisode) -> val latest = latestEpisode ?: return@forEach diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt index 6fd73200..14c460d3 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt @@ -345,15 +345,42 @@ class WatchProgressRepositoryImpl @Inject constructor( mergeNextUpSeeds(canonicalSeeds, optimisticSeeds) } } else { - watchProgressPreferences.allProgress.map { items -> - items.filter { progress -> - progress.isCompleted() && - progress.contentType.equals("series", ignoreCase = true) && - progress.season != null && - progress.episode != null && - progress.season != 0 && - !isMalformedNextUpSeedContentId(progress.contentId) - } + // Use watched items (fully synced with pagination) to build seeds + // instead of watch progress (limited to 1000 entries). + watchedItemsPreferences.allItems.map { items -> + items + .filter { item -> + (item.contentType.equals("series", ignoreCase = true) || + item.contentType.equals("tv", ignoreCase = true)) && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.contentId) + } + .groupBy { it.contentId } + .mapNotNull { (_, episodes) -> + val latest = episodes.maxWithOrNull( + compareBy { it.watchedAt } + .thenBy { it.season ?: 0 } + .thenBy { it.episode ?: 0 } + ) ?: return@mapNotNull null + WatchProgress( + contentId = latest.contentId, + contentType = latest.contentType, + name = latest.title, + poster = null, + backdrop = null, + logo = null, + videoId = latest.contentId, + season = latest.season, + episode = latest.episode, + episodeTitle = null, + position = 1L, + duration = 1L, + lastWatched = latest.watchedAt, + progressPercent = 100f + ) + } } } } From 8d543b3dc3463bee011a51a297d356748609bc19 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 22:50:10 +0200 Subject: [PATCH 05/11] TMDB Enrich Pause Overlay --- .../home/HomeViewModelCatalogPipeline.kt | 6 ++ .../screens/player/PlayerRuntimeController.kt | 3 + .../player/PlayerRuntimeControllerMetadata.kt | 69 +++++++++++++++++++ .../tv/ui/screens/player/PlayerViewModel.kt | 9 +++ 4 files changed, 87 insertions(+) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt index d8c8df9c..e8051f72 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt @@ -73,7 +73,13 @@ internal fun HomeViewModel.observeTmdbSettingsPipeline() { tmdbSettingsDataStore.settings .distinctUntilChanged() .collectLatest { settings -> + val languageChanged = currentTmdbSettings.language != settings.language currentTmdbSettings = settings + if (languageChanged) { + // Allow re-enrichment with the new language on next focus. + prefetchedTmdbIds.clear() + prefetchedExternalMetaIds.clear() + } scheduleUpdateCatalogRows() } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 67d0cc74..9d330301 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -52,6 +52,9 @@ class PlayerRuntimeController( internal val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore, internal val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences, internal val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore, + internal val tmdbService: com.nuvio.tv.core.tmdb.TmdbService, + internal val tmdbMetadataService: com.nuvio.tv.core.tmdb.TmdbMetadataService, + internal val tmdbSettingsDataStore: com.nuvio.tv.data.local.TmdbSettingsDataStore, savedStateHandle: SavedStateHandle, internal val scope: CoroutineScope ) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt index e3b46346..b29da74b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt @@ -2,6 +2,7 @@ package com.nuvio.tv.ui.screens.player import com.nuvio.tv.R import com.nuvio.tv.core.network.NetworkResult +import com.nuvio.tv.domain.model.ContentType import com.nuvio.tv.domain.model.Meta import com.nuvio.tv.domain.model.Stream import kotlinx.coroutines.delay @@ -28,6 +29,10 @@ internal fun PlayerRuntimeController.fetchMetaDetails(id: String?, type: String? } } } + + scope.launch { + enrichDescriptionFromTmdb(id, type) + } } internal fun PlayerRuntimeController.applyMetaDetails(meta: Meta) { @@ -63,6 +68,70 @@ internal fun PlayerRuntimeController.updateEpisodeDescription() { if (!overview.isNullOrBlank()) { _uiState.update { it.copy(description = overview) } } + + // Re-enrich from TMDB for the new episode. + scope.launch { + enrichDescriptionFromTmdb(contentId, contentType) + } +} + +private suspend fun PlayerRuntimeController.enrichDescriptionFromTmdb(id: String?, type: String?) { + if (id.isNullOrBlank() || type.isNullOrBlank()) return + val settings = tmdbSettingsDataStore.settings.first() + if (!settings.enabled || !settings.useBasicInfo) return + + val tmdbId = runCatching { tmdbService.ensureTmdbId(id, type) }.getOrNull() ?: return + val contentType = when (type.lowercase()) { + "series", "tv" -> ContentType.SERIES + else -> ContentType.MOVIE + } + val enrichment = runCatching { + tmdbMetadataService.fetchEnrichment( + tmdbId = tmdbId, + contentType = contentType, + language = settings.language + ) + }.getOrNull() ?: return + + val isSeries = type.lowercase() in listOf("series", "tv") + val season = currentSeason + val episode = currentEpisode + + // For series, try to get episode-level overview and title from TMDB. + val episodeEnrichment = if (isSeries && season != null && episode != null) { + runCatching { + tmdbMetadataService.fetchEpisodeEnrichment( + tmdbId = tmdbId, + seasonNumbers = listOf(season), + language = settings.language + )[season to episode] + }.getOrNull() + } else null + + val tmdbDescription = episodeEnrichment?.overview ?: enrichment.description + if (!tmdbDescription.isNullOrBlank()) { + _uiState.update { it.copy(description = tmdbDescription) } + } + + // Enrich title from TMDB (localized). + val tmdbTitle = enrichment.localizedTitle + if (!tmdbTitle.isNullOrBlank()) { + _uiState.update { it.copy(title = tmdbTitle) } + } + + // Enrich logo from TMDB if artwork is enabled. + if (settings.useArtwork) { + val tmdbLogo = enrichment.logo + if (!tmdbLogo.isNullOrBlank()) { + _uiState.update { it.copy(logo = tmdbLogo) } + } + } + + // Also enrich episode title from TMDB if available. + val tmdbEpisodeTitle = episodeEnrichment?.title + if (!tmdbEpisodeTitle.isNullOrBlank()) { + _uiState.update { it.copy(currentEpisodeTitle = tmdbEpisodeTitle) } + } } internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boolean) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index 8a3ca00a..844bfc17 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -16,6 +16,9 @@ import com.nuvio.tv.domain.repository.AddonRepository import com.nuvio.tv.domain.repository.MetaRepository import com.nuvio.tv.domain.repository.StreamRepository import com.nuvio.tv.domain.repository.WatchProgressRepository +import com.nuvio.tv.core.tmdb.TmdbService +import com.nuvio.tv.core.tmdb.TmdbMetadataService +import com.nuvio.tv.data.local.TmdbSettingsDataStore import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.StateFlow @@ -39,6 +42,9 @@ class PlayerViewModel @Inject constructor( private val layoutPreferenceDataStore: com.nuvio.tv.data.local.LayoutPreferenceDataStore, private val watchedItemsPreferences: com.nuvio.tv.data.local.WatchedItemsPreferences, private val trackPreferenceDataStore: com.nuvio.tv.data.local.TrackPreferenceDataStore, + private val tmdbService: TmdbService, + private val tmdbMetadataService: TmdbMetadataService, + private val tmdbSettingsDataStore: TmdbSettingsDataStore, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -59,6 +65,9 @@ class PlayerViewModel @Inject constructor( layoutPreferenceDataStore = layoutPreferenceDataStore, watchedItemsPreferences = watchedItemsPreferences, trackPreferenceDataStore = trackPreferenceDataStore, + tmdbService = tmdbService, + tmdbMetadataService = tmdbMetadataService, + tmdbSettingsDataStore = tmdbSettingsDataStore, savedStateHandle = savedStateHandle, scope = viewModelScope ) From 1cb222117afcb4f5ff0c7bdf00fce18f6176728d Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 8 Apr 2026 23:43:45 +0200 Subject: [PATCH 06/11] Fix for collection catalog names --- .../screens/collection/FolderDetailScreen.kt | 32 ++++++++++++++++--- .../collection/FolderDetailViewModel.kt | 29 +++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) 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 aaceb25f..12723298 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 @@ -53,6 +53,8 @@ import com.nuvio.tv.domain.model.HomeLayout import com.nuvio.tv.ui.components.CatalogRowSection import com.nuvio.tv.ui.components.ContentCard import com.nuvio.tv.ui.components.LoadingIndicator +import com.nuvio.tv.R +import androidx.compose.ui.res.stringResource import com.nuvio.tv.ui.components.PosterCardDefaults import com.nuvio.tv.ui.components.PosterCardStyle import com.nuvio.tv.ui.screens.home.ClassicHomeContent @@ -261,8 +263,13 @@ private fun TabbedGridContent( style = MaterialTheme.typography.labelLarge ) if (tab.typeLabel.isNotBlank()) { + val localizedType = when (tab.rawType.lowercase()) { + "movie" -> stringResource(R.string.type_movie) + "series" -> stringResource(R.string.type_series) + else -> tab.typeLabel + } Text( - text = tab.typeLabel, + text = localizedType, style = MaterialTheme.typography.bodySmall, color = NuvioColors.TextTertiary ) @@ -425,6 +432,9 @@ private fun RowsContent( } } + val strTypeMovie = stringResource(R.string.type_movie) + val strTypeSeries = stringResource(R.string.type_series) + LazyColumn( state = columnListState, modifier = Modifier.fillMaxSize(), @@ -433,11 +443,25 @@ private fun RowsContent( ) { sourceTabs.forEachIndexed { index, tab -> item(key = "row_${index}_${tab.label}") { + val localizedTypeLabel = remember(tab.rawType, strTypeMovie, strTypeSeries) { + when (tab.rawType.lowercase()) { + "movie" -> strTypeMovie + "series" -> strTypeSeries + else -> tab.rawType.replaceFirstChar { it.uppercase() } + } + } + val rowTitle = remember(tab.label, localizedTypeLabel) { + if (tab.label != tab.typeLabel && localizedTypeLabel.isNotEmpty()) { + "${tab.label} - $localizedTypeLabel" + } else { + tab.label + } + } when { tab.isLoading -> { Column(modifier = Modifier.fillMaxWidth()) { Text( - text = tab.label, + text = rowTitle, style = MaterialTheme.typography.headlineSmall, color = NuvioColors.TextPrimary, modifier = Modifier.padding(start = 48.dp, end = 48.dp, bottom = 12.dp) @@ -455,7 +479,7 @@ private fun RowsContent( tab.error != null -> { Column(modifier = Modifier.fillMaxWidth()) { Text( - text = tab.label, + text = rowTitle, style = MaterialTheme.typography.headlineSmall, color = NuvioColors.TextPrimary, modifier = Modifier.padding(start = 48.dp, end = 48.dp, bottom = 12.dp) @@ -483,7 +507,7 @@ private fun RowsContent( onItemClick = onNavigateToDetail, showPosterLabels = true, showAddonName = false, - showCatalogTypeSuffix = false, + showCatalogTypeSuffix = true, isItemWatched = isItemWatched, listState = listState, focusedItemIndex = if ( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt index 4bac975c..8c2356ba 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt @@ -47,6 +47,7 @@ data class FolderDetailUiState( data class FolderTab( val label: String, val typeLabel: String = "", + val rawType: String = "", val catalogRow: CatalogRow? = null, val isLoading: Boolean = true, val error: String? = null, @@ -127,8 +128,10 @@ class FolderDetailViewModel @Inject constructor( val sourceTabs = folder.catalogSources.map { source -> val addon = addons.find { it.id == source.addonId } val catalog = addon?.catalogs?.find { it.id == source.catalogId && it.apiType == source.type } + ?: addon?.catalogs?.find { it.id == source.catalogId.substringBefore(",") && it.apiType == source.type } + ?: addons.firstNotNullOfOrNull { a -> a.catalogs.find { it.id == source.catalogId && it.apiType == source.type } } val (name, typeLabel) = buildTabLabels(source, catalog?.name) - FolderTab(label = name, typeLabel = typeLabel, isLoading = true) + FolderTab(label = name, typeLabel = typeLabel, rawType = source.type, isLoading = true) } val tabs = if (showAll) { @@ -266,13 +269,27 @@ class FolderDetailViewModel @Inject constructor( return@launch } - val catalog = addon.catalogs.find { it.id == source.catalogId && it.apiType == source.type } - val catalogName = catalog?.name ?: source.catalogId + var catalog = addon.catalogs.find { it.id == source.catalogId && it.apiType == source.type } + ?: addon.catalogs.find { it.id == source.catalogId.substringBefore(",") && it.apiType == source.type } + // If the catalog wasn't found in the declared addon, search all installed addons. + var effectiveAddon: com.nuvio.tv.domain.model.Addon = addon + if (catalog == null) { + for (a in addons) { + val match = a.catalogs.find { it.id == source.catalogId && it.apiType == source.type } + if (match != null) { + effectiveAddon = a + catalog = match + break + } + } + } + val tab = _uiState.value.tabs.getOrNull(tabIndex) + val catalogName = catalog?.name ?: tab?.label?.takeIf { it != tab?.typeLabel } ?: source.catalogId catalogRepository.getCatalog( - addonBaseUrl = addon.baseUrl, - addonId = addon.id, - addonName = addon.displayName, + addonBaseUrl = effectiveAddon.baseUrl, + addonId = effectiveAddon.id, + addonName = effectiveAddon.displayName, catalogId = source.catalogId, catalogName = catalogName, type = source.type, From 08c6c87e430d639010f6884eb90db5ab423df3c1 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 9 Apr 2026 00:35:50 +0200 Subject: [PATCH 07/11] Add enrichment on focus to collections items --- .../screens/collection/FolderDetailScreen.kt | 12 +- .../collection/FolderDetailViewModel.kt | 147 +++++++++++++++++- 2 files changed, 156 insertions(+), 3 deletions(-) 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 12723298..ae32d415 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 @@ -96,13 +96,17 @@ fun FolderDetailScreen( { item -> uiState.movieWatchedStatus[com.nuvio.tv.ui.screens.home.homeItemStatusKey(item.id, item.apiType)] == true } } + val enrichingItemId by viewModel.enrichingItemId.collectAsStateWithLifecycle() + if (uiState.viewMode == FolderViewMode.FOLLOW_LAYOUT) { FollowLayoutContent( uiState = uiState, focusState = followLayoutFocusState, + enrichingItemId = enrichingItemId, onNavigateToDetail = onNavigateToDetail, onSaveFocusState = viewModel::saveFollowLayoutFocusState, - onSaveGridFocusState = viewModel::saveFollowLayoutGridFocusState + onSaveGridFocusState = viewModel::saveFollowLayoutGridFocusState, + onItemFocus = viewModel::onItemFocused ) } else { Column( @@ -534,9 +538,11 @@ private fun RowsContent( private fun FollowLayoutContent( uiState: FolderDetailUiState, focusState: HomeScreenFocusState, + enrichingItemId: String? = null, onNavigateToDetail: (String, String, String) -> Unit, onSaveFocusState: (Int, Int, Int, Int, Map) -> Unit, - onSaveGridFocusState: (Int, Int, String?) -> Unit + onSaveGridFocusState: (Int, Int, String?) -> Unit, + onItemFocus: (MetaPreview) -> Unit = {} ) { val homeState = uiState.followLayoutHomeState @@ -593,6 +599,7 @@ private fun FollowLayoutContent( HomeLayout.MODERN -> ModernHomeContent( uiState = homeState, focusState = focusState, + enrichingItemId = enrichingItemId, trailerPreviewUrls = emptyMap(), trailerPreviewAudioUrls = emptyMap(), onNavigateToDetail = onNavigateToDetail, @@ -602,6 +609,7 @@ private fun FollowLayoutContent( onRemoveContinueWatching = noOpRemoveCw, isCatalogItemWatched = isItemWatched, onNavigateToFolderDetail = noOpFolderDetail, + onItemFocus = onItemFocus, onSaveFocusState = onSaveFocusState ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt index 8c2356ba..88f96cb8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/collection/FolderDetailViewModel.kt @@ -69,7 +69,13 @@ class FolderDetailViewModel @Inject constructor( private val catalogRepository: CatalogRepository, private val layoutPreferenceDataStore: LayoutPreferenceDataStore, private val watchProgressRepository: WatchProgressRepository, - private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder + private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder, + private val tmdbService: com.nuvio.tv.core.tmdb.TmdbService, + private val tmdbMetadataService: com.nuvio.tv.core.tmdb.TmdbMetadataService, + private val tmdbSettingsDataStore: com.nuvio.tv.data.local.TmdbSettingsDataStore, + private val mdbListRepository: com.nuvio.tv.data.repository.MDBListRepository, + private val mdbListSettingsDataStore: com.nuvio.tv.data.local.MDBListSettingsDataStore, + private val metaRepository: com.nuvio.tv.domain.repository.MetaRepository ) : ViewModel() { private val collectionId: String = savedStateHandle["collectionId"] ?: "" @@ -80,6 +86,10 @@ class FolderDetailViewModel @Inject constructor( private var movieWatchedJob: Job? = null private var seriesWatchedJob: Job? = null + private var enrichFocusJob: Job? = null + private val enrichedItemIds = java.util.Collections.synchronizedSet(mutableSetOf()) + private val _enrichingItemId = MutableStateFlow(null) + val enrichingItemId: StateFlow = _enrichingItemId.asStateFlow() private val _rowsFocusState = MutableStateFlow(com.nuvio.tv.ui.screens.home.HomeScreenFocusState()) val rowsFocusState: StateFlow = _rowsFocusState.asStateFlow() @@ -464,4 +474,139 @@ class FolderDetailViewModel @Inject constructor( } return name to typeLabel } + + fun onItemFocused(item: MetaPreview) { + // Clear enriching for previous item immediately. + if (_enrichingItemId.value != null && _enrichingItemId.value != item.id) { + _enrichingItemId.value = null + } + if (item.id in enrichedItemIds) return + + // Check if any enrichment source is active — if so, signal enriching immediately + // so hero content hides until enrichment completes. + val viewMode = _uiState.value.viewMode + if (viewMode == FolderViewMode.FOLLOW_LAYOUT && _uiState.value.homeLayout == HomeLayout.MODERN) { + _enrichingItemId.value = item.id + } + + enrichFocusJob?.cancel() + enrichFocusJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + kotlinx.coroutines.delay(350) + val tmdbSettings = tmdbSettingsDataStore.settings.first() + val mdbSettings = mdbListSettingsDataStore.settings.first() + val homeLayout = _uiState.value.homeLayout + val tmdbEnabled = tmdbSettings.enabled && + (homeLayout != HomeLayout.MODERN || tmdbSettings.modernHomeEnabled) + val mdbEnabled = mdbSettings.enabled && mdbSettings.apiKey.isNotBlank() + val externalMetaEnabled = layoutPreferenceDataStore.preferExternalMetaAddonDetail.first() + if (!tmdbEnabled && !mdbEnabled && !externalMetaEnabled) { + if (_enrichingItemId.value == item.id) _enrichingItemId.value = null + return@launch + } + + val mdbRating = if (mdbEnabled) { + runCatching { mdbListRepository.getImdbRatingForItem(item.id, item.apiType) }.getOrNull() + } else null + + var enrichment: com.nuvio.tv.core.tmdb.TmdbEnrichment? = null + if (tmdbEnabled) { + val tmdbId = runCatching { tmdbService.ensureTmdbId(item.id, item.apiType) }.getOrNull() + if (tmdbId != null) { + enrichment = runCatching { + tmdbMetadataService.fetchEnrichment( + tmdbId = tmdbId, + contentType = item.type, + language = tmdbSettings.language + ) + }.getOrNull() + } + } + + if (enrichment == null && mdbRating == null && !externalMetaEnabled) return@launch + enrichedItemIds.add(item.id) + + // Apply TMDB + MDB enrichment if available. + if (enrichment != null || mdbRating != null) { + val finalEnrichment = enrichment + val finalMdbRating = mdbRating + + updateItemInTabs(item.id) { merged -> + var result = merged + if (finalEnrichment != null) { + if (tmdbSettings.useBasicInfo) { + result = result.copy( + name = finalEnrichment.localizedTitle ?: result.name, + description = finalEnrichment.description ?: result.description, + genres = if (finalEnrichment.genres.isNotEmpty()) finalEnrichment.genres else result.genres, + imdbRating = finalEnrichment.rating?.toFloat() ?: result.imdbRating + ) + } + if (tmdbSettings.useArtwork) { + result = result.copy( + background = finalEnrichment.backdrop ?: result.background, + logo = finalEnrichment.logo ?: result.logo + ) + } + if (tmdbSettings.useReleaseDates) { + result = result.copy( + releaseInfo = finalEnrichment.releaseInfo ?: result.releaseInfo + ) + } + if (tmdbSettings.useDetails) { + result = result.copy( + ageRating = finalEnrichment.ageRating ?: result.ageRating, + status = finalEnrichment.status ?: result.status + ) + } + } + if (finalMdbRating != null && result.imdbRating == null) { + result = result.copy(imdbRating = finalMdbRating.toFloat()) + } + result + } + } + + // External meta addon fallback when TMDB didn't enrich. + if (enrichment == null && externalMetaEnabled) { + val metaResult = metaRepository.getMetaFromAllAddons(item.apiType, item.id) + .first { it is NetworkResult.Success || it is NetworkResult.Error } + if (metaResult is NetworkResult.Success) { + val meta = metaResult.data + updateItemInTabs(item.id) { merged -> + merged.copy( + name = meta.name.takeIf { it.isNotBlank() } ?: merged.name, + description = meta.description?.takeIf { it.isNotBlank() } ?: merged.description, + background = meta.background?.takeIf { it.isNotBlank() } ?: merged.background, + logo = meta.logo?.takeIf { it.isNotBlank() } ?: merged.logo, + genres = meta.genres.takeIf { it.isNotEmpty() } ?: merged.genres, + imdbRating = meta.imdbRating ?: merged.imdbRating, + releaseInfo = meta.releaseInfo?.takeIf { it.isNotBlank() } ?: merged.releaseInfo + ) + } + } + } + + // Sync enriched tabs into followLayoutHomeState for FOLLOW_LAYOUT mode. + if (_enrichingItemId.value == item.id) _enrichingItemId.value = null + rebuildFollowLayoutState() + } + } + + private fun updateItemInTabs(itemId: String, transform: (MetaPreview) -> MetaPreview) { + _uiState.update { state -> + var changed = false + val updatedTabs = state.tabs.map { tab -> + val row = tab.catalogRow ?: return@map tab + val idx = row.items.indexOfFirst { it.id == itemId } + if (idx < 0) return@map tab + val merged = transform(row.items[idx]) + if (merged == row.items[idx]) return@map tab + changed = true + val items = row.items.toMutableList() + items[idx] = merged + tab.copy(catalogRow = row.copy(items = items)) + } + if (changed) state.copy(tabs = updatedTabs) else state + } + } } From 0cddbaf4bc7e703084f35ad4941983ce9938f657 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 9 Apr 2026 01:15:40 +0200 Subject: [PATCH 08/11] Add translations for collections --- .../com/nuvio/tv/core/server/AddonWebPage.kt | 119 +++++++++++------- .../tv/ui/screens/addon/AddonManagerScreen.kt | 4 +- .../collection/CollectionEditorScreen.kt | 116 ++++++++--------- .../collection/CollectionManagementScreen.kt | 65 +++++----- .../screens/collection/FolderDetailScreen.kt | 9 +- app/src/main/res/values-pl/strings.xml | 95 ++++++++++++++ app/src/main/res/values/strings.xml | 95 ++++++++++++++ 7 files changed, 368 insertions(+), 135 deletions(-) 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 7b4edd80..0356c808 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 @@ -871,9 +871,9 @@ object AddonWebPage {
- - - + + +
@@ -895,8 +895,8 @@ object AddonWebPage {
- - + +
    ${context.getString(R.string.web_no_catalogs)}
    @@ -905,28 +905,28 @@ object AddonWebPage {
    - +
    - - + +
    - - - + + +
    -
    No collections yet
    +
    ${context.getString(R.string.web_no_collections)}
    -
    Import Collections
    +
    ${context.getString(R.string.web_import_collections_title)}
    - - - + + +
    @@ -973,6 +973,39 @@ var pollTimer = null; var pollStartTime = 0; var POLL_TIMEOUT = 120000; var POLL_INTERVAL = 1500; + +var i18n = { + newCollection: '${context.getString(R.string.collections_new).replace("'", "\\'")}', + backdrop: '${context.getString(R.string.collections_editor_backdrop).replace("'", "\\'")}', + pinAbove: '${context.getString(R.string.collections_editor_pin_above).replace("'", "\\'")}', + focusGlow: '${context.getString(R.string.collections_editor_focus_glow).replace("'", "\\'")}', + viewMode: '${context.getString(R.string.collections_editor_view_mode).replace("'", "\\'")}', + tabs: '${context.getString(R.string.collections_editor_view_mode_tabs).replace("'", "\\'")}', + rows: '${context.getString(R.string.collections_editor_view_mode_rows).replace("'", "\\'")}', + followHome: '${context.getString(R.string.collections_editor_view_mode_follow).replace("'", "\\'")}', + showAllTab: '${context.getString(R.string.collections_editor_show_all_tab).replace("'", "\\'")}', + cover: '${context.getString(R.string.collections_editor_cover).replace("'", "\\'")}', + coverNone: '${context.getString(R.string.collections_editor_cover_none).replace("'", "\\'")}', + coverEmoji: '${context.getString(R.string.collections_editor_cover_emoji).replace("'", "\\'")}', + coverImage: '${context.getString(R.string.collections_editor_cover_image_url).replace("'", "\\'")}', + focusGif: '${context.getString(R.string.collections_editor_focus_gif).replace("'", "\\'")}', + playGif: '${context.getString(R.string.collections_editor_play_gif).replace("'", "\\'")}', + tileShape: '${context.getString(R.string.collections_editor_tile_shape).replace("'", "\\'")}', + hideTitle: '${context.getString(R.string.collections_editor_hide_title).replace("'", "\\'")}', + catalogs: '${context.getString(R.string.collections_editor_catalogs).replace("'", "\\'")}', + addCatalog: '${context.getString(R.string.collections_editor_add_catalog).replace("'", "\\'")}', + addFolder: '${context.getString(R.string.collections_editor_add_folder).replace("'", "\\'")}', + folders: '${context.getString(R.string.collections_editor_folders).replace("'", "\\'")}', + hidden: '${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}', + display: '${context.getString(R.string.collections_editor_display).replace("'", "\\'")}', + shape: '${context.getString(R.string.collections_editor_tile_shape).replace("'", "\\'")}', + shapePoster: '${context.getString(R.string.collections_editor_shape_poster).replace("'", "\\'")}', + shapeWide: '${context.getString(R.string.collections_editor_shape_wide).replace("'", "\\'")}', + shapeSquare: '${context.getString(R.string.collections_editor_shape_square).replace("'", "\\'")}', + tapToPickEmoji: '${context.getString(R.string.collections_editor_cover_emoji).replace("'", "\\'")}', + added: '${context.getString(R.string.web_btn_add).replace("'", "\\'")}', + add: '+ ${context.getString(R.string.web_btn_add).replace("'", "\\'")}' +}; var connectionLost = false; var consecutiveErrors = 0; var activeTab = 'addons'; @@ -1247,7 +1280,7 @@ function renderCatalogs() { '
    ' + '
    ' + '
    ' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) + - (isCollection ? 'Collection' : '') + + (isCollection ? '${context.getString(R.string.web_badge_collection).replace("'", "\\'")}' : '') + (catalog.isDisabled ? '${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}' : '') + '
    ' + '
    ' + escapeHtml(catalog.addonName) + '
    ' + @@ -1577,7 +1610,7 @@ function isCollectionDisabled(ci) { } function addCollection() { - collections.push({ id: generateId(), title: 'New Collection', backdropImageUrl: null, pinToTop: false, focusGlowEnabled: true, viewMode: 'TABBED_GRID', showAllTab: true, folders: [] }); + collections.push({ id: generateId(), title: i18n.newCollection, backdropImageUrl: null, pinToTop: false, focusGlowEnabled: true, viewMode: 'TABBED_GRID', showAllTab: true, folders: [] }); expandedCollection = collections.length - 1; expandedFolder = null; renderCollections(); @@ -1782,7 +1815,7 @@ function renderCollections() { '
    ' + '' + '' + - (disabled ? 'Hidden' : '') + + (disabled ? '' + i18n.hidden + '' : '') + (errors.length > 0 ? '' + errors.length + ' issue' + (errors.length > 1 ? 's' : '') + '' : '') + '
    ' + '' + - '' + - '' + + '' + + '' + + '' + '
    ' + '
    ' + ((col.viewMode === 'TABBED_GRID' || !col.viewMode) ? '
    ' + - 'Show "All" tab' + + '' + i18n.showAllTab + '' + '