From 5934329b66a5afe911d5c8843b01a5b7191e8bd5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:15:18 +0530 Subject: [PATCH] fix: use videoID for stream url generation, trakt percentage to duration mapping --- .../commonMain/kotlin/com/nuvio/app/App.kt | 95 ++++++++++++++++--- .../app/features/details/MetaDetailsScreen.kt | 33 +++++-- .../details/components/DetailMetaInfo.kt | 27 ++++-- .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 42 +++++++- .../app/features/streams/StreamsScreen.kt | 63 ++++++++++-- .../features/streams/StreamsTabletLayout.kt | 8 +- .../features/trakt/TraktProgressRepository.kt | 73 ++++++++++---- .../watchprogress/WatchProgressModels.kt | 23 ++++- 9 files changed, 296 insertions(+), 69 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 8627f530..523ffdec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -155,6 +155,7 @@ data class StreamRoute( val episodeThumbnail: String? = null, val streamContextId: Long? = null, val resumePositionMs: Long? = null, + val resumeProgressFraction: Float? = null, ) @Serializable @@ -335,6 +336,7 @@ private fun MainAppContent( episodeThumbnail = episodeThumbnail, streamContextId = streamContextId, resumePositionMs = resumePositionMs, + resumeProgressFraction = null, ) ) } @@ -389,6 +391,7 @@ private fun MainAppContent( episodeThumbnail = item.episodeThumbnail, streamContextId = streamContextId, resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, ), ) } @@ -559,6 +562,59 @@ private fun MainAppContent( StreamContextStore.get(contextId)?.pauseDescription } } + val shouldResolveEpisodeVideoId = + route.type == "series" && + route.parentMetaId != null && + route.seasonNumber != null && + route.episodeNumber != null + var effectiveVideoId by rememberSaveable( + route.videoId, + route.parentMetaId, + route.seasonNumber, + route.episodeNumber, + ) { + mutableStateOf(route.videoId) + } + var hasResolvedVideoId by rememberSaveable( + route.videoId, + route.parentMetaId, + route.seasonNumber, + route.episodeNumber, + ) { + mutableStateOf(!shouldResolveEpisodeVideoId) + } + + LaunchedEffect( + route.videoId, + route.parentMetaId, + route.parentMetaType, + route.type, + route.seasonNumber, + route.episodeNumber, + ) { + effectiveVideoId = route.videoId + if (!shouldResolveEpisodeVideoId) { + hasResolvedVideoId = true + return@LaunchedEffect + } + + hasResolvedVideoId = false + val metaType = route.parentMetaType ?: route.type + val metaId = route.parentMetaId ?: return@LaunchedEffect + val resolvedVideoId = runCatching { + MetaDetailsRepository.fetch(metaType, metaId) + }.getOrNull() + ?.videos + ?.firstOrNull { video -> + video.season == route.seasonNumber && + video.episode == route.episodeNumber + } + ?.id + ?.takeIf { it.isNotBlank() } + + effectiveVideoId = resolvedVideoId ?: route.videoId + hasResolvedVideoId = true + } val playerSettings by remember { PlayerSettingsRepository.ensureLoaded() @@ -566,12 +622,13 @@ private fun MainAppContent( }.collectAsStateWithLifecycle() // Reuse Last Link: auto-play from cache if enabled (only on first entry) - var reuseHandled by rememberSaveable(route.videoId) { mutableStateOf(false) } - LaunchedEffect(route.videoId, playerSettings.streamReuseLastLinkEnabled) { + var reuseHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } + LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled) { + if (!hasResolvedVideoId) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect reuseHandled = true if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect - val cacheKey = StreamLinkCacheRepository.contentKey(route.type, route.videoId) + val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { @@ -592,10 +649,11 @@ private fun MainAppContent( providerName = cached.addonName, providerAddonId = cached.addonId, contentType = route.type, - videoId = route.videoId, - parentMetaId = route.parentMetaId ?: route.videoId, + videoId = effectiveVideoId, + parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, initialPositionMs = route.resumePositionMs ?: 0L, + initialProgressFraction = route.resumeProgressFraction, ) ) route.streamContextId?.let(StreamContextStore::remove) @@ -605,10 +663,20 @@ private fun MainAppContent( } } + if (!hasResolvedVideoId) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + return@composable + } + StreamsScreen( type = route.type, - videoId = route.videoId, - parentMetaId = route.parentMetaId ?: route.videoId, + videoId = effectiveVideoId, + parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, title = route.title, logo = route.logo, @@ -619,12 +687,13 @@ private fun MainAppContent( episodeTitle = route.episodeTitle, episodeThumbnail = route.episodeThumbnail, resumePositionMs = route.resumePositionMs, - onStreamSelected = { stream -> + resumeProgressFraction = route.resumeProgressFraction, + onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> val sourceUrl = stream.directPlaybackUrl if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(route.type, route.videoId) + val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -652,10 +721,11 @@ private fun MainAppContent( providerName = stream.addonName, providerAddonId = stream.addonId, contentType = route.type, - videoId = route.videoId, - parentMetaId = route.parentMetaId ?: route.videoId, + videoId = effectiveVideoId, + parentMetaId = route.parentMetaId ?: effectiveVideoId, parentMetaType = route.parentMetaType ?: route.type, - initialPositionMs = route.resumePositionMs ?: 0L, + initialPositionMs = resolvedResumePositionMs ?: 0L, + initialProgressFraction = resolvedResumeProgressFraction, ) ) route.streamContextId?.let(StreamContextStore::remove) @@ -703,6 +773,7 @@ private fun MainAppContent( parentMetaId = launch.parentMetaId, parentMetaType = launch.parentMetaType, initialPositionMs = launch.initialPositionMs, + initialProgressFraction = launch.initialProgressFraction, onBack = { PlayerLaunchStore.remove(route.launchId) navController.popBackStack() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 7369fed1..7e9f3461 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -214,16 +214,28 @@ fun MetaDetailsScreen( preferFurthestEpisode = cwPrefs.upNextFromFurthestEpisode, ) } - val seriesPauseDescription = remember(seriesAction, meta.id, meta.videos) { + val seriesActionVideo = remember(seriesAction, meta.id, meta.videos) { val action = seriesAction ?: return@remember null meta.videos.firstOrNull { video -> - buildPlaybackVideoId( - parentMetaId = meta.id, - seasonNumber = video.season, - episodeNumber = video.episode, - fallbackVideoId = video.id, - ) == action.videoId - }?.overview + if (action.seasonNumber != null && action.episodeNumber != null) { + video.season == action.seasonNumber && + video.episode == action.episodeNumber + } else { + buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = video.season, + episodeNumber = video.episode, + fallbackVideoId = video.id, + ) == action.videoId || video.id == action.videoId + } + } + } + val seriesPauseDescription = remember(seriesActionVideo) { + seriesActionVideo?.overview + } + val seriesStreamVideoId = remember(seriesAction, seriesActionVideo) { + val action = seriesAction ?: return@remember null + seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } val hasProductionSection = remember(meta) { @@ -346,7 +358,7 @@ fun MetaDetailsScreen( (meta.type == "series" || hasEpisodes) && seriesAction != null -> { onPlay?.invoke( meta.type, - seriesAction.videoId, + seriesStreamVideoId ?: seriesAction.videoId, meta.id, meta.type, meta.name, @@ -417,11 +429,12 @@ fun MetaDetailsScreen( episodeNumber = episode, fallbackVideoId = video.id, ) + val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId val savedProgress = watchProgressUiState.byVideoId[playbackVideoId] ?.takeUnless { it.isCompleted } onPlay?.invoke( meta.type, - playbackVideoId, + streamVideoId, meta.id, meta.type, meta.name, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 5beaefd6..5c95eb6f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.details.components +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border @@ -149,7 +150,10 @@ fun DetailMetaInfo( if (!meta.description.isNullOrBlank()) { var expanded by remember { mutableStateOf(false) } - Column { + var canExpand by remember(meta.description) { mutableStateOf(false) } + Column( + modifier = Modifier.animateContentSize(), + ) { Text( text = meta.description, style = MaterialTheme.typography.bodyMedium, @@ -157,14 +161,21 @@ fun DetailMetaInfo( maxLines = if (expanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, + onTextLayout = { result -> + if (!expanded) { + canExpand = result.hasVisualOverflow + } + }, ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = if (expanded) "Show Less" else "Show More ▾", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable { expanded = !expanded }, - ) + if (canExpand) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (expanded) "Show Less" else "Show More ▾", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { expanded = !expanded }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 8297f295..d06edc5f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -28,6 +28,7 @@ data class PlayerLaunch( val parentMetaId: String, val parentMetaType: String, val initialPositionMs: Long = 0L, + val initialProgressFraction: Float? = null, ) object PlayerLaunchStore { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 25b7ebc2..40d391f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -83,6 +83,7 @@ fun PlayerScreen( parentMetaType: String, providerAddonId: String? = null, initialPositionMs: Long = 0L, + initialProgressFraction: Float? = null, ) { LockPlayerToLandscape() EnterImmersivePlayerMode() @@ -114,6 +115,7 @@ fun PlayerScreen( var activeEpisodeThumbnail by rememberSaveable { mutableStateOf(episodeThumbnail) } var activeVideoId by rememberSaveable { mutableStateOf(videoId) } var activeInitialPositionMs by rememberSaveable { mutableStateOf(initialPositionMs) } + var activeInitialProgressFraction by rememberSaveable { mutableStateOf(initialProgressFraction) } var shouldPlay by rememberSaveable(activeSourceUrl) { mutableStateOf(true) } var resizeMode by rememberSaveable { mutableStateOf(PlayerResizeMode.Fit) } var layoutSize by remember { mutableStateOf(IntSize.Zero) } @@ -125,8 +127,12 @@ fun PlayerScreen( var gestureFeedback by remember { mutableStateOf(null) } var gestureMessageJob by remember { mutableStateOf(null) } var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) } - var initialSeekApplied by remember(activeSourceUrl, activeInitialPositionMs) { - mutableStateOf(activeInitialPositionMs <= 0L) + var initialSeekApplied by remember(activeSourceUrl, activeInitialPositionMs, activeInitialProgressFraction) { + val initialProgressFraction = activeInitialProgressFraction + mutableStateOf( + activeInitialPositionMs <= 0L && + (initialProgressFraction == null || initialProgressFraction <= 0f), + ) } var lastProgressPersistEpochMs by remember(activeSourceUrl) { mutableStateOf(0L) } var previousIsPlaying by remember(activeSourceUrl) { mutableStateOf(false) } @@ -467,6 +473,7 @@ fun PlayerScreen( activeProviderName = stream.addonName activeProviderAddonId = stream.addonId activeInitialPositionMs = 0L + activeInitialProgressFraction = null showSourcesPanel = false controlsVisible = true } @@ -503,6 +510,7 @@ fun PlayerScreen( activeEpisodeThumbnail = episode.thumbnail activeVideoId = episode.id activeInitialPositionMs = 0L + activeInitialProgressFraction = null showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() PlayerStreamsRepository.clearEpisodeStreams() @@ -572,12 +580,36 @@ fun PlayerScreen( } } - LaunchedEffect(playerController, playbackSnapshot.isLoading, activeInitialPositionMs, initialSeekApplied) { + LaunchedEffect( + playerController, + playbackSnapshot.isLoading, + playbackSnapshot.durationMs, + activeInitialPositionMs, + activeInitialProgressFraction, + initialSeekApplied, + ) { val controller = playerController ?: return@LaunchedEffect - if (initialSeekApplied || playbackSnapshot.isLoading || activeInitialPositionMs <= 0L) { + if (initialSeekApplied || playbackSnapshot.isLoading) { return@LaunchedEffect } - controller.seekTo(activeInitialPositionMs) + + val progressFraction = activeInitialProgressFraction + ?.takeIf { it > 0f } + ?.coerceIn(0f, 1f) + val targetPositionMs = when { + activeInitialPositionMs > 0L -> activeInitialPositionMs + progressFraction != null && playbackSnapshot.durationMs > 0L -> { + (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong() + } + progressFraction != null -> return@LaunchedEffect + else -> 0L + } + if (targetPositionMs <= 0L) { + initialSeekApplied = true + return@LaunchedEffect + } + + controller.seekTo(targetPositionMs) initialSeekApplied = true } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 76e5dfa6..4eacb6ea 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -69,7 +69,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import kotlin.math.round +import kotlin.math.roundToInt // --------------------------------------------------------------------------- // Streams Screen @@ -90,7 +92,8 @@ fun StreamsScreen( episodeTitle: String? = null, episodeThumbnail: String? = null, resumePositionMs: Long? = null, - onStreamSelected: (StreamItem) -> Unit = {}, + resumeProgressFraction: Float? = null, + onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> }, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -101,8 +104,31 @@ fun StreamsScreen( }.collectAsStateWithLifecycle() val isEpisode = seasonNumber != null && episodeNumber != null var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } + val legacyEpisodeVideoId = remember(type, parentMetaId, seasonNumber, episodeNumber, videoId) { + if (type == "series" && seasonNumber != null && episodeNumber != null) { + buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ).takeIf { it != videoId } + } else { + null + } + } val storedProgress = watchProgressUiState.byVideoId[videoId] - val effectiveResumePositionMs = resumePositionMs ?: storedProgress?.lastPositionMs + ?: legacyEpisodeVideoId?.let { legacyId -> watchProgressUiState.byVideoId[legacyId] } + val storedProgressFraction = storedProgress?.progressPercent + ?.takeIf { it > 0f } + ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } + val effectiveResumeProgressFraction = resumeProgressFraction + ?.takeIf { it > 0f } + ?.coerceIn(0f, 1f) + ?: storedProgressFraction + val effectiveResumePositionMs = if (effectiveResumeProgressFraction != null) { + null + } else { + (resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L } + } LaunchedEffect(type, videoId) { StreamsRepository.load(type, videoId) @@ -143,6 +169,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, resumePositionMs = effectiveResumePositionMs, + resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = onStreamSelected, ) } else { @@ -156,6 +183,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, resumePositionMs = effectiveResumePositionMs, + resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = onStreamSelected, ) } @@ -207,7 +235,8 @@ private fun MobileStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, resumePositionMs: Long?, - onStreamSelected: (StreamItem) -> Unit, + resumeProgressFraction: Float?, + onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -269,9 +298,10 @@ private fun MobileStreamsLayout( } Column(modifier = Modifier.fillMaxSize()) { - if (resumePositionMs != null && resumePositionMs > 0L) { + if ((resumePositionMs != null && resumePositionMs > 0L) || (resumeProgressFraction != null && resumeProgressFraction > 0f)) { ResumeBanner( positionMs = resumePositionMs, + progressFraction = resumeProgressFraction, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) } @@ -284,6 +314,8 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, onStreamSelected = onStreamSelected, + resumePositionMs = resumePositionMs, + resumeProgressFraction = resumeProgressFraction, modifier = Modifier.weight(1f), ) } @@ -294,9 +326,16 @@ private fun MobileStreamsLayout( @Composable internal fun ResumeBanner( - positionMs: Long, + positionMs: Long?, + progressFraction: Float? = null, modifier: Modifier = Modifier, ) { + val resumeText = when { + progressFraction != null && progressFraction > 0f -> "Resume from ${(progressFraction * 100f).roundToInt()}%" + positionMs != null && positionMs > 0L -> "Resume from ${positionMs.toPlaybackClock()}" + else -> null + } ?: return + Box( modifier = modifier .clip(RoundedCornerShape(18.dp)) @@ -304,7 +343,7 @@ internal fun ResumeBanner( .padding(horizontal = 14.dp, vertical = 10.dp), ) { Text( - text = "Resume from ${positionMs.toPlaybackClock()}", + text = resumeText, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -560,7 +599,9 @@ private fun FilterChip( @Composable internal fun StreamList( uiState: StreamsUiState, - onStreamSelected: (StreamItem) -> Unit, + onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + resumePositionMs: Long?, + resumeProgressFraction: Float?, modifier: Modifier = Modifier, ) { val filteredGroups = uiState.filteredGroups @@ -595,6 +636,8 @@ internal fun StreamList( group = group, showHeader = uiState.selectedFilter == null, onStreamSelected = onStreamSelected, + resumePositionMs = resumePositionMs, + resumeProgressFraction = resumeProgressFraction, ) } if (anyLoading) { @@ -613,7 +656,9 @@ internal fun StreamList( private fun LazyListScope.streamSection( group: AddonStreamGroup, showHeader: Boolean, - onStreamSelected: (StreamItem) -> Unit, + onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + resumePositionMs: Long?, + resumeProgressFraction: Float?, ) { if (group.streams.isEmpty() && !group.isLoading) return @@ -634,7 +679,7 @@ private fun LazyListScope.streamSection( stream = stream, onClick = { if (stream.directPlaybackUrl != null) { - onStreamSelected(stream) + onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 9cc321c4..203e97ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -59,7 +59,8 @@ internal fun TabletStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, resumePositionMs: Long?, - onStreamSelected: (StreamItem) -> Unit, + resumeProgressFraction: Float?, + onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, modifier: Modifier = Modifier, ) { val hazeState = rememberHazeState() @@ -179,9 +180,10 @@ internal fun TabletStreamsLayout( .fillMaxSize() .padding(16.dp), ) { - if (resumePositionMs != null && resumePositionMs > 0L) { + if ((resumePositionMs != null && resumePositionMs > 0L) || (resumeProgressFraction != null && resumeProgressFraction > 0f)) { ResumeBanner( positionMs = resumePositionMs, + progressFraction = resumeProgressFraction, modifier = Modifier.padding(bottom = 8.dp), ) } @@ -200,6 +202,8 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, onStreamSelected = onStreamSelected, + resumePositionMs = resumePositionMs, + resumeProgressFraction = resumeProgressFraction, modifier = Modifier.weight(1f), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index b21ed597..dccda559 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -30,6 +30,7 @@ private const val PLAYBACK_SYNTHETIC_DURATION_MS = 100_000L private const val HISTORY_LIMIT = 250 private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 +private const val METADATA_HYDRATION_LIMIT = 30 data class TraktProgressUiState( val entries: List = emptyList(), @@ -143,22 +144,39 @@ object TraktProgressRepository { } private suspend fun fetchSnapshotEntries(headers: Map): List = withContext(Dispatchers.Default) { - val moviesPayload = httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/movies", - headers = headers, - ) - val episodesPayload = httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/episodes", - headers = headers, - ) - val historyPayload = httpGetTextWithHeaders( - url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT", - headers = headers, - ) - val movieHistoryPayload = httpGetTextWithHeaders( - url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT", - headers = headers, - ) + val payloads = coroutineScope { + val moviesPayload = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/playback/movies", + headers = headers, + ) + } + val episodesPayload = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/playback/episodes", + headers = headers, + ) + } + val historyPayload = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT", + headers = headers, + ) + } + val movieHistoryPayload = async { + httpGetTextWithHeaders( + url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT", + headers = headers, + ) + } + + awaitAll(moviesPayload, episodesPayload, historyPayload, movieHistoryPayload) + } + + val moviesPayload = payloads[0] + val episodesPayload = payloads[1] + val historyPayload = payloads[2] + val movieHistoryPayload = payloads[3] val moviePlayback = json.decodeFromString>(moviesPayload) val episodePlayback = json.decodeFromString>(episodesPayload) @@ -243,6 +261,7 @@ object TraktProgressRepository { val uniqueContent = entries .map { entry -> entry.parentMetaType to entry.parentMetaId } .distinct() + .take(METADATA_HYDRATION_LIMIT) val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) val metadataByContent = uniqueContent @@ -292,7 +311,9 @@ object TraktProgressRepository { val parentMetaId = normalizeTraktContentId(movie.ids, fallback = movie.title) if (parentMetaId.isBlank()) return null - val progressFraction = ((item.progress ?: 0f).coerceIn(0f, 100f) / 100f) + val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null + if (progressPercent <= 0f) return null + val progressFraction = progressPercent / 100f val positionMs = (PLAYBACK_SYNTHETIC_DURATION_MS * progressFraction).toLong() return WatchProgressEntry( @@ -305,6 +326,7 @@ object TraktProgressRepository { durationMs = PLAYBACK_SYNTHETIC_DURATION_MS, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), isCompleted = false, + progressPercent = progressPercent, ) } @@ -317,7 +339,9 @@ object TraktProgressRepository { val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) if (parentMetaId.isBlank()) return null - val progressFraction = ((item.progress ?: 0f).coerceIn(0f, 100f) / 100f) + val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null + if (progressPercent <= 0f) return null + val progressFraction = progressPercent / 100f val positionMs = (PLAYBACK_SYNTHETIC_DURATION_MS * progressFraction).toLong() return WatchProgressEntry( @@ -338,6 +362,7 @@ object TraktProgressRepository { durationMs = PLAYBACK_SYNTHETIC_DURATION_MS, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), isCompleted = false, + progressPercent = progressPercent, ) } @@ -368,6 +393,7 @@ object TraktProgressRepository { durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, + progressPercent = 100f, ) } @@ -386,9 +412,20 @@ object TraktProgressRepository { durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, + progressPercent = 100f, ) } + private fun normalizeTraktProgressPercent(rawProgress: Float?): Float? { + val value = rawProgress ?: return null + if (!value.isFinite()) return null + val normalized = when { + value <= 1f -> value * 100f + else -> value + } + return normalized.coerceIn(0f, 100f) + } + private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { val compactDigits = isoDate ?.filter(Char::isDigit) 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 eb90775b..6371bddd 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 @@ -34,12 +34,18 @@ data class WatchProgressEntry( val pauseDescription: String? = null, val lastSourceUrl: String? = null, val isCompleted: Boolean = false, + val progressPercent: Float? = null, ) { val progressFraction: Float - get() = if (durationMs > 0L) { - (lastPositionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) - } else { - 0f + get() { + progressPercent?.let { explicitPercent -> + return (explicitPercent / 100f).coerceIn(0f, 1f) + } + return if (durationMs > 0L) { + (lastPositionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } } val isEpisode: Boolean @@ -96,6 +102,7 @@ data class ContinueWatchingItem( val episodeThumbnail: String? = null, val pauseDescription: String? = null, val resumePositionMs: Long, + val resumeProgressFraction: Float? = null, val durationMs: Long, val progressFraction: Float, ) @@ -107,6 +114,10 @@ data class ContinueWatchingPreferencesUiState( ) internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { + val explicitResumeProgressFraction = progressPercent + ?.takeIf { durationMs <= 0L && it > 0f } + ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } + val subtitle = if (seasonNumber != null && episodeNumber != null) { buildString { append("S") @@ -137,7 +148,8 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, - resumePositionMs = lastPositionMs, + resumePositionMs = if (explicitResumeProgressFraction != null) 0L else lastPositionMs, + resumeProgressFraction = explicitResumeProgressFraction, durationMs = durationMs, progressFraction = progressFraction, ) @@ -181,6 +193,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeThumbnail = nextEpisode.thumbnail, pauseDescription = nextEpisode.overview, resumePositionMs = 0L, + resumeProgressFraction = null, durationMs = 0L, progressFraction = 0f, )