feat(cloud): passing watchprogress

This commit is contained in:
tapframe 2026-05-22 15:32:11 +05:30
parent 3d6d0fcfb4
commit 8fe059901c
6 changed files with 205 additions and 24 deletions

View file

@ -1345,8 +1345,11 @@
<string name="cloud_library_no_files_message">This item does not expose a playable video file.</string>
<string name="cloud_library_no_files_title">No playable files</string>
<string name="cloud_library_no_playable_files">No playable files</string>
<string name="cloud_library_play_disabled">Cloud library is off.</string>
<string name="cloud_library_play_failed">Couldn't play this cloud file.</string>
<string name="cloud_library_play_file">Play file</string>
<string name="cloud_library_play_not_connected">Cloud service is not connected.</string>
<string name="cloud_library_play_provider_not_connected">%1$s is not connected.</string>
<string name="cloud_library_playable_file_count">%1$d playable files</string>
<string name="cloud_library_provider_all">All</string>
<string name="cloud_library_refresh">Refresh cloud library</string>

View file

@ -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)
}
}

View file

@ -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(':')

View file

@ -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(

View file

@ -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<String>? = null,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
cloudLibraryUiState: CloudLibraryUiState? = null,
): List<ContinueWatchingItem> {
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)

View file

@ -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(