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(