diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f60bdd76..864d3010 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,10 +106,13 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +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.CloudLibraryRepository +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.cloud.providerPosterUrl import com.nuvio.app.features.debrid.DirectDebridPlayableResult import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage @@ -842,6 +845,52 @@ private fun MainAppContent( } } + suspend fun launchCloudLibraryFile( + item: CloudLibraryItem, + file: CloudLibraryFile, + resumePositionMs: Long? = null, + resumeProgressFraction: Float? = null, + startFromBeginning: Boolean = false, + ): Boolean { + return when ( + val resolved = CloudLibraryRepository.resolvePlayback( + item = item, + file = file, + ) + ) { + is CloudLibraryPlaybackResult.Success -> { + val playbackTitle = resolved.filename + ?.takeIf { it.isNotBlank() } + ?: file.name.ifBlank { item.name } + val playerLaunch = PlayerLaunch( + title = playbackTitle, + sourceUrl = resolved.url, + streamTitle = playbackTitle, + streamSubtitle = item.name.takeIf { it != playbackTitle }, + providerName = item.providerName, + providerAddonId = "cloud:${item.providerId}", + poster = item.providerPosterUrl(), + contentType = CloudLibraryContentType, + videoId = item.playbackVideoId(file), + parentMetaId = item.stableKey, + parentMetaType = CloudLibraryContentType, + initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L), + initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + openExternalPlayback(playerLaunch) + true + } else { + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + true + } + } + + else -> false + } + } + fun launchPlaybackWithDownloadPreference( type: String, videoId: String, @@ -1012,25 +1061,46 @@ private fun MainAppContent( } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> - launchPlaybackWithDownloadPreference( - type = item.parentMetaType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - title = item.title, - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - pauseDescription = item.pauseDescription, - resumePositionMs = item.resumePositionMs, - resumeProgressFraction = item.resumeProgressFraction, - manualSelection = manualSelection, - startFromBeginning = 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, + ) + } == true + if (!launched) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + } else { + launchPlaybackWithDownloadPreference( + type = item.parentMetaType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + title = item.title, + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + manualSelection = manualSelection, + startFromBeginning = startFromBeginning, + ) + } } val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> @@ -1165,36 +1235,8 @@ private fun MainAppContent( onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onCloudFilePlay = { item, file -> coroutineScope.launch { - when ( - val resolved = CloudLibraryRepository.resolvePlayback( - item = item, - file = file, - ) - ) { - is CloudLibraryPlaybackResult.Success -> { - val playbackTitle = file.name.ifBlank { item.name } - val playerLaunch = PlayerLaunch( - title = playbackTitle, - sourceUrl = resolved.url, - streamTitle = playbackTitle, - streamSubtitle = item.name.takeIf { it != playbackTitle }, - providerName = item.providerName, - providerAddonId = "cloud:${item.providerId}", - contentType = "cloud", - videoId = "${item.stableKey}:${file.stableKey}", - parentMetaId = item.stableKey, - parentMetaType = "cloud", - ) - if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - return@launch - } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) - } - else -> { - NuvioToastController.show(cloudLibraryPlayFailedText) - } + if (!launchCloudLibraryFile(item = item, file = file)) { + NuvioToastController.show(cloudLibraryPlayFailedText) } } }, @@ -2161,6 +2203,7 @@ private fun MainAppContent( NuvioContinueWatchingActionSheet( item = selectedContinueWatchingForActions, showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState), + showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true, onDismiss = { selectedContinueWatchingForActions = null }, onOpenDetails = { selectedContinueWatchingForActions?.let { item -> @@ -2540,6 +2583,9 @@ private fun TabletFloatingTopBar( } } +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + @Composable private fun TabletTopPillItem( label: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt index 8c122e2c..f99e90b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.ui import androidx.compose.runtime.Composable +import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.watchprogress.ContinueWatchingItem import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -18,6 +19,8 @@ fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String { stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index b85173d3..693017d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.watchprogress.ContinueWatchingItem import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -42,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource fun NuvioContinueWatchingActionSheet( item: ContinueWatchingItem?, showManualPlayOption: Boolean, + showDetailsOption: Boolean = true, onDismiss: () -> Unit, onOpenDetails: () -> Unit, onStartFromBeginning: (() -> Unit)? = null, @@ -73,12 +76,14 @@ fun NuvioContinueWatchingActionSheet( .padding(bottom = nuvioSafeBottomPadding(16.dp)), ) { ContinueWatchingSheetHeader(item = item) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Default.Info, - title = stringResource(Res.string.cw_action_go_to_details), - onClick = { dismissAfter(onOpenDetails) }, - ) + if (showDetailsOption) { + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.Info, + title = stringResource(Res.string.cw_action_go_to_details), + onClick = { dismissAfter(onOpenDetails) }, + ) + } if (showManualPlayOption && onPlayManually != null) { NuvioBottomSheetDivider() NuvioBottomSheetActionRow( @@ -128,10 +133,10 @@ private fun ContinueWatchingSheetHeader( val artwork = item.poster ?: item.imageUrl if (artwork != null) { AsyncImage( - model = artwork, + model = cloudLibraryDisplayArtworkUrl(artwork), contentDescription = item.title, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } else { Text( @@ -167,3 +172,6 @@ private fun ContinueWatchingSheetHeader( } } } + +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) 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 dfdbcdfd..eea7f20c 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 @@ -39,6 +39,43 @@ data class CloudLibraryItem( get() = files.filter { it.playable } } +data class CloudLibraryPlaybackTarget( + val item: CloudLibraryItem, + val file: CloudLibraryFile, +) + +const val CloudLibraryContentType = "cloud" +const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg" +const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg" +private const val TorboxCloudLibraryPosterDataUrl = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM2NyAzMDggNzY2IDg4NCI+PHBvbHlnb24gZmlsbD0iIzAwNDQ0RCIgcG9pbnRzPSI3NDkuOTksNzQ5Ljk5IDc0OS45OSwxMTkxLjk2IDM2Ny4yNSw5NzAuOTcgMzY3LjI1LDUyOS4wMSIvPjxwb2x5Z29uIGZpbGw9IiMzNEJBOTAiIHBvaW50cz0iMTEzMi43NSw1MjkuMDEgMTEzMi43NSw5NzAuOTcgNzQ5Ljk5LDExOTEuOTYgNzQ5Ljk5LDc0OS45OSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYiLz48cG9seWdvbiBmaWxsPSIjNTJBMTUzIiBwb2ludHM9IjExMzIuNzUsNTI5LjAxIDc0OS45OSw3NDkuOTkgMzY3LjI1LDUyOS4wMSA3NDkuOTksMzA4LjA0Ii8+PHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIxMDQzLjA0LDczOS4zNiA5NTguNjYsMTA1Ny4wOCA5NTIuNCw4NTEuODQgODM5LjcxLDkxNS4zOSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYgOTMxLjgxLDc5OS4yMSIvPjwvc3ZnPg==" + +fun CloudLibraryItem.playbackVideoId(file: CloudLibraryFile): String = + "$stableKey:${file.stableKey}" + +fun CloudLibraryItem.providerPosterUrl(): String? = + cloudLibraryProviderPosterUrl(providerId) + +fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? = + when (providerIdOrContentId.normalizedCloudLibraryProviderId()) { + "torbox" -> TorboxCloudLibraryPosterUrl + "premiumize" -> PremiumizeCloudLibraryPosterUrl + else -> null + } + +fun cloudLibraryDisplayArtworkUrl(url: String?): String? = + when (url?.trim()) { + TorboxCloudLibraryPosterUrl -> TorboxCloudLibraryPosterDataUrl + else -> url?.trim() + } + +private fun String?.normalizedCloudLibraryProviderId(): String = + orEmpty() + .trim() + .removePrefix("$CloudLibraryContentType:") + .substringBefore(':') + .lowercase() + data class CloudLibraryProviderState( val provider: DebridProvider, val isLoading: Boolean = false, 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 c9eaa43c..01543dfd 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 @@ -121,6 +121,29 @@ object CloudLibraryRepository { } } + suspend fun findPlaybackTargetForProgress( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTarget? { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return null + } + + _uiState.value.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + )?.let { target -> return target } + + val refreshed = refreshNow() + return refreshed.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + ) + } + suspend fun resolvePlayback( item: CloudLibraryItem, file: CloudLibraryFile, @@ -147,8 +170,47 @@ object CloudLibraryRepository { ) }.sortedBy { it.providerId } + private suspend fun refreshNow(): CloudLibraryUiState { + _uiState.update { current -> + current.copy( + isEnabled = true, + isRefreshing = true, + providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, + ) + } + val refreshed = store.refresh() + loadedConnectionKeys = connectedCloudConnectionKeys() + _uiState.value = refreshed + return refreshed + } + private data class CloudConnectionKey( val providerId: String, val apiKeyHash: Int, ) } + +internal fun CloudLibraryUiState.findPlaybackTargetForProgress( + contentId: String, + videoId: String, +): CloudLibraryPlaybackTarget? { + val normalizedContentId = contentId.trim() + val normalizedVideoId = videoId.trim() + if (normalizedContentId.isBlank()) return null + + val matchingItems = items.filter { item -> item.stableKey == normalizedContentId } + if (matchingItems.isEmpty()) return null + + for (item in matchingItems) { + val exactFile = item.playableFiles.firstOrNull { file -> + item.playbackVideoId(file) == normalizedVideoId + } + if (exactFile != null) { + return CloudLibraryPlaybackTarget(item = item, file = exactFile) + } + } + + val singleItem = matchingItems.singleOrNull() ?: return null + val singleFile = singleItem.playableFiles.singleOrNull() ?: return null + return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 6037ce7b..804a5e22 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -45,6 +45,8 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle @@ -64,10 +66,15 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.isCloudLibraryItem() -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + private fun ContinueWatchingItem.continueWatchingArtworkUrl( useEpisodeThumbnails: Boolean, ): String? = when { @@ -392,6 +399,7 @@ private fun ContinueWatchingWideCard( imageUrl = artworkUrl, width = layout.widePosterStripWidth, blurred = shouldBlurArtwork, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, modifier = Modifier.fillMaxHeight(), ) Column( @@ -504,12 +512,12 @@ private fun ContinueWatchingPosterCard( val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = item.title, modifier = Modifier .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { @@ -589,6 +597,7 @@ private fun ArtworkPanel( imageUrl: String?, width: Dp, blurred: Boolean = false, + contentScale: ContentScale = ContentScale.Crop, modifier: Modifier = Modifier, ) { Box( @@ -598,12 +607,12 @@ private fun ArtworkPanel( ) { if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = null, modifier = Modifier .fillMaxSize() .then(if (blurred) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = contentScale, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 9fb84629..7783f353 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable @@ -199,6 +201,7 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -213,9 +216,9 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, + imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, logo = normalizedEntry.logo, - poster = normalizedEntry.poster, + poster = normalizedEntry.poster ?: cloudPosterUrl, background = normalizedEntry.background, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, @@ -233,6 +236,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { ) } +private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? { + if (!contentType.equals(CloudLibraryContentType, ignoreCase = true) && + !parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + ) { + return null + } + return cloudLibraryProviderPosterUrl(parentMetaId) + ?: cloudLibraryProviderPosterUrl(providerAddonId) +} + internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt index 297daf49..5610846a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -6,6 +6,7 @@ import com.nuvio.app.features.debrid.DebridServiceCredential import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class CloudLibraryStoreTest { @@ -68,6 +69,86 @@ class CloudLibraryStoreTest { assertEquals(listOf("cloud"), state.providers.map { it.providerId }) assertEquals(listOf("cloud-item"), state.items.map { it.id }) } + + @Test + fun `playback target lookup matches cloud watch progress video id`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = CloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "Torrent", + files = listOf( + CloudLibraryFile(id = "7", name = "sample.mkv", playable = true), + CloudLibraryFile(id = "8", name = "movie.mkv", playable = true), + ), + ) + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = "torbox:Torrent:29773238", + videoId = "torbox:Torrent:29773238:8", + ), + ) + + assertEquals(item, target.item) + assertEquals("8", target.file.id) + } + + @Test + fun `playback target lookup falls back to single playable file`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = cloudItem(provider, "29773238") + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = item.stableKey, + videoId = item.stableKey, + ), + ) + + assertEquals(item, target.item) + assertEquals(item.playableFiles.single(), target.file) + } + + @Test + fun `provider poster urls are mapped for cloud services`() { + assertEquals( + TorboxCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("torbox:Torrent:29773238"), + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("cloud:premiumize"), + ) + assertTrue( + cloudLibraryDisplayArtworkUrl(TorboxCloudLibraryPosterUrl) + ?.startsWith("data:image/svg+xml;base64,") == true, + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryDisplayArtworkUrl(PremiumizeCloudLibraryPosterUrl), + ) + } } private class FakeCloudProviderApi( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index b6112ba8..883e47e6 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo import kotlin.test.Test import kotlin.test.assertEquals @@ -95,6 +96,23 @@ class WatchProgressRulesTest { assertEquals(2, result.size) } + @Test + fun `cloud continue watching uses provider poster fallback`() { + val item = WatchProgressEntry( + contentType = "cloud", + parentMetaId = "torbox:Torrent:29773238", + parentMetaType = "cloud", + videoId = "torbox:Torrent:29773238:8", + title = "Cloud file", + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 1L, + ).toContinueWatchingItem() + + assertEquals(TorboxCloudLibraryPosterUrl, item.poster) + assertEquals(TorboxCloudLibraryPosterUrl, item.imageUrl) + } + @Test fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() { val completedByPercent = entry(