diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 115fbf8a..43397cbe 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1345,8 +1345,11 @@ This item does not expose a playable video file. No playable files No playable files + Cloud library is off. Couldn't play this cloud file. Play file + Cloud service is not connected. + %1$s is not connected. %1$d playable files All Refresh cloud library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 864d3010..266ac99a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -110,6 +110,7 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult +import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.cloud.providerPosterUrl @@ -187,6 +188,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ResumePromptRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -610,6 +612,8 @@ private fun MainAppContent( val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) val externalPlayerFailedText = stringResource(Res.string.external_player_failed) val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) + val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) + val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -1063,21 +1067,42 @@ private fun MainAppContent( val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> if (item.isCloudLibraryContinueWatchingItem()) { coroutineScope.launch { - val target = CloudLibraryRepository.findPlaybackTargetForProgress( - contentId = item.parentMetaId, - videoId = item.videoId, - ) - val launched = target?.let { playbackTarget -> - launchCloudLibraryFile( - item = playbackTarget.item, - file = playbackTarget.file, - resumePositionMs = item.resumePositionMs, - resumeProgressFraction = item.resumeProgressFraction, - startFromBeginning = startFromBeginning, + when ( + val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult( + contentId = item.parentMetaId, + videoId = item.videoId, ) - } == true - if (!launched) { - NuvioToastController.show(cloudLibraryPlayFailedText) + ) { + is CloudLibraryPlaybackTargetLookupResult.Found -> { + val launched = launchCloudLibraryFile( + item = lookup.target.item, + file = lookup.target.file, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + startFromBeginning = startFromBeginning, + ) + if (!launched) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + + CloudLibraryPlaybackTargetLookupResult.Disabled -> { + NuvioToastController.show(cloudLibraryPlayDisabledText) + } + + is CloudLibraryPlaybackTargetLookupResult.NotConnected -> { + val providerName = lookup.providerName?.takeIf { it.isNotBlank() } + NuvioToastController.show( + providerName?.let { name -> + getString(Res.string.cloud_library_play_provider_not_connected, name) + } + ?: cloudLibraryPlayNotConnectedText, + ) + } + + CloudLibraryPlaybackTargetLookupResult.NotFound -> { + NuvioToastController.show(cloudLibraryPlayFailedText) + } } } } else { @@ -1235,7 +1260,18 @@ private fun MainAppContent( onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onCloudFilePlay = { item, file -> coroutineScope.launch { - if (!launchCloudLibraryFile(item = item, file = file)) { + val resumeItem = WatchProgressRepository + .progressForVideo(item.playbackVideoId(file)) + ?.takeIf { it.isResumable } + ?.toContinueWatchingItem() + if ( + !launchCloudLibraryFile( + item = item, + file = file, + resumePositionMs = resumeItem?.resumePositionMs, + resumeProgressFraction = resumeItem?.resumeProgressFraction, + ) + ) { NuvioToastController.show(cloudLibraryPlayFailedText) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt index eea7f20c..751c0608 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt @@ -44,6 +44,13 @@ data class CloudLibraryPlaybackTarget( val file: CloudLibraryFile, ) +sealed interface CloudLibraryPlaybackTargetLookupResult { + data class Found(val target: CloudLibraryPlaybackTarget) : CloudLibraryPlaybackTargetLookupResult + data object Disabled : CloudLibraryPlaybackTargetLookupResult + data class NotConnected(val providerName: String? = null) : CloudLibraryPlaybackTargetLookupResult + data object NotFound : CloudLibraryPlaybackTargetLookupResult +} + const val CloudLibraryContentType = "cloud" const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg" const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg" @@ -57,7 +64,7 @@ fun CloudLibraryItem.providerPosterUrl(): String? = cloudLibraryProviderPosterUrl(providerId) fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? = - when (providerIdOrContentId.normalizedCloudLibraryProviderId()) { + when (cloudLibraryProviderId(providerIdOrContentId)) { "torbox" -> TorboxCloudLibraryPosterUrl "premiumize" -> PremiumizeCloudLibraryPosterUrl else -> null @@ -69,8 +76,8 @@ fun cloudLibraryDisplayArtworkUrl(url: String?): String? = else -> url?.trim() } -private fun String?.normalizedCloudLibraryProviderId(): String = - orEmpty() +fun cloudLibraryProviderId(providerIdOrContentId: String?): String = + providerIdOrContentId.orEmpty() .trim() .removePrefix("$CloudLibraryContentType:") .substringBefore(':') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt index 01543dfd..d5f7669d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -124,24 +124,58 @@ object CloudLibraryRepository { suspend fun findPlaybackTargetForProgress( contentId: String, videoId: String, - ): CloudLibraryPlaybackTarget? { + ): CloudLibraryPlaybackTarget? = + when (val result = findPlaybackTargetForProgressResult(contentId = contentId, videoId = videoId)) { + is CloudLibraryPlaybackTargetLookupResult.Found -> result.target + CloudLibraryPlaybackTargetLookupResult.Disabled, + is CloudLibraryPlaybackTargetLookupResult.NotConnected, + CloudLibraryPlaybackTargetLookupResult.NotFound, + -> null + } + + suspend fun findPlaybackTargetForProgressResult( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTargetLookupResult { DebridSettingsRepository.ensureLoaded() if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { loadedConnectionKeys = emptyList() _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) - return null + return CloudLibraryPlaybackTargetLookupResult.Disabled + } + + val providerId = cloudLibraryProviderId(contentId) + .ifBlank { cloudLibraryProviderId(videoId) } + val connectedCredentials = connectedCloudCredentials() + if (connectedCredentials.isEmpty()) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = providerId.takeIf { it.isNotBlank() }?.let(DebridProviders::displayName), + ) + } + if ( + providerId.isNotBlank() && + connectedCredentials.none { credential -> credential.provider.id.equals(providerId, ignoreCase = true) } + ) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = DebridProviders.displayName(providerId), + ) } _uiState.value.findPlaybackTargetForProgress( contentId = contentId, videoId = videoId, - )?.let { target -> return target } + )?.let { target -> return CloudLibraryPlaybackTargetLookupResult.Found(target) } val refreshed = refreshNow() - return refreshed.findPlaybackTargetForProgress( + val refreshedTarget = refreshed.findPlaybackTargetForProgress( contentId = contentId, videoId = videoId, ) + return if (refreshedTarget != null) { + CloudLibraryPlaybackTargetLookupResult.Found(refreshedTarget) + } else { + CloudLibraryPlaybackTargetLookupResult.NotFound + } } suspend fun resolvePlayback( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index a5da0fdc..030a0643 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -21,6 +21,10 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.findPlaybackTargetForProgress import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.nextReleasedEpisodeAfter import com.nuvio.app.features.home.components.HomeCatalogRowSection @@ -109,6 +113,7 @@ fun HomeScreen( val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() + val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val traktSettingsUiState by remember { TraktSettingsRepository.ensureLoaded() @@ -218,6 +223,12 @@ fun HomeScreen( effectiveWatchProgressEntries.continueWatchingEntries() } + LaunchedEffect(visibleContinueWatchingEntries) { + if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) { + CloudLibraryRepository.ensureLoaded() + } + } + val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { allNextUpSeedEntries .groupBy { entry -> entry.parentMetaId } @@ -348,6 +359,7 @@ fun HomeScreen( effectivNextUpItems, nextUpSuppressedSeriesIds, continueWatchingPreferences.sortMode, + cloudLibraryUiState, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, @@ -356,6 +368,7 @@ fun HomeScreen( nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), + cloudLibraryUiState = cloudLibraryUiState, ) } val availableManifests = remember(addonsUiState.addons) { @@ -911,6 +924,7 @@ internal fun buildHomeContinueWatchingItems( nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", + cloudLibraryUiState: CloudLibraryUiState? = null, ): List { val suppressedSeriesIds = nextUpSuppressedSeriesIds ?: visibleEntries @@ -926,7 +940,9 @@ internal fun buildHomeContinueWatchingItems( val liveItem = entry.toContinueWatchingItem() HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - item = liveItem.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]), + item = liveItem + .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) }, @@ -1166,9 +1182,16 @@ private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { if (fallback == null) return this + val fallbackTitle = fallback.title + .takeIf { it.isNotBlank() } + ?.takeUnless { fallback.hasPlaceholderCloudTitle() } return copy( - title = title.ifBlank { fallback.title }, + title = when { + title.isBlank() -> fallback.title + hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + else -> title + }, subtitle = subtitle.ifBlank { fallback.subtitle }, imageUrl = imageUrl ?: fallback.imageUrl, logo = logo ?: fallback.logo, @@ -1180,3 +1203,35 @@ private fun ContinueWatchingItem.withFallbackMetadata( released = released ?: fallback.released, ) } + +private fun ContinueWatchingItem.withCloudLibraryMetadata( + cloudLibraryUiState: CloudLibraryUiState?, +): ContinueWatchingItem { + if (!isCloudLibraryContinueWatchingItem() || cloudLibraryUiState == null) return this + val target = cloudLibraryUiState.findPlaybackTargetForProgress( + contentId = parentMetaId, + videoId = videoId, + ) ?: return this + val fileName = target.file.name.trim().takeIf { it.isNotBlank() } + ?: target.item.name.trim().takeIf { it.isNotBlank() } + ?: return this + return copy( + title = fileName, + pauseDescription = pauseDescription + ?: target.item.name.takeIf { itemName -> itemName.isNotBlank() && itemName != fileName }, + ) +} + +private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { + if (!isCloudLibraryContinueWatchingItem()) return false + val normalizedTitle = title.trim() + return normalizedTitle.equals(parentMetaId, ignoreCase = true) || + normalizedTitle.equals(videoId, ignoreCase = true) +} + +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + +private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = + contentType.equals(CloudLibraryContentType, ignoreCase = true) || + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb..7c96c5af 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -1,5 +1,12 @@ package com.nuvio.app.features.home +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryItemType +import com.nuvio.app.features.cloud.CloudLibraryProviderState +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL @@ -84,6 +91,45 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `build home continue watching items enriches cloud title from library file`() { + val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv") + val cloudItem = CloudLibraryItem( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "GOAT torrent", + files = listOf(file), + ) + val progress = WatchProgressEntry( + contentType = "cloud", + parentMetaId = cloudItem.stableKey, + parentMetaType = "cloud", + videoId = cloudItem.playbackVideoId(file), + title = cloudItem.stableKey, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 500L, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + nextUpItemsBySeries = emptyMap(), + cloudLibraryUiState = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = DebridProviders.Torbox, + items = listOf(cloudItem), + ), + ), + ), + ) + + assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry(