Skip to content

Commit 8fe0599

Browse files
committed
feat(cloud): passing watchprogress
1 parent 3d6d0fc commit 8fe0599

6 files changed

Lines changed: 205 additions & 24 deletions

File tree

composeApp/src/commonMain/composeResources/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,8 +1345,11 @@
13451345
<string name="cloud_library_no_files_message">This item does not expose a playable video file.</string>
13461346
<string name="cloud_library_no_files_title">No playable files</string>
13471347
<string name="cloud_library_no_playable_files">No playable files</string>
1348+
<string name="cloud_library_play_disabled">Cloud library is off.</string>
13481349
<string name="cloud_library_play_failed">Couldn't play this cloud file.</string>
13491350
<string name="cloud_library_play_file">Play file</string>
1351+
<string name="cloud_library_play_not_connected">Cloud service is not connected.</string>
1352+
<string name="cloud_library_play_provider_not_connected">%1$s is not connected.</string>
13501353
<string name="cloud_library_playable_file_count">%1$d playable files</string>
13511354
<string name="cloud_library_provider_all">All</string>
13521355
<string name="cloud_library_refresh">Refresh cloud library</string>

composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType
110110
import com.nuvio.app.features.cloud.CloudLibraryFile
111111
import com.nuvio.app.features.cloud.CloudLibraryItem
112112
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
113+
import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult
113114
import com.nuvio.app.features.cloud.CloudLibraryRepository
114115
import com.nuvio.app.features.cloud.playbackVideoId
115116
import com.nuvio.app.features.cloud.providerPosterUrl
@@ -187,6 +188,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
187188
import com.nuvio.app.features.watchprogress.ResumePromptRepository
188189
import com.nuvio.app.features.watchprogress.WatchProgressRepository
189190
import com.nuvio.app.features.watchprogress.nextUpDismissKey
191+
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
190192
import com.nuvio.app.features.watching.application.WatchingActions
191193
import com.nuvio.app.features.watching.application.WatchingState
192194
import kotlinx.coroutines.flow.Flow
@@ -610,6 +612,8 @@ private fun MainAppContent(
610612
val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
611613
val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
612614
val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed)
615+
val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled)
616+
val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected)
613617
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
614618
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
615619
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@@ -1063,21 +1067,42 @@ private fun MainAppContent(
10631067
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
10641068
if (item.isCloudLibraryContinueWatchingItem()) {
10651069
coroutineScope.launch {
1066-
val target = CloudLibraryRepository.findPlaybackTargetForProgress(
1067-
contentId = item.parentMetaId,
1068-
videoId = item.videoId,
1069-
)
1070-
val launched = target?.let { playbackTarget ->
1071-
launchCloudLibraryFile(
1072-
item = playbackTarget.item,
1073-
file = playbackTarget.file,
1074-
resumePositionMs = item.resumePositionMs,
1075-
resumeProgressFraction = item.resumeProgressFraction,
1076-
startFromBeginning = startFromBeginning,
1070+
when (
1071+
val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult(
1072+
contentId = item.parentMetaId,
1073+
videoId = item.videoId,
10771074
)
1078-
} == true
1079-
if (!launched) {
1080-
NuvioToastController.show(cloudLibraryPlayFailedText)
1075+
) {
1076+
is CloudLibraryPlaybackTargetLookupResult.Found -> {
1077+
val launched = launchCloudLibraryFile(
1078+
item = lookup.target.item,
1079+
file = lookup.target.file,
1080+
resumePositionMs = item.resumePositionMs,
1081+
resumeProgressFraction = item.resumeProgressFraction,
1082+
startFromBeginning = startFromBeginning,
1083+
)
1084+
if (!launched) {
1085+
NuvioToastController.show(cloudLibraryPlayFailedText)
1086+
}
1087+
}
1088+
1089+
CloudLibraryPlaybackTargetLookupResult.Disabled -> {
1090+
NuvioToastController.show(cloudLibraryPlayDisabledText)
1091+
}
1092+
1093+
is CloudLibraryPlaybackTargetLookupResult.NotConnected -> {
1094+
val providerName = lookup.providerName?.takeIf { it.isNotBlank() }
1095+
NuvioToastController.show(
1096+
providerName?.let { name ->
1097+
getString(Res.string.cloud_library_play_provider_not_connected, name)
1098+
}
1099+
?: cloudLibraryPlayNotConnectedText,
1100+
)
1101+
}
1102+
1103+
CloudLibraryPlaybackTargetLookupResult.NotFound -> {
1104+
NuvioToastController.show(cloudLibraryPlayFailedText)
1105+
}
10811106
}
10821107
}
10831108
} else {
@@ -1235,7 +1260,18 @@ private fun MainAppContent(
12351260
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
12361261
onCloudFilePlay = { item, file ->
12371262
coroutineScope.launch {
1238-
if (!launchCloudLibraryFile(item = item, file = file)) {
1263+
val resumeItem = WatchProgressRepository
1264+
.progressForVideo(item.playbackVideoId(file))
1265+
?.takeIf { it.isResumable }
1266+
?.toContinueWatchingItem()
1267+
if (
1268+
!launchCloudLibraryFile(
1269+
item = item,
1270+
file = file,
1271+
resumePositionMs = resumeItem?.resumePositionMs,
1272+
resumeProgressFraction = resumeItem?.resumeProgressFraction,
1273+
)
1274+
) {
12391275
NuvioToastController.show(cloudLibraryPlayFailedText)
12401276
}
12411277
}

composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ data class CloudLibraryPlaybackTarget(
4444
val file: CloudLibraryFile,
4545
)
4646

47+
sealed interface CloudLibraryPlaybackTargetLookupResult {
48+
data class Found(val target: CloudLibraryPlaybackTarget) : CloudLibraryPlaybackTargetLookupResult
49+
data object Disabled : CloudLibraryPlaybackTargetLookupResult
50+
data class NotConnected(val providerName: String? = null) : CloudLibraryPlaybackTargetLookupResult
51+
data object NotFound : CloudLibraryPlaybackTargetLookupResult
52+
}
53+
4754
const val CloudLibraryContentType = "cloud"
4855
const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg"
4956
const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg"
@@ -57,7 +64,7 @@ fun CloudLibraryItem.providerPosterUrl(): String? =
5764
cloudLibraryProviderPosterUrl(providerId)
5865

5966
fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? =
60-
when (providerIdOrContentId.normalizedCloudLibraryProviderId()) {
67+
when (cloudLibraryProviderId(providerIdOrContentId)) {
6168
"torbox" -> TorboxCloudLibraryPosterUrl
6269
"premiumize" -> PremiumizeCloudLibraryPosterUrl
6370
else -> null
@@ -69,8 +76,8 @@ fun cloudLibraryDisplayArtworkUrl(url: String?): String? =
6976
else -> url?.trim()
7077
}
7178

72-
private fun String?.normalizedCloudLibraryProviderId(): String =
73-
orEmpty()
79+
fun cloudLibraryProviderId(providerIdOrContentId: String?): String =
80+
providerIdOrContentId.orEmpty()
7481
.trim()
7582
.removePrefix("$CloudLibraryContentType:")
7683
.substringBefore(':')

composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,24 +124,58 @@ object CloudLibraryRepository {
124124
suspend fun findPlaybackTargetForProgress(
125125
contentId: String,
126126
videoId: String,
127-
): CloudLibraryPlaybackTarget? {
127+
): CloudLibraryPlaybackTarget? =
128+
when (val result = findPlaybackTargetForProgressResult(contentId = contentId, videoId = videoId)) {
129+
is CloudLibraryPlaybackTargetLookupResult.Found -> result.target
130+
CloudLibraryPlaybackTargetLookupResult.Disabled,
131+
is CloudLibraryPlaybackTargetLookupResult.NotConnected,
132+
CloudLibraryPlaybackTargetLookupResult.NotFound,
133+
-> null
134+
}
135+
136+
suspend fun findPlaybackTargetForProgressResult(
137+
contentId: String,
138+
videoId: String,
139+
): CloudLibraryPlaybackTargetLookupResult {
128140
DebridSettingsRepository.ensureLoaded()
129141
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
130142
loadedConnectionKeys = emptyList()
131143
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
132-
return null
144+
return CloudLibraryPlaybackTargetLookupResult.Disabled
145+
}
146+
147+
val providerId = cloudLibraryProviderId(contentId)
148+
.ifBlank { cloudLibraryProviderId(videoId) }
149+
val connectedCredentials = connectedCloudCredentials()
150+
if (connectedCredentials.isEmpty()) {
151+
return CloudLibraryPlaybackTargetLookupResult.NotConnected(
152+
providerName = providerId.takeIf { it.isNotBlank() }?.let(DebridProviders::displayName),
153+
)
154+
}
155+
if (
156+
providerId.isNotBlank() &&
157+
connectedCredentials.none { credential -> credential.provider.id.equals(providerId, ignoreCase = true) }
158+
) {
159+
return CloudLibraryPlaybackTargetLookupResult.NotConnected(
160+
providerName = DebridProviders.displayName(providerId),
161+
)
133162
}
134163

135164
_uiState.value.findPlaybackTargetForProgress(
136165
contentId = contentId,
137166
videoId = videoId,
138-
)?.let { target -> return target }
167+
)?.let { target -> return CloudLibraryPlaybackTargetLookupResult.Found(target) }
139168

140169
val refreshed = refreshNow()
141-
return refreshed.findPlaybackTargetForProgress(
170+
val refreshedTarget = refreshed.findPlaybackTargetForProgress(
142171
contentId = contentId,
143172
videoId = videoId,
144173
)
174+
return if (refreshedTarget != null) {
175+
CloudLibraryPlaybackTargetLookupResult.Found(refreshedTarget)
176+
} else {
177+
CloudLibraryPlaybackTargetLookupResult.NotFound
178+
}
145179
}
146180

147181
suspend fun resolvePlayback(

composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import com.nuvio.app.core.ui.NuvioScreen
2121
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
2222
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
2323
import com.nuvio.app.features.addons.AddonRepository
24+
import com.nuvio.app.features.cloud.CloudLibraryContentType
25+
import com.nuvio.app.features.cloud.CloudLibraryRepository
26+
import com.nuvio.app.features.cloud.CloudLibraryUiState
27+
import com.nuvio.app.features.cloud.findPlaybackTargetForProgress
2428
import com.nuvio.app.features.details.MetaDetailsRepository
2529
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
2630
import com.nuvio.app.features.home.components.HomeCatalogRowSection
@@ -109,6 +113,7 @@ fun HomeScreen(
109113
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
110114
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
111115
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
116+
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
112117
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
113118
val traktSettingsUiState by remember {
114119
TraktSettingsRepository.ensureLoaded()
@@ -218,6 +223,12 @@ fun HomeScreen(
218223
effectiveWatchProgressEntries.continueWatchingEntries()
219224
}
220225

226+
LaunchedEffect(visibleContinueWatchingEntries) {
227+
if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) {
228+
CloudLibraryRepository.ensureLoaded()
229+
}
230+
}
231+
221232
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
222233
allNextUpSeedEntries
223234
.groupBy { entry -> entry.parentMetaId }
@@ -348,6 +359,7 @@ fun HomeScreen(
348359
effectivNextUpItems,
349360
nextUpSuppressedSeriesIds,
350361
continueWatchingPreferences.sortMode,
362+
cloudLibraryUiState,
351363
) {
352364
buildHomeContinueWatchingItems(
353365
visibleEntries = visibleContinueWatchingEntries,
@@ -356,6 +368,7 @@ fun HomeScreen(
356368
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
357369
sortMode = continueWatchingPreferences.sortMode,
358370
todayIsoDate = CurrentDateProvider.todayIsoDate(),
371+
cloudLibraryUiState = cloudLibraryUiState,
359372
)
360373
}
361374
val availableManifests = remember(addonsUiState.addons) {
@@ -911,6 +924,7 @@ internal fun buildHomeContinueWatchingItems(
911924
nextUpSuppressedSeriesIds: Set<String>? = null,
912925
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
913926
todayIsoDate: String = "",
927+
cloudLibraryUiState: CloudLibraryUiState? = null,
914928
): List<ContinueWatchingItem> {
915929
val suppressedSeriesIds = nextUpSuppressedSeriesIds
916930
?: visibleEntries
@@ -926,7 +940,9 @@ internal fun buildHomeContinueWatchingItems(
926940
val liveItem = entry.toContinueWatchingItem()
927941
HomeContinueWatchingCandidate(
928942
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
929-
item = liveItem.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]),
943+
item = liveItem
944+
.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId])
945+
.withCloudLibraryMetadata(cloudLibraryUiState),
930946
isProgressEntry = true,
931947
)
932948
},
@@ -1166,9 +1182,16 @@ private fun ContinueWatchingItem.withFallbackMetadata(
11661182
fallback: ContinueWatchingItem?,
11671183
): ContinueWatchingItem {
11681184
if (fallback == null) return this
1185+
val fallbackTitle = fallback.title
1186+
.takeIf { it.isNotBlank() }
1187+
?.takeUnless { fallback.hasPlaceholderCloudTitle() }
11691188

11701189
return copy(
1171-
title = title.ifBlank { fallback.title },
1190+
title = when {
1191+
title.isBlank() -> fallback.title
1192+
hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle
1193+
else -> title
1194+
},
11721195
subtitle = subtitle.ifBlank { fallback.subtitle },
11731196
imageUrl = imageUrl ?: fallback.imageUrl,
11741197
logo = logo ?: fallback.logo,
@@ -1180,3 +1203,35 @@ private fun ContinueWatchingItem.withFallbackMetadata(
11801203
released = released ?: fallback.released,
11811204
)
11821205
}
1206+
1207+
private fun ContinueWatchingItem.withCloudLibraryMetadata(
1208+
cloudLibraryUiState: CloudLibraryUiState?,
1209+
): ContinueWatchingItem {
1210+
if (!isCloudLibraryContinueWatchingItem() || cloudLibraryUiState == null) return this
1211+
val target = cloudLibraryUiState.findPlaybackTargetForProgress(
1212+
contentId = parentMetaId,
1213+
videoId = videoId,
1214+
) ?: return this
1215+
val fileName = target.file.name.trim().takeIf { it.isNotBlank() }
1216+
?: target.item.name.trim().takeIf { it.isNotBlank() }
1217+
?: return this
1218+
return copy(
1219+
title = fileName,
1220+
pauseDescription = pauseDescription
1221+
?: target.item.name.takeIf { itemName -> itemName.isNotBlank() && itemName != fileName },
1222+
)
1223+
}
1224+
1225+
private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean {
1226+
if (!isCloudLibraryContinueWatchingItem()) return false
1227+
val normalizedTitle = title.trim()
1228+
return normalizedTitle.equals(parentMetaId, ignoreCase = true) ||
1229+
normalizedTitle.equals(videoId, ignoreCase = true)
1230+
}
1231+
1232+
private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
1233+
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
1234+
1235+
private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean =
1236+
contentType.equals(CloudLibraryContentType, ignoreCase = true) ||
1237+
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)

composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
package com.nuvio.app.features.home
22

3+
import com.nuvio.app.features.cloud.CloudLibraryFile
4+
import com.nuvio.app.features.cloud.CloudLibraryItem
5+
import com.nuvio.app.features.cloud.CloudLibraryItemType
6+
import com.nuvio.app.features.cloud.CloudLibraryProviderState
7+
import com.nuvio.app.features.cloud.CloudLibraryUiState
8+
import com.nuvio.app.features.cloud.playbackVideoId
9+
import com.nuvio.app.features.debrid.DebridProviders
310
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
411
import com.nuvio.app.features.watchprogress.WatchProgressEntry
512
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
@@ -84,6 +91,45 @@ class HomeScreenTest {
8491
assertEquals("S1E4 • Current", result.single().subtitle)
8592
}
8693

94+
@Test
95+
fun `build home continue watching items enriches cloud title from library file`() {
96+
val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv")
97+
val cloudItem = CloudLibraryItem(
98+
providerId = DebridProviders.TORBOX_ID,
99+
providerName = DebridProviders.Torbox.displayName,
100+
id = "29773238",
101+
type = CloudLibraryItemType.Torrent,
102+
name = "GOAT torrent",
103+
files = listOf(file),
104+
)
105+
val progress = WatchProgressEntry(
106+
contentType = "cloud",
107+
parentMetaId = cloudItem.stableKey,
108+
parentMetaType = "cloud",
109+
videoId = cloudItem.playbackVideoId(file),
110+
title = cloudItem.stableKey,
111+
lastPositionMs = 120_000L,
112+
durationMs = 1_000_000L,
113+
lastUpdatedEpochMs = 500L,
114+
)
115+
116+
val result = buildHomeContinueWatchingItems(
117+
visibleEntries = listOf(progress),
118+
nextUpItemsBySeries = emptyMap(),
119+
cloudLibraryUiState = CloudLibraryUiState(
120+
isLoaded = true,
121+
providers = listOf(
122+
CloudLibraryProviderState(
123+
provider = DebridProviders.Torbox,
124+
items = listOf(cloudItem),
125+
),
126+
),
127+
),
128+
)
129+
130+
assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title)
131+
}
132+
87133
@Test
88134
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
89135
val oldEntry = progressEntry(

0 commit comments

Comments
 (0)