fix: use videoID for stream url generation, trakt percentage to duration mapping

This commit is contained in:
tapframe 2026-04-02 00:15:18 +05:30
parent a92954a762
commit 5934329b66
9 changed files with 296 additions and 69 deletions

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ data class PlayerLaunch(
val parentMetaId: String,
val parentMetaType: String,
val initialPositionMs: Long = 0L,
val initialProgressFraction: Float? = null,
)
object PlayerLaunchStore {

View file

@ -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<GestureFeedbackState?>(null) }
var gestureMessageJob by remember { mutableStateOf<Job?>(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
}

View file

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

View file

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

View file

@ -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<WatchProgressEntry> = emptyList(),
@ -143,22 +144,39 @@ object TraktProgressRepository {
}
private suspend fun fetchSnapshotEntries(headers: Map<String, String>): List<WatchProgressEntry> = 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<List<TraktPlaybackItem>>(moviesPayload)
val episodePlayback = json.decodeFromString<List<TraktPlaybackItem>>(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)

View file

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