diff --git a/.gitignore b/.gitignore index 99c9e022..3157fb69 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ tv-samples HOME_UI_OPTIMIZATION_NOTES.local.md .kotlin/ bugs/ +logs/ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cba12cfe..2481fde2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ android:name=".NuvioApplication" android:allowBackup="true" android:banner="@mipmap/banner" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" diff --git a/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt b/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt index a114a519..232931a5 100644 --- a/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt +++ b/app/src/main/java/com/nuvio/tv/core/sync/StartupSyncService.kt @@ -102,15 +102,14 @@ class StartupSyncService @Inject constructor( startupPullJob = scope.launch { val maxAttempts = 3 var syncCompleted = false - repeat(maxAttempts) { index -> - val attempt = index + 1 + for (attempt in 1..maxAttempts) { Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key") val result = pullRemoteData() if (result.isSuccess) { lastPulledKey = key Log.d(TAG, "Startup sync completed for key=$key") syncCompleted = true - return@repeat + break } Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull()) diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt index 8531b7ad..d057332a 100644 --- a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt +++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt @@ -13,6 +13,7 @@ import com.nuvio.tv.domain.model.MetaCompany import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.PersonDetail import com.nuvio.tv.domain.model.PosterShape +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -292,6 +293,8 @@ class TmdbMetadataService @Inject constructor( ) enrichmentCache[cacheKey] = enrichment enrichment + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e) null @@ -318,6 +321,8 @@ class TmdbMetadataService @Inject constructor( val epNum = ep.episodeNumber ?: return@forEach result[season to epNum] = ep.toEnrichment() } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}") } diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt index f93bbd2e..5232a2df 100644 --- a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt +++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt @@ -2,6 +2,7 @@ package com.nuvio.tv.core.tmdb import android.util.Log import com.nuvio.tv.data.remote.api.TmdbApi +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -89,6 +90,8 @@ class TmdbService @Inject constructor( Log.w(TAG, "No TMDB result found for IMDB: $imdbId") null + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e) null @@ -141,6 +144,8 @@ class TmdbService @Inject constructor( Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId") null + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e) null diff --git a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt index 131cf30f..183593d0 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt @@ -18,24 +18,31 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import com.nuvio.tv.core.auth.AuthManager import com.nuvio.tv.core.sync.AddonSyncService import javax.inject.Inject +import javax.inject.Singleton +@Singleton +@OptIn(ExperimentalCoroutinesApi::class) class AddonRepositoryImpl @Inject constructor( private val api: AddonApi, private val preferences: AddonPreferences, private val addonSyncService: AddonSyncService, private val authManager: AuthManager, - @ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context ) : AddonRepository { companion object { @@ -80,6 +87,33 @@ class AddonRepositoryImpl @Inject constructor( private val gson = Gson() private val manifestCache = mutableMapOf() + private val installedAddonsFlow = preferences.installedAddonUrls + .flatMapLatest { urls -> + flow { + // Emit cached addons immediately so consumers can render before refresh completes. + val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] } + if (cached.isNotEmpty()) { + emit(applyDisplayNames(cached)) + } + + val fresh = coroutineScope { + urls.map { url -> + async { + when (val result = fetchAddon(url)) { + is NetworkResult.Success -> result.data + else -> manifestCache[canonicalizeUrl(url)] + } + } + }.awaitAll().filterNotNull() + } + + if (fresh != cached || cached.isEmpty()) { + emit(applyDisplayNames(fresh)) + } + }.flowOn(Dispatchers.IO) + } + .distinctUntilChanged() + .shareIn(syncScope, SharingStarted.Eagerly, replay = 1) init { loadManifestCacheFromDisk() @@ -107,32 +141,7 @@ class AddonRepositoryImpl @Inject constructor( } } - override fun getInstalledAddons(): Flow> = - preferences.installedAddonUrls.flatMapLatest { urls -> - flow { - // Emit cached addons immediately (now includes disk-persisted cache) - val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] } - if (cached.isNotEmpty()) { - emit(applyDisplayNames(cached)) - } - - val fresh = coroutineScope { - urls.map { url -> - async { - when (val result = fetchAddon(url)) { - is NetworkResult.Success -> result.data - else -> manifestCache[canonicalizeUrl(url)] - } - } - }.awaitAll().filterNotNull() - } - - - if (fresh != cached || cached.isEmpty()) { - emit(applyDisplayNames(fresh)) - } - }.flowOn(Dispatchers.IO) - } + override fun getInstalledAddons(): Flow> = installedAddonsFlow override suspend fun fetchAddon(baseUrl: String): NetworkResult { val cleanBaseUrl = canonicalizeUrl(baseUrl) @@ -146,7 +155,17 @@ class AddonRepositoryImpl @Inject constructor( NetworkResult.Success(addon) } is NetworkResult.Error -> { - Log.w(TAG, "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}") + if (isBenignManifestFetchFailure(result.message)) { + Log.d( + TAG, + "Manifest fetch cancelled/short-circuited for url=$cleanBaseUrl message=${result.message}" + ) + } else { + Log.w( + TAG, + "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}" + ) + } result } NetworkResult.Loading -> NetworkResult.Loading @@ -241,4 +260,11 @@ class AddonRepositoryImpl @Inject constructor( } } } + + private fun isBenignManifestFetchFailure(message: String?): Boolean { + val normalized = message?.lowercase().orEmpty() + return "flow was aborted" in normalized || + "no more elements needed" in normalized || + "child of the scoped flow was cancelled" in normalized + } } diff --git a/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt index 1809824b..6a44a5e0 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt @@ -13,6 +13,11 @@ import com.nuvio.tv.domain.repository.AddonRepository import com.nuvio.tv.domain.repository.MetaRepository import com.nuvio.tv.R import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -23,7 +28,7 @@ import javax.inject.Singleton @Singleton class MetaRepositoryImpl @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val api: AddonApi, private val addonRepository: AddonRepository ) : MetaRepository { @@ -35,6 +40,8 @@ class MetaRepositoryImpl @Inject constructor( private val metaCache = ConcurrentHashMap() // Separate cache for full meta fetched from addons (bypasses catalog-level cache) private val addonMetaCache = ConcurrentHashMap() + private val inFlightAddonMetaRequests = ConcurrentHashMap>>() + private val requestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun getMeta( addonBaseUrl: String, @@ -80,6 +87,37 @@ class MetaRepositoryImpl @Inject constructor( emit(NetworkResult.Loading) + val result = awaitAddonMetaResult(cacheKey = cacheKey, type = type, id = id) + emit(result) + } + + private suspend fun awaitAddonMetaResult( + cacheKey: String, + type: String, + id: String + ): NetworkResult { + addonMetaCache[cacheKey]?.let { return NetworkResult.Success(it) } + val request = inFlightAddonMetaRequests[cacheKey] ?: requestScope.async { + fetchMetaFromAllAddonsResult(cacheKey = cacheKey, type = type, id = id) + }.also { created -> + val existing = inFlightAddonMetaRequests.putIfAbsent(cacheKey, created) + if (existing != null) { + created.cancel() + } + } + val activeRequest = inFlightAddonMetaRequests[cacheKey] ?: request + return try { + activeRequest.await() + } finally { + inFlightAddonMetaRequests.remove(cacheKey, activeRequest) + } + } + + private suspend fun fetchMetaFromAllAddonsResult( + cacheKey: String, + type: String, + id: String + ): NetworkResult { val addons = addonRepository.getInstalledAddons().first() val requestedType = type.trim() @@ -130,16 +168,14 @@ class MetaRepositoryImpl @Inject constructor( val meta = metaDto.toDomain(episodeLabel) addonMetaCache[cacheKey] = meta metaCache[cacheKey] = meta - emit(NetworkResult.Success(meta)) - return@flow + return NetworkResult.Success(meta) } } else -> { /* Try next addon */ } } } - emit(NetworkResult.Error("No addons support meta for type: $requestedType")) - return@flow + return NetworkResult.Error("No addons support meta for type: $requestedType") } // Try each candidate until we find meta. @@ -161,8 +197,7 @@ class MetaRepositoryImpl @Inject constructor( TAG, "Meta fetch success addonId=${addon.id} type=$candidateType id=$id" ) - emit(NetworkResult.Success(meta)) - return@flow + return NetworkResult.Success(meta) } Log.d( TAG, @@ -170,16 +205,23 @@ class MetaRepositoryImpl @Inject constructor( ) } is NetworkResult.Error -> { - Log.w( - TAG, - "Meta fetch failed addonId=${addon.id} type=$candidateType id=$id code=${result.code} message=${result.message}" - ) + if (isBenignMetaFetchFailure(result.message)) { + Log.d( + TAG, + "Meta fetch cancelled/short-circuited addonId=${addon.id} type=$candidateType id=$id message=${result.message}" + ) + } else { + Log.w( + TAG, + "Meta fetch failed addonId=${addon.id} type=$candidateType id=$id code=${result.code} message=${result.message}" + ) + } } NetworkResult.Loading -> { /* no-op */ } } } - emit(NetworkResult.Error("Meta not found in any addon")) + return NetworkResult.Error("Meta not found in any addon") } private fun buildMetaUrl(baseUrl: String, type: String, id: String): String { @@ -220,6 +262,15 @@ class MetaRepositoryImpl @Inject constructor( private fun encodePathSegment(value: String): String { return URLEncoder.encode(value, "UTF-8").replace("+", "%20") } + + private fun isBenignMetaFetchFailure(message: String?): Boolean { + val normalized = message?.lowercase().orEmpty() + return "flow was aborted" in normalized || + "no more elements needed" in normalized || + "child of the scoped flow was cancelled" in normalized || + "standalonecoroutine was cancelled" in normalized || + "job was cancelled" in normalized + } override fun clearCache() { metaCache.clear() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index ee2fabe5..b5198a54 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -56,6 +56,10 @@ class HomeViewModel @Inject constructor( private const val MAX_NEXT_UP_LOOKUPS = 24 private const val MAX_NEXT_UP_CONCURRENCY = 4 private const val MAX_CATALOG_LOAD_CONCURRENCY = 4 + internal const val STARTUP_GRACE_PERIOD_MS = 3000L + internal const val MAX_INITIAL_HOME_CATALOGS = 8 + internal const val DEFERRED_HOME_CATALOG_DELAY_MS = 2500L + internal const val DEFERRED_HOME_CATALOG_STAGGER_MS = 120L internal const val EXTERNAL_META_PREFETCH_FOCUS_DEBOUNCE_MS = 220L internal const val MAX_POSTER_STATUS_OBSERVERS = 24 } @@ -88,6 +92,7 @@ class HomeViewModel @Inject constructor( internal var activeCatalogLoadSignature: String? = null internal var catalogLoadGeneration: Long = 0L internal var catalogsLoadInProgress: Boolean = false + internal var deferredCatalogLoadJob: Job? = null internal data class TruncatedRowCacheEntry( val sourceRow: CatalogRow, val truncatedRow: CatalogRow @@ -101,6 +106,10 @@ class HomeViewModel @Inject constructor( internal var trailerPreviewRequestVersion: Long = 0L internal var currentTmdbSettings: TmdbSettings = TmdbSettings() internal var heroEnrichmentJob: Job? = null + internal var activeHeroEnrichmentSignature: String? = null + internal var continueWatchingEnrichmentJob: Job? = null + internal var activeContinueWatchingEnrichmentSignature: String? = null + internal var lastContinueWatchingEnrichmentSignature: String? = null internal var lastHeroEnrichmentSignature: String? = null internal var lastHeroEnrichedItems: List = emptyList() internal val prefetchedExternalMetaIds = Collections.synchronizedSet(mutableSetOf()) @@ -129,8 +138,9 @@ class HomeViewModel @Inject constructor( loadContinueWatching() observeInstalledAddons() viewModelScope.launch { - delay(3000) + delay(STARTUP_GRACE_PERIOD_MS) startupGracePeriodActive = false + scheduleUpdateCatalogRows() } } @@ -293,6 +303,8 @@ class HomeViewModel @Inject constructor( } override fun onCleared() { + heroEnrichmentJob?.cancel() + continueWatchingEnrichmentJob?.cancel() posterStatusReconcileJob?.cancel() cancelInFlightCatalogLoads() posterLibraryObserverJobs.values.forEach { it.cancel() } 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 e5306d0e..f03d0de7 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 @@ -142,10 +142,15 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline( } .map { catalog -> addon to catalog } } - pendingCatalogLoads = catalogsToLoad.size - catalogsToLoad.forEach { (addon, catalog) -> + val orderedCatalogsToLoad = orderCatalogLoads(catalogsToLoad) + val initialCatalogsToLoad = selectInitialCatalogLoads(orderedCatalogsToLoad) + val deferredCatalogsToLoad = orderedCatalogsToLoad.filterNot { it in initialCatalogsToLoad } + + pendingCatalogLoads = initialCatalogsToLoad.size + initialCatalogsToLoad.forEach { (addon, catalog) -> loadCatalogPipeline(addon, catalog, generation) } + scheduleDeferredCatalogLoads(deferredCatalogsToLoad, generation) } catch (e: Exception) { catalogsLoadInProgress = false _uiState.update { it.copy(isLoading = false, error = e.message) } @@ -224,6 +229,46 @@ internal fun HomeViewModel.loadCatalogPipeline( registerCatalogLoadJob(loadJob) } +private fun HomeViewModel.orderCatalogLoads( + catalogsToLoad: List> +): List> { + val orderIndexByKey = catalogOrder.withIndex().associate { (index, key) -> key to index } + return catalogsToLoad.sortedBy { (addon, catalog) -> + orderIndexByKey[catalogKey(addon.id, catalog.apiType, catalog.id)] ?: Int.MAX_VALUE + } +} + +private fun HomeViewModel.selectInitialCatalogLoads( + orderedCatalogsToLoad: List> +): List> { + val preferredCatalogs = orderedCatalogsToLoad.filterNot { (_, catalog) -> + catalog.apiType.equals("other", ignoreCase = true) + } + return preferredCatalogs.take(HomeViewModel.MAX_INITIAL_HOME_CATALOGS) +} + +private fun HomeViewModel.scheduleDeferredCatalogLoads( + deferredCatalogsToLoad: List>, + generation: Long +) { + deferredCatalogLoadJob?.cancel() + deferredCatalogLoadJob = null + + if (deferredCatalogsToLoad.isEmpty()) return + + deferredCatalogLoadJob = viewModelScope.launch { + delay(HomeViewModel.DEFERRED_HOME_CATALOG_DELAY_MS) + deferredCatalogsToLoad.forEachIndexed { index, (addon, catalog) -> + if (generation != catalogLoadGeneration) return@launch + pendingCatalogLoads += 1 + loadCatalogPipeline(addon, catalog, generation) + if (index < deferredCatalogsToLoad.lastIndex) { + delay(HomeViewModel.DEFERRED_HOME_CATALOG_STAGGER_MS) + } + } + } +} + internal fun HomeViewModel.loadMoreCatalogItemsPipeline(catalogId: String, addonId: String, type: String) { val key = catalogKey(addonId = addonId, type = type, catalogId = catalogId) val currentRow = catalogsMap[key] ?: return @@ -417,36 +462,52 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { (tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails) if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) { - heroEnrichmentJob?.cancel() - heroEnrichmentJob = viewModelScope.launch { - val enrichmentSignature = heroEnrichmentSignaturePipeline(baseHeroItems, tmdbSettings) - if (lastHeroEnrichmentSignature == enrichmentSignature) { - val cached = lastHeroEnrichedItems - _uiState.update { state -> - state.copy( - heroItems = if (state.heroItems == cached) state.heroItems else cached, - gridItems = if (currentLayout == HomeLayout.GRID) { - val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, cached) - if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid - } else state.gridItems - ) + val enrichmentSignature = heroEnrichmentSignaturePipeline(baseHeroItems, tmdbSettings) + if (lastHeroEnrichmentSignature == enrichmentSignature) { + activeHeroEnrichmentSignature = null + val cached = lastHeroEnrichedItems + _uiState.update { state -> + state.copy( + heroItems = if (state.heroItems == cached) state.heroItems else cached, + gridItems = if (currentLayout == HomeLayout.GRID) { + val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, cached) + if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid + } else state.gridItems + ) + } + } else if (activeHeroEnrichmentSignature != enrichmentSignature) { + heroEnrichmentJob?.cancel() + activeHeroEnrichmentSignature = enrichmentSignature + heroEnrichmentJob = viewModelScope.launch { + if (startupGracePeriodActive) { + delay(HomeViewModel.STARTUP_GRACE_PERIOD_MS) } - } else { - val enrichedItems = enrichHeroItemsPipeline(baseHeroItems, tmdbSettings) - lastHeroEnrichmentSignature = enrichmentSignature - lastHeroEnrichedItems = enrichedItems - _uiState.update { state -> - state.copy( - heroItems = if (state.heroItems == enrichedItems) state.heroItems else enrichedItems, - gridItems = if (currentLayout == HomeLayout.GRID) { - val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, enrichedItems) - if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid - } else state.gridItems - ) + if (startupGracePeriodActive) { + activeHeroEnrichmentSignature = null + return@launch + } + try { + val enrichedItems = enrichHeroItemsPipeline(baseHeroItems, tmdbSettings) + lastHeroEnrichmentSignature = enrichmentSignature + lastHeroEnrichedItems = enrichedItems + _uiState.update { state -> + state.copy( + heroItems = if (state.heroItems == enrichedItems) state.heroItems else enrichedItems, + gridItems = if (currentLayout == HomeLayout.GRID) { + val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, enrichedItems) + if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid + } else state.gridItems + ) + } + } finally { + if (activeHeroEnrichmentSignature == enrichmentSignature) { + activeHeroEnrichmentSignature = null + } } } } } else { + activeHeroEnrichmentSignature = null lastHeroEnrichmentSignature = null lastHeroEnrichedItems = emptyList() } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogUtils.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogUtils.kt index b10c4543..cbca6160 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogUtils.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogUtils.kt @@ -41,6 +41,8 @@ internal fun HomeViewModel.cancelInFlightCatalogLoads() { activeCatalogLoadJobs.toList().also { activeCatalogLoadJobs.clear() } } jobsToCancel.forEach { it.cancel() } + deferredCatalogLoadJob?.cancel() + deferredCatalogLoadJob = null } internal fun HomeViewModel.rebuildCatalogOrder(addons: List) { 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 adbee77f..1f7efd43 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 @@ -11,8 +11,10 @@ import com.nuvio.tv.domain.model.Video import com.nuvio.tv.domain.model.WatchProgress import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update @@ -34,6 +36,8 @@ import java.util.Locale private const val CW_MAX_RECENT_PROGRESS_ITEMS = 300 private const val CW_MAX_NEXT_UP_LOOKUPS = 24 private const val CW_MAX_NEXT_UP_CONCURRENCY = 4 +private const val CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT = 4 +private const val CW_DEFERRED_IN_PROGRESS_DETAILS_DELAY_MS = 1200L private data class ContinueWatchingSettingsSnapshot( val items: List, @@ -68,7 +72,9 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() { dismissedNextUp = dismissedNextUp, showUnairedNextUp = showUnairedNextUp ) - }.collectLatest { snapshot -> + } + .distinctUntilChanged() + .collectLatest { snapshot -> val items = snapshot.items val daysCap = snapshot.daysCap val dismissedNextUp = snapshot.dismissedNextUp @@ -112,17 +118,111 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() { } // Then enrich Next Up and item details in background. - enrichContinueWatchingProgressively( + scheduleContinueWatchingEnrichmentPipeline( allProgress = recentItems, inProgressItems = inProgressOnly, dismissedNextUp = dismissedNextUp, showUnairedNextUp = showUnairedNextUp ) - enrichInProgressEpisodeDetailsProgressively(inProgressOnly) } } } +private fun HomeViewModel.scheduleContinueWatchingEnrichmentPipeline( + allProgress: List, + inProgressItems: List, + dismissedNextUp: Set, + showUnairedNextUp: Boolean +) { + val enrichmentSignature = buildContinueWatchingEnrichmentSignature( + allProgress = allProgress, + inProgressItems = inProgressItems, + dismissedNextUp = dismissedNextUp, + showUnairedNextUp = showUnairedNextUp + ) + if (enrichmentSignature == lastContinueWatchingEnrichmentSignature || + enrichmentSignature == activeContinueWatchingEnrichmentSignature + ) { + return + } + continueWatchingEnrichmentJob?.cancel() + activeContinueWatchingEnrichmentSignature = enrichmentSignature + continueWatchingEnrichmentJob = viewModelScope.launch { + if (startupGracePeriodActive) { + delay(HomeViewModel.STARTUP_GRACE_PERIOD_MS) + } + if (startupGracePeriodActive) { + activeContinueWatchingEnrichmentSignature = null + return@launch + } + try { + enrichContinueWatchingProgressively( + allProgress = allProgress, + inProgressItems = inProgressItems, + dismissedNextUp = dismissedNextUp, + showUnairedNextUp = showUnairedNextUp + ) + val priorityInProgressItems = inProgressItems.take(CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT) + val deferredInProgressItems = inProgressItems.drop(CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT) + enrichInProgressEpisodeDetailsProgressively(priorityInProgressItems) + if (deferredInProgressItems.isNotEmpty()) { + delay(CW_DEFERRED_IN_PROGRESS_DETAILS_DELAY_MS) + enrichInProgressEpisodeDetailsProgressively(deferredInProgressItems) + } + lastContinueWatchingEnrichmentSignature = enrichmentSignature + } finally { + if (activeContinueWatchingEnrichmentSignature == enrichmentSignature) { + activeContinueWatchingEnrichmentSignature = null + } + } + } +} + +private fun buildContinueWatchingEnrichmentSignature( + allProgress: List, + inProgressItems: List, + dismissedNextUp: Set, + showUnairedNextUp: Boolean +): String { + val allProgressSignature = allProgress.joinToString(separator = "|") { progress -> + listOf( + progress.contentId, + progress.contentType, + progress.videoId, + progress.season, + progress.episode, + progress.position, + progress.duration, + progress.lastWatched, + progress.progressPercent, + progress.source + ).joinToString(separator = ":") + } + val inProgressSignature = inProgressItems.joinToString(separator = "|") { item -> + val progress = item.progress + listOf( + progress.contentId, + progress.contentType, + progress.videoId, + progress.season, + progress.episode, + progress.position, + progress.duration, + progress.lastWatched + ).joinToString(separator = ":") + } + val dismissedSignature = dismissedNextUp.toSortedSet().joinToString(separator = "|") + return buildString { + append(showUnairedNextUp) + append("::") + append(dismissedSignature) + append("::") + append(inProgressSignature) + append("::") + append(allProgressSignature) + } +} + private fun deduplicateInProgress(items: List): List { val (series, nonSeries) = items.partition { isSeriesTypeCW(it.contentType) } val latestPerShow = series @@ -629,7 +729,11 @@ private suspend fun HomeViewModel.resolveMetaForProgress( if (progress.contentId.startsWith("trakt:")) add(progress.contentId.substringAfter(':')) }.distinct() - val typeCandidates = listOf(progress.contentType, "series", "tv").distinct() + val typeCandidates = when { + isSeriesTypeCW(progress.contentType) -> listOf(progress.contentType, "series", "tv").distinct() + progress.contentType.equals("movie", ignoreCase = true) -> listOf("movie") + else -> listOf(progress.contentType).filter { it.isNotBlank() }.distinct() + } val resolved = run { var meta: Meta? = null for (type in typeCandidates) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt index e344f64e..c0db5cd8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt @@ -8,6 +8,7 @@ import com.nuvio.tv.domain.model.HomeLayout import com.nuvio.tv.domain.model.Meta import com.nuvio.tv.domain.model.MetaPreview import com.nuvio.tv.domain.model.TmdbSettings +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async @@ -426,6 +427,8 @@ internal suspend fun HomeViewModel.enrichHeroItemsPipeline( } enriched + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(HomeViewModel.TAG, "Hero enrichment failed for ${item.id}: ${e.message}") item