mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
feat(cloud): passing watchprogress
This commit is contained in:
parent
3d6d0fcfb4
commit
8fe059901c
6 changed files with 205 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(':')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue