mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-04 17:29:07 +00:00
fix: use videoID for stream url generation, trakt percentage to duration mapping
This commit is contained in:
parent
a92954a762
commit
5934329b66
9 changed files with 296 additions and 69 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ data class PlayerLaunch(
|
|||
val parentMetaId: String,
|
||||
val parentMetaType: String,
|
||||
val initialPositionMs: Long = 0L,
|
||||
val initialProgressFraction: Float? = null,
|
||||
)
|
||||
|
||||
object PlayerLaunchStore {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue