watchprogress init

This commit is contained in:
tapframe 2026-03-28 17:28:53 +05:30
parent 6308a7431d
commit 2e7da22c25
21 changed files with 1206 additions and 117 deletions

View file

@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -15,6 +16,7 @@ class MainActivity : ComponentActivity() {
AddonStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
setContent {
App()

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.watchprogress
actual object WatchProgressClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,25 @@
package com.nuvio.app.features.watchprogress
import android.content.Context
import android.content.SharedPreferences
actual object WatchProgressStorage {
private const val preferencesName = "nuvio_watch_progress"
private const val payloadKey = "watch_progress_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(payloadKey, null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(payloadKey, payload)
?.apply()
}
}

View file

@ -46,6 +46,7 @@ import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.serialization.Serializable
@Serializable
@ -58,6 +59,8 @@ data class DetailRoute(val type: String, val id: String)
data class StreamRoute(
val type: String,
val videoId: String,
val parentMetaId: String? = null,
val parentMetaType: String? = null,
val title: String,
val logo: String? = null,
val poster: String? = null,
@ -66,6 +69,7 @@ data class StreamRoute(
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val resumePositionMs: Long? = null,
)
@Serializable
@ -91,12 +95,16 @@ fun AppScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
when (tab) {
AppScreenTab.Home -> HomeScreen(
modifier = modifier,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
)
AppScreenTab.Search -> SearchScreen(
modifier = modifier,
@ -121,12 +129,14 @@ fun App() {
val navController = rememberNavController()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val onPlay: (String, String, String, String?, String?, String?, Int?, Int?, String?, String?) -> Unit =
{ type, videoId, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail ->
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, resumePositionMs ->
navController.navigate(
StreamRoute(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
@ -135,6 +145,7 @@ fun App() {
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
resumePositionMs = resumePositionMs,
)
)
}
@ -152,6 +163,35 @@ fun App() {
)
}
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
navController.navigate(
StreamRoute(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
resumePositionMs = item.resumePositionMs,
),
)
}
val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item ->
navController.navigate(
DetailRoute(
type = item.parentMetaType,
id = item.parentMetaId,
),
)
}
Box(
modifier = Modifier
.fillMaxSize()
@ -206,6 +246,8 @@ fun App() {
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
)
}
@ -235,6 +277,8 @@ fun App() {
StreamsScreen(
type = route.type,
videoId = route.videoId,
parentMetaId = route.parentMetaId ?: route.videoId,
parentMetaType = route.parentMetaType ?: route.type,
title = route.title,
logo = route.logo,
poster = route.poster,
@ -243,6 +287,7 @@ fun App() {
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
resumePositionMs = route.resumePositionMs,
onStreamSelected = { stream ->
val sourceUrl = stream.directPlaybackUrl
if (sourceUrl != null) {
@ -256,11 +301,16 @@ fun App() {
seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
streamTitle = stream.streamLabel,
streamSubtitle = stream.streamSubtitle,
providerName = stream.addonName,
providerAddonId = stream.addonId,
contentType = route.type,
videoId = route.videoId,
parentMetaId = route.parentMetaId ?: route.videoId,
parentMetaType = route.parentMetaType ?: route.type,
initialPositionMs = route.resumePositionMs ?: 0L,
)
)
}
@ -283,11 +333,16 @@ fun App() {
seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
streamTitle = route.streamTitle,
streamSubtitle = route.streamSubtitle,
providerName = route.providerName,
providerAddonId = route.providerAddonId,
contentType = route.contentType,
videoId = route.videoId,
parentMetaId = route.parentMetaId,
parentMetaType = route.parentMetaType,
initialPositionMs = route.initialPositionMs,
onBack = { navController.popBackStack() },
modifier = Modifier.fillMaxSize(),
)

View file

@ -0,0 +1,42 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun NuvioProgressBar(
progress: Float,
modifier: Modifier = Modifier,
height: Dp = 4.dp,
trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
fillColor: Color = MaterialTheme.colorScheme.primary,
) {
val clampedProgress = progress.coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(percent = 50))
.background(trackColor),
) {
Box(
modifier = Modifier
.fillMaxWidth(clampedProgress)
.width(0.dp)
.height(height)
.clip(RoundedCornerShape(percent = 50))
.background(fillColor),
)
}
}

View file

@ -43,16 +43,23 @@ import com.nuvio.app.features.details.components.DetailCastSection
import com.nuvio.app.features.details.components.DetailHero
import com.nuvio.app.features.details.components.DetailMetaInfo
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
@Composable
fun MetaDetailsScreen(
type: String,
id: String,
onBack: () -> Unit,
onPlay: ((type: String, videoId: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?) -> Unit)? = null,
onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, resumePositionMs: Long?) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val screenAlpha = remember(type, id) { Animatable(0f) }
val requestedMeta = uiState.meta?.takeIf { it.type == type && it.id == id }
val needsFreshLoad = requestedMeta == null && !uiState.isLoading
@ -107,6 +114,22 @@ fun MetaDetailsScreen(
requestedMeta != null -> {
val meta = requestedMeta
val movieProgress = watchProgressUiState.byVideoId[meta.id]
val seriesResumeEntry = watchProgressUiState.entries
.filter { it.parentMetaId == meta.id }
.maxByOrNull { it.lastUpdatedEpochMs }
val firstEpisode = remember(meta.videos) { meta.firstPlayableEpisode() }
val playButtonLabel = remember(movieProgress, seriesResumeEntry, firstEpisode, meta.type) {
when {
meta.type == "series" && seriesResumeEntry != null ->
seriesResumeEntry.resumeLabel()
meta.type == "series" && firstEpisode != null ->
firstEpisode.playLabel()
meta.type != "series" && movieProgress != null ->
"Resume"
else -> "Play"
}
}
val scrollState = rememberScrollState()
Column(
modifier = Modifier
@ -122,19 +145,68 @@ fun MetaDetailsScreen(
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
DetailActionButtons(
playLabel = playButtonLabel,
onPlayClick = {
onPlay?.invoke(
meta.type,
meta.id,
meta.name,
meta.logo,
meta.poster,
meta.background,
null,
null,
null,
null,
)
when {
meta.type == "series" && seriesResumeEntry != null -> {
onPlay?.invoke(
meta.type,
seriesResumeEntry.videoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
seriesResumeEntry.seasonNumber,
seriesResumeEntry.episodeNumber,
seriesResumeEntry.episodeTitle,
seriesResumeEntry.episodeThumbnail,
seriesResumeEntry.lastPositionMs,
)
}
meta.type == "series" && firstEpisode != null -> {
onPlay?.invoke(
meta.type,
buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = firstEpisode.season,
episodeNumber = firstEpisode.episode,
fallbackVideoId = firstEpisode.id,
),
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
firstEpisode.season,
firstEpisode.episode,
firstEpisode.title,
firstEpisode.thumbnail,
null,
)
}
else -> {
onPlay?.invoke(
meta.type,
meta.id,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
null,
null,
null,
null,
movieProgress?.lastPositionMs,
)
}
}
},
)
@ -144,17 +216,22 @@ fun MetaDetailsScreen(
DetailSeriesContent(
meta = meta,
progressByVideoId = watchProgressUiState.byVideoId,
onEpisodeClick = { video ->
val season = video.season
val episode = video.episode
val videoId = if (season != null && episode != null) {
"${meta.id}:${season}:${episode}"
} else {
video.id
}
val playbackVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = season,
episodeNumber = episode,
fallbackVideoId = video.id,
)
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
onPlay?.invoke(
meta.type,
videoId,
playbackVideoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
@ -163,6 +240,7 @@ fun MetaDetailsScreen(
episode,
video.title,
video.thumbnail,
savedProgress?.lastPositionMs,
)
},
)
@ -195,3 +273,30 @@ fun MetaDetailsScreen(
}
}
}
private fun MetaDetails.firstPlayableEpisode(): MetaVideo? =
videos
.filter { it.season != null || it.episode != null }
.sortedWith(
compareBy<MetaVideo>(
{ it.season ?: Int.MAX_VALUE },
{ it.episode ?: Int.MAX_VALUE },
{ it.released ?: "" },
{ it.title },
),
)
.firstOrNull()
private fun MetaVideo.playLabel(): String =
if (season != null && episode != null) {
"Play S${season}E${episode}"
} else {
"Play"
}
private fun WatchProgressEntry.resumeLabel(): String =
if (seasonNumber != null && episodeNumber != null) {
"Resume S${seasonNumber}E${episodeNumber}"
} else {
"Resume"
}

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun DetailActionButtons(
modifier: Modifier = Modifier,
playLabel: String = "Play",
onPlayClick: () -> Unit = {},
onSaveClick: () -> Unit = {},
) {
@ -51,7 +52,7 @@ fun DetailActionButtons(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Play",
text = playLabel,
style = MaterialTheme.typography.titleMedium,
)
}

View file

@ -38,8 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
private val log = Logger.withTag("SeriesContent")
@ -47,6 +50,7 @@ private val log = Logger.withTag("SeriesContent")
fun DetailSeriesContent(
meta: MetaDetails,
modifier: Modifier = Modifier,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
) {
val groupedEpisodes = remember(meta.videos) {
@ -146,9 +150,16 @@ fun DetailSeriesContent(
verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
) {
episodes.forEach { episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
EpisodeCard(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
)
@ -162,12 +173,13 @@ fun DetailSeriesContent(
private fun EpisodeCard(
video: MetaVideo,
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
sizing: SeriesContentSizing,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val cardShape = RoundedCornerShape(sizing.cardRadius)
Row(
Box(
modifier = modifier
.fillMaxWidth()
.height(sizing.cardHeight)
@ -180,111 +192,126 @@ private fun EpisodeCard(
)
.clickable(enabled = onClick != null) { onClick?.invoke() },
) {
// Image area - fixed width matching card height per spec
Box(
modifier = Modifier
.width(sizing.imageWidth)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
Row(
modifier = Modifier.fillMaxSize(),
) {
val imageUrl = video.thumbnail ?: fallbackImage
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
)
}
// Episode number badge — bottom-right of image per spec
// Image area - fixed width matching card height per spec
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 8.dp, end = 4.dp)
.clip(RoundedCornerShape(sizing.badgeRadius))
.background(Color.Black.copy(alpha = 0.85f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(sizing.badgeRadius),
.width(sizing.imageWidth)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) {
val imageUrl = video.thumbnail ?: fallbackImage
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
)
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 8.dp, end = 4.dp)
.clip(RoundedCornerShape(sizing.badgeRadius))
.background(Color.Black.copy(alpha = 0.85f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(sizing.badgeRadius),
)
.padding(
horizontal = sizing.badgeHorizontalPadding,
vertical = sizing.badgeVerticalPadding,
),
) {
Text(
text = video.episodeBadge(),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.badgeTextSize,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
),
color = Color.White,
)
}
}
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.padding(
horizontal = sizing.badgeHorizontalPadding,
vertical = sizing.badgeVerticalPadding,
start = sizing.contentHorizontalPadding,
end = sizing.contentHorizontalPadding,
top = sizing.contentVerticalPadding,
bottom = sizing.contentVerticalPadding,
),
verticalArrangement = Arrangement.spacedBy(sizing.contentSpacing),
) {
Text(
text = video.episodeBadge(),
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.badgeTextSize,
fontWeight = FontWeight.SemiBold,
text = video.title,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp,
),
color = Color.White,
color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
video.released?.formattedDate()?.let { formattedDate ->
Text(
text = formattedDate,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (!video.overview.isNullOrBlank()) {
Text(
text = video.overview,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = sizing.bodyTextSize,
lineHeight = sizing.bodyLineHeight,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = sizing.overviewMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Info block
Column(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.padding(
start = sizing.contentHorizontalPadding,
end = sizing.contentHorizontalPadding,
top = sizing.contentVerticalPadding,
bottom = sizing.contentVerticalPadding,
),
verticalArrangement = Arrangement.spacedBy(sizing.contentSpacing),
) {
Text(
text = video.title,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
// Metadata row: air date
video.released?.formattedDate()?.let { formattedDate ->
Text(
text = formattedDate,
style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
progressEntry
?.takeIf { it.durationMs > 0L }
?.let { entry ->
NuvioProgressBar(
progress = entry.progressFraction,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 12.dp, vertical = 10.dp),
height = 5.dp,
trackColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.14f),
fillColor = MaterialTheme.colorScheme.primary,
)
}
if (!video.overview.isNullOrBlank()) {
Text(
text = video.overview,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = sizing.bodyTextSize,
lineHeight = sizing.bodyLineHeight,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = sizing.overviewMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -11,22 +11,33 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
WatchProgressRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val continueWatchingItems = remember(watchProgressUiState.entries) {
watchProgressUiState.entries.take(20).map { it.toContinueWatchingItem() }
}
val catalogRefreshKey = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon ->
@ -53,6 +64,16 @@ fun HomeScreen(
) {
when {
addonsUiState.addons.none { it.manifest != null } -> {
if (continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
@ -63,12 +84,22 @@ fun HomeScreen(
}
homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
if (continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() -> {
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && continueWatchingItems.isEmpty() -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
@ -89,6 +120,16 @@ fun HomeScreen(
)
}
}
if (continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
items(
count = homeUiState.sections.size,
key = { index -> homeUiState.sections[index].key },

View file

@ -0,0 +1,134 @@
package com.nuvio.app.features.home.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
@Composable
fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>,
modifier: Modifier = Modifier,
onItemClick: ((ContinueWatchingItem) -> Unit)? = null,
onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
if (items.isEmpty()) return
NuvioShelfSection(
title = "Continue Watching",
entries = items,
modifier = modifier,
headerHorizontalPadding = 16.dp,
rowContentPadding = PaddingValues(horizontal = 16.dp),
key = { item -> item.videoId },
) { item ->
ContinueWatchingCard(
item = item,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContinueWatchingCard(
item: ContinueWatchingItem,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier
.width(260.dp)
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f))
.combinedClickable(
enabled = onClick != null || onLongClick != null,
onClick = { onClick?.invoke() },
onLongClick = onLongClick,
)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.72f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
val imageUrl = item.imageUrl
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.78f))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = "Resume",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
}
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
NuvioProgressBar(
progress = item.progressFraction,
modifier = Modifier.fillMaxWidth(),
height = 5.dp,
)
Spacer(modifier = Modifier.height(2.dp))
}
}

View file

@ -12,11 +12,16 @@ data class PlayerRoute(
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val streamTitle: String,
val streamSubtitle: String? = null,
val providerName: String,
val providerAddonId: String? = null,
val contentType: String? = null,
val videoId: String? = null,
val parentMetaId: String,
val parentMetaType: String,
val initialPositionMs: Long = 0L,
)
enum class PlayerResizeMode {

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -30,6 +31,10 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -49,8 +54,13 @@ fun PlayerScreen(
seasonNumber: Int? = null,
episodeNumber: Int? = null,
episodeTitle: String? = null,
episodeThumbnail: String? = null,
contentType: String? = null,
videoId: String? = null,
parentMetaId: String,
parentMetaType: String,
providerAddonId: String? = null,
initialPositionMs: Long = 0L,
) {
LockPlayerToLandscape()
EnterImmersivePlayerMode()
@ -79,9 +89,72 @@ fun PlayerScreen(
var gestureMessage by remember { mutableStateOf<String?>(null) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var initialLoadCompleted by remember(sourceUrl) { mutableStateOf(false) }
var initialSeekApplied by remember(sourceUrl, initialPositionMs) {
mutableStateOf(initialPositionMs <= 0L)
}
var lastProgressPersistEpochMs by remember(sourceUrl) { mutableStateOf(0L) }
var previousIsPlaying by remember(sourceUrl) { mutableStateOf(false) }
val backdropArtwork = background ?: poster
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = seasonNumber != null && episodeNumber != null
val playbackSession = remember(
contentType,
parentMetaId,
parentMetaType,
videoId,
title,
logo,
poster,
background,
seasonNumber,
episodeNumber,
episodeTitle,
episodeThumbnail,
providerName,
providerAddonId,
streamTitle,
streamSubtitle,
sourceUrl,
) {
WatchProgressPlaybackSession(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
fallbackVideoId = videoId,
),
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
providerName = providerName,
providerAddonId = providerAddonId,
lastStreamTitle = streamTitle,
lastStreamSubtitle = streamSubtitle,
lastSourceUrl = sourceUrl,
)
}
fun flushWatchProgress() {
WatchProgressRepository.flushPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
)
}
val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) {
{
flushWatchProgress()
onBack()
}
}
var showAudioModal by remember { mutableStateOf(false) }
var showSubtitleModal by remember { mutableStateOf(false) }
@ -160,7 +233,10 @@ fun PlayerScreen(
errorMessage = null
scrubbingPositionMs = null
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
SubtitleRepository.clear()
WatchProgressRepository.ensureLoaded()
}
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
@ -169,6 +245,15 @@ fun PlayerScreen(
}
}
LaunchedEffect(playerController, playbackSnapshot.isLoading, initialPositionMs, initialSeekApplied) {
val controller = playerController ?: return@LaunchedEffect
if (initialSeekApplied || playbackSnapshot.isLoading || initialPositionMs <= 0L) {
return@LaunchedEffect
}
controller.seekTo(initialPositionMs)
initialSeekApplied = true
}
LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) {
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) {
return@LaunchedEffect
@ -186,6 +271,40 @@ fun PlayerScreen(
pausedOverlayVisible = true
}
LaunchedEffect(playbackSnapshot.positionMs, playbackSnapshot.isPlaying, playbackSnapshot.isEnded, playbackSnapshot.durationMs) {
if (playbackSnapshot.isEnded) {
flushWatchProgress()
previousIsPlaying = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying) {
flushWatchProgress()
}
previousIsPlaying = playbackSnapshot.isPlaying
if (!playbackSnapshot.isPlaying) {
return@LaunchedEffect
}
val now = WatchProgressClock.nowEpochMs()
if (now - lastProgressPersistEpochMs < 5_000L) {
return@LaunchedEffect
}
lastProgressPersistEpochMs = now
WatchProgressRepository.upsertPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
)
}
DisposableEffect(playbackSession.videoId, sourceUrl) {
onDispose {
flushWatchProgress()
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -268,7 +387,7 @@ fun PlayerScreen(
displayedPositionMs = displayedPositionMs,
metrics = metrics,
resizeMode = resizeMode,
onBack = onBack,
onBack = onBackWithProgress,
onTogglePlayback = ::togglePlayback,
onSeekBack = { seekBy(-10_000L) },
onSeekForward = { seekBy(10_000L) },
@ -300,7 +419,7 @@ fun PlayerScreen(
OpeningOverlay(
artwork = backdropArtwork,
logo = logo,
onBack = onBack,
onBack = onBackWithProgress,
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
)
@ -328,7 +447,7 @@ fun PlayerScreen(
if (errorMessage != null) {
ErrorModal(
message = errorMessage.orEmpty(),
onDismiss = onBack,
onDismiss = onBackWithProgress,
modifier = Modifier.align(Alignment.Center),
)
}

View file

@ -43,6 +43,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@ -58,6 +61,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlin.math.round
// ---------------------------------------------------------------------------
@ -68,6 +72,8 @@ import kotlin.math.round
fun StreamsScreen(
type: String,
videoId: String,
parentMetaId: String,
parentMetaType: String,
title: String,
logo: String? = null,
poster: String? = null,
@ -76,17 +82,34 @@ fun StreamsScreen(
episodeNumber: Int? = null,
episodeTitle: String? = null,
episodeThumbnail: String? = null,
resumePositionMs: Long? = null,
onStreamSelected: (StreamItem) -> Unit = {},
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val isEpisode = seasonNumber != null && episodeNumber != null
var preferredFilterApplied by remember(videoId) { mutableStateOf(false) }
val storedProgress = watchProgressUiState.byVideoId[videoId]
val effectiveResumePositionMs = resumePositionMs ?: storedProgress?.lastPositionMs
LaunchedEffect(type, videoId) {
StreamsRepository.load(type, videoId)
}
LaunchedEffect(uiState.groups, storedProgress?.providerAddonId, preferredFilterApplied) {
if (preferredFilterApplied) return@LaunchedEffect
val preferredAddonId = storedProgress?.providerAddonId ?: return@LaunchedEffect
if (uiState.groups.any { it.addonId == preferredAddonId }) {
StreamsRepository.selectFilter(preferredAddonId)
preferredFilterApplied = true
}
}
val heroArtwork = if (isEpisode) {
episodeThumbnail ?: background ?: poster
} else {
@ -121,7 +144,7 @@ fun StreamsScreen(
// Main content column
Column(modifier = Modifier.fillMaxSize()) {
// Hero block
if (isEpisode && seasonNumber != null && episodeNumber != null) {
if (isEpisode) {
EpisodeHeroBlock(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
@ -160,6 +183,12 @@ fun StreamsScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
if (effectiveResumePositionMs != null && effectiveResumePositionMs > 0L) {
ResumeBanner(
positionMs = effectiveResumePositionMs,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
ProviderFilterRow(
groups = uiState.groups,
selectedFilter = uiState.selectedFilter,
@ -220,6 +249,26 @@ fun StreamsScreen(
}
}
@Composable
private fun ResumeBanner(
positionMs: Long,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = "Resume from ${positionMs.toPlaybackClock()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
}
}
// ---------------------------------------------------------------------------
// Movie Hero
// ---------------------------------------------------------------------------
@ -684,6 +733,28 @@ private fun StreamFileSizeBadge(stream: StreamItem) {
}
}
private fun Long.toPlaybackClock(): String {
val totalSeconds = (this / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
buildString {
append(hours)
append(':')
append(minutes.toString().padStart(2, '0'))
append(':')
append(seconds.toString().padStart(2, '0'))
}
} else {
buildString {
append(minutes)
append(':')
append(seconds.toString().padStart(2, '0'))
}
}
}
// ---------------------------------------------------------------------------
// State blocks
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.watchprogress
internal expect object WatchProgressClock {
fun nowEpochMs(): Long
}

View file

@ -0,0 +1,131 @@
package com.nuvio.app.features.watchprogress
import kotlinx.serialization.Serializable
@Serializable
data class WatchProgressEntry(
val contentType: String,
val parentMetaId: String,
val parentMetaType: String,
val videoId: String,
val title: String,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val lastPositionMs: Long,
val durationMs: Long,
val lastUpdatedEpochMs: Long,
val providerName: String? = null,
val providerAddonId: String? = null,
val lastStreamTitle: String? = null,
val lastStreamSubtitle: String? = null,
val lastSourceUrl: String? = null,
) {
val progressFraction: Float
get() = if (durationMs > 0L) {
(lastPositionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val isEpisode: Boolean
get() = seasonNumber != null && episodeNumber != null
}
data class WatchProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(),
) {
val byVideoId: Map<String, WatchProgressEntry>
get() = entries.associateBy { it.videoId }
}
data class WatchProgressPlaybackSession(
val contentType: String,
val parentMetaId: String,
val parentMetaType: String,
val videoId: String,
val title: String,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val providerName: String? = null,
val providerAddonId: String? = null,
val lastStreamTitle: String? = null,
val lastStreamSubtitle: String? = null,
val lastSourceUrl: String? = null,
)
data class ContinueWatchingItem(
val parentMetaId: String,
val parentMetaType: String,
val videoId: String,
val title: String,
val subtitle: String,
val imageUrl: String?,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val resumePositionMs: Long,
val durationMs: Long,
val progressFraction: Float,
)
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
val subtitle = if (seasonNumber != null && episodeNumber != null) {
buildString {
append("S")
append(seasonNumber)
append("E")
append(episodeNumber)
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
}
} else {
"Movie"
}
return ContinueWatchingItem(
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = videoId,
title = title,
subtitle = subtitle,
imageUrl = episodeThumbnail ?: background ?: poster,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
resumePositionMs = lastPositionMs,
durationMs = durationMs,
progressFraction = progressFraction,
)
}
fun buildPlaybackVideoId(
parentMetaId: String,
seasonNumber: Int?,
episodeNumber: Int?,
fallbackVideoId: String? = null,
): String =
if (seasonNumber != null && episodeNumber != null) {
"$parentMetaId:$seasonNumber:$episodeNumber"
} else {
fallbackVideoId?.takeIf { it.isNotBlank() } ?: parentMetaId
}

View file

@ -0,0 +1,122 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object WatchProgressRepository {
private val _uiState = MutableStateFlow(WatchProgressUiState())
val uiState: StateFlow<WatchProgressUiState> = _uiState.asStateFlow()
private var hasLoaded = false
private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf()
fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val payload = WatchProgressStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return
entriesByVideoId = WatchProgressCodec.decodeEntries(payload)
.associateBy { it.videoId }
.toMutableMap()
publish()
}
fun upsertPlaybackProgress(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,
) {
ensureLoaded()
upsert(session = session, snapshot = snapshot, persist = true)
}
fun flushPlaybackProgress(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,
) {
ensureLoaded()
upsert(session = session, snapshot = snapshot, persist = true)
}
fun clearProgress(videoId: String) {
ensureLoaded()
if (entriesByVideoId.remove(videoId) != null) {
publish()
persist()
}
}
fun progressForVideo(videoId: String): WatchProgressEntry? {
ensureLoaded()
return entriesByVideoId[videoId]
}
fun resumeEntryForSeries(metaId: String): WatchProgressEntry? {
ensureLoaded()
return entriesByVideoId.values.toList().resumeEntryForSeries(metaId)
}
fun continueWatching(): List<WatchProgressEntry> {
ensureLoaded()
return entriesByVideoId.values.toList().continueWatchingEntries()
}
private fun upsert(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,
persist: Boolean,
) {
val positionMs = snapshot.positionMs.coerceAtLeast(0L)
val durationMs = snapshot.durationMs.coerceAtLeast(0L)
if (isWatchProgressComplete(positionMs = positionMs, durationMs = durationMs, isEnded = snapshot.isEnded)) {
if (entriesByVideoId.remove(session.videoId) != null) {
publish()
if (persist) persist()
}
return
}
if (!shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) {
return
}
entriesByVideoId[session.videoId] = WatchProgressEntry(
contentType = session.contentType,
parentMetaId = session.parentMetaId,
parentMetaType = session.parentMetaType,
videoId = session.videoId,
title = session.title,
logo = session.logo,
poster = session.poster,
background = session.background,
seasonNumber = session.seasonNumber,
episodeNumber = session.episodeNumber,
episodeTitle = session.episodeTitle,
episodeThumbnail = session.episodeThumbnail,
lastPositionMs = positionMs,
durationMs = durationMs,
lastUpdatedEpochMs = WatchProgressClock.nowEpochMs(),
providerName = session.providerName,
providerAddonId = session.providerAddonId,
lastStreamTitle = session.lastStreamTitle,
lastStreamSubtitle = session.lastStreamSubtitle,
lastSourceUrl = session.lastSourceUrl,
)
publish()
if (persist) persist()
}
private fun publish() {
_uiState.value = WatchProgressUiState(
entries = entriesByVideoId.values.toList().continueWatchingEntries(limit = Int.MAX_VALUE),
)
}
private fun persist() {
WatchProgressStorage.savePayload(
WatchProgressCodec.encodeEntries(entriesByVideoId.values),
)
}
}

View file

@ -0,0 +1,70 @@
package com.nuvio.app.features.watchprogress
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.max
internal const val ContinueWatchingLimit = 20
private const val MinResumePositionMs = 30_000L
private const val CompletionThresholdMs = 180_000L
@Serializable
private data class StoredWatchProgressPayload(
val entries: List<WatchProgressEntry> = emptyList(),
)
internal object WatchProgressCodec {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
fun decodeEntries(payload: String): List<WatchProgressEntry> =
runCatching {
json.decodeFromString<StoredWatchProgressPayload>(payload).entries
}.getOrDefault(emptyList())
fun encodeEntries(entries: Collection<WatchProgressEntry>): String =
json.encodeToString(
StoredWatchProgressPayload(
entries = entries.toList().sortedByDescending { it.lastUpdatedEpochMs },
),
)
}
internal fun shouldStoreWatchProgress(
positionMs: Long,
durationMs: Long,
): Boolean {
val thresholdMs = if (durationMs > 0L) {
max(MinResumePositionMs, (durationMs * 0.02f).toLong())
} else {
MinResumePositionMs
}
return positionMs >= thresholdMs
}
internal fun isWatchProgressComplete(
positionMs: Long,
durationMs: Long,
isEnded: Boolean,
): Boolean {
if (isEnded) return true
if (durationMs <= 0L) return false
val remainingMs = (durationMs - positionMs).coerceAtLeast(0L)
val watchedFraction = positionMs.toDouble() / durationMs.toDouble()
return watchedFraction >= 0.92 || remainingMs <= CompletionThresholdMs
}
internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): WatchProgressEntry? =
filter { it.parentMetaId == metaId }
.maxByOrNull { it.lastUpdatedEpochMs }
internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> =
sortedByDescending { it.lastUpdatedEpochMs }
.take(limit)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.watchprogress
internal expect object WatchProgressStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -0,0 +1,100 @@
package com.nuvio.app.features.watchprogress
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class WatchProgressRulesTest {
@Test
fun `codec round trips entries in descending updated order`() {
val older = entry(videoId = "movie-1", lastUpdatedEpochMs = 100L)
val newer = entry(videoId = "movie-2", lastUpdatedEpochMs = 200L)
val payload = WatchProgressCodec.encodeEntries(listOf(older, newer))
val decoded = WatchProgressCodec.decodeEntries(payload)
assertEquals(listOf("movie-2", "movie-1"), decoded.map { it.videoId })
}
@Test
fun `codec ignores corrupt payload`() {
assertTrue(WatchProgressCodec.decodeEntries("{not json").isEmpty())
}
@Test
fun `save threshold uses max of thirty seconds and two percent`() {
assertFalse(shouldStoreWatchProgress(positionMs = 29_999L, durationMs = 600_000L))
assertTrue(shouldStoreWatchProgress(positionMs = 30_000L, durationMs = 600_000L))
assertFalse(shouldStoreWatchProgress(positionMs = 119_999L, durationMs = 6_000_000L))
assertTrue(shouldStoreWatchProgress(positionMs = 120_000L, durationMs = 6_000_000L))
}
@Test
fun `completion detects watched threshold remaining time and ended state`() {
assertTrue(isWatchProgressComplete(positionMs = 920_000L, durationMs = 1_000_000L, isEnded = false))
assertTrue(isWatchProgressComplete(positionMs = 850_000L, durationMs = 1_000_000L, isEnded = false))
assertTrue(isWatchProgressComplete(positionMs = 1L, durationMs = 0L, isEnded = true))
assertFalse(isWatchProgressComplete(positionMs = 200_000L, durationMs = 1_000_000L, isEnded = false))
}
@Test
fun `resume entry for series picks most recent episode`() {
val older = entry(videoId = "show:1:1", parentMetaId = "show", seasonNumber = 1, episodeNumber = 1, lastUpdatedEpochMs = 10L)
val newer = entry(videoId = "show:1:2", parentMetaId = "show", seasonNumber = 1, episodeNumber = 2, lastUpdatedEpochMs = 20L)
val other = entry(videoId = "movie", parentMetaId = "movie", lastUpdatedEpochMs = 30L)
val result = listOf(older, newer, other).resumeEntryForSeries("show")
assertEquals("show:1:2", result?.videoId)
}
@Test
fun `resume entry returns null when no series entries exist`() {
val result = listOf(entry(videoId = "movie", parentMetaId = "movie")).resumeEntryForSeries("show")
assertNull(result)
}
@Test
fun `continue watching entries are sorted and capped`() {
val entries = (1..25).map { index ->
entry(videoId = "video-$index", lastUpdatedEpochMs = index.toLong())
}
val result = entries.continueWatchingEntries()
assertEquals(20, result.size)
assertEquals("video-25", result.first().videoId)
assertEquals("video-6", result.last().videoId)
}
@Test
fun `build playback video id uses season and episode when present`() {
assertEquals("show:1:2", buildPlaybackVideoId(parentMetaId = "show", seasonNumber = 1, episodeNumber = 2, fallbackVideoId = "fallback"))
assertEquals("fallback", buildPlaybackVideoId(parentMetaId = "movie", seasonNumber = null, episodeNumber = null, fallbackVideoId = "fallback"))
assertEquals("movie", buildPlaybackVideoId(parentMetaId = "movie", seasonNumber = null, episodeNumber = null, fallbackVideoId = null))
}
private fun entry(
videoId: String,
parentMetaId: String = videoId.substringBefore(':'),
seasonNumber: Int? = null,
episodeNumber: Int? = null,
lastUpdatedEpochMs: Long = 1L,
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
parentMetaId = parentMetaId,
parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
videoId = videoId,
title = "Title",
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastPositionMs = 120_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = lastUpdatedEpochMs,
)
}

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.watchprogress
import kotlinx.cinterop.ExperimentalForeignApi
import platform.posix.time
actual object WatchProgressClock {
@OptIn(ExperimentalForeignApi::class)
actual fun nowEpochMs(): Long = time(null) * 1000L
}

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.watchprogress
import platform.Foundation.NSUserDefaults
actual object WatchProgressStorage {
private const val payloadKey = "watch_progress_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}