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.addons.AddonStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -15,6 +16,7 @@ class MainActivity : ComponentActivity() {
AddonStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext) HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
setContent { setContent {
App() 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.settings.SettingsScreen
import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@ -58,6 +59,8 @@ data class DetailRoute(val type: String, val id: String)
data class StreamRoute( data class StreamRoute(
val type: String, val type: String,
val videoId: String, val videoId: String,
val parentMetaId: String? = null,
val parentMetaType: String? = null,
val title: String, val title: String,
val logo: String? = null, val logo: String? = null,
val poster: String? = null, val poster: String? = null,
@ -66,6 +69,7 @@ data class StreamRoute(
val episodeNumber: Int? = null, val episodeNumber: Int? = null,
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null, val episodeThumbnail: String? = null,
val resumePositionMs: Long? = null,
) )
@Serializable @Serializable
@ -91,12 +95,16 @@ fun AppScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) { ) {
when (tab) { when (tab) {
AppScreenTab.Home -> HomeScreen( AppScreenTab.Home -> HomeScreen(
modifier = modifier, modifier = modifier,
onCatalogClick = onCatalogClick, onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick, onPosterClick = onPosterClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
) )
AppScreenTab.Search -> SearchScreen( AppScreenTab.Search -> SearchScreen(
modifier = modifier, modifier = modifier,
@ -121,12 +129,14 @@ fun App() {
val navController = rememberNavController() val navController = rememberNavController()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
val onPlay: (String, String, String, String?, String?, String?, Int?, Int?, String?, String?) -> Unit = val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, Long?) -> Unit =
{ type, videoId, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail -> { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, resumePositionMs ->
navController.navigate( navController.navigate(
StreamRoute( StreamRoute(
type = type, type = type,
videoId = videoId, videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title, title = title,
logo = logo, logo = logo,
poster = poster, poster = poster,
@ -135,6 +145,7 @@ fun App() {
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail, 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -206,6 +246,8 @@ fun App() {
onPosterClick = { meta -> onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id)) navController.navigate(DetailRoute(type = meta.type, id = meta.id))
}, },
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
) )
} }
@ -235,6 +277,8 @@ fun App() {
StreamsScreen( StreamsScreen(
type = route.type, type = route.type,
videoId = route.videoId, videoId = route.videoId,
parentMetaId = route.parentMetaId ?: route.videoId,
parentMetaType = route.parentMetaType ?: route.type,
title = route.title, title = route.title,
logo = route.logo, logo = route.logo,
poster = route.poster, poster = route.poster,
@ -243,6 +287,7 @@ fun App() {
episodeNumber = route.episodeNumber, episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle, episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail, episodeThumbnail = route.episodeThumbnail,
resumePositionMs = route.resumePositionMs,
onStreamSelected = { stream -> onStreamSelected = { stream ->
val sourceUrl = stream.directPlaybackUrl val sourceUrl = stream.directPlaybackUrl
if (sourceUrl != null) { if (sourceUrl != null) {
@ -256,11 +301,16 @@ fun App() {
seasonNumber = route.seasonNumber, seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber, episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle, episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
streamTitle = stream.streamLabel, streamTitle = stream.streamLabel,
streamSubtitle = stream.streamSubtitle, streamSubtitle = stream.streamSubtitle,
providerName = stream.addonName, providerName = stream.addonName,
providerAddonId = stream.addonId,
contentType = route.type, contentType = route.type,
videoId = route.videoId, 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, seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber, episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle, episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
streamTitle = route.streamTitle, streamTitle = route.streamTitle,
streamSubtitle = route.streamSubtitle, streamSubtitle = route.streamSubtitle,
providerName = route.providerName, providerName = route.providerName,
providerAddonId = route.providerAddonId,
contentType = route.contentType, contentType = route.contentType,
videoId = route.videoId, videoId = route.videoId,
parentMetaId = route.parentMetaId,
parentMetaType = route.parentMetaType,
initialPositionMs = route.initialPositionMs,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
modifier = Modifier.fillMaxSize(), 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.DetailHero
import com.nuvio.app.features.details.components.DetailMetaInfo import com.nuvio.app.features.details.components.DetailMetaInfo
import com.nuvio.app.features.details.components.DetailSeriesContent 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 @Composable
fun MetaDetailsScreen( fun MetaDetailsScreen(
type: String, type: String,
id: String, id: String,
onBack: () -> Unit, 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, modifier: Modifier = Modifier,
) { ) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val screenAlpha = remember(type, id) { Animatable(0f) } val screenAlpha = remember(type, id) { Animatable(0f) }
val requestedMeta = uiState.meta?.takeIf { it.type == type && it.id == id } val requestedMeta = uiState.meta?.takeIf { it.type == type && it.id == id }
val needsFreshLoad = requestedMeta == null && !uiState.isLoading val needsFreshLoad = requestedMeta == null && !uiState.isLoading
@ -107,6 +114,22 @@ fun MetaDetailsScreen(
requestedMeta != null -> { requestedMeta != null -> {
val meta = requestedMeta 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() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
@ -122,19 +145,68 @@ fun MetaDetailsScreen(
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
DetailActionButtons( DetailActionButtons(
playLabel = playButtonLabel,
onPlayClick = { onPlayClick = {
onPlay?.invoke( when {
meta.type, meta.type == "series" && seriesResumeEntry != null -> {
meta.id, onPlay?.invoke(
meta.name, meta.type,
meta.logo, seriesResumeEntry.videoId,
meta.poster, meta.id,
meta.background, meta.type,
null, meta.name,
null, meta.logo,
null, meta.poster,
null, 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( DetailSeriesContent(
meta = meta, meta = meta,
progressByVideoId = watchProgressUiState.byVideoId,
onEpisodeClick = { video -> onEpisodeClick = { video ->
val season = video.season val season = video.season
val episode = video.episode val episode = video.episode
val videoId = if (season != null && episode != null) { val playbackVideoId = buildPlaybackVideoId(
"${meta.id}:${season}:${episode}" parentMetaId = meta.id,
} else { seasonNumber = season,
video.id episodeNumber = episode,
} fallbackVideoId = video.id,
)
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
onPlay?.invoke( onPlay?.invoke(
meta.type, meta.type,
videoId, playbackVideoId,
meta.id,
meta.type,
meta.name, meta.name,
meta.logo, meta.logo,
meta.poster, meta.poster,
@ -163,6 +240,7 @@ fun MetaDetailsScreen(
episode, episode,
video.title, video.title,
video.thumbnail, 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 @Composable
fun DetailActionButtons( fun DetailActionButtons(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playLabel: String = "Play",
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
) { ) {
@ -51,7 +52,7 @@ fun DetailActionButtons(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = "Play", text = playLabel,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }

View file

@ -38,8 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import co.touchlab.kermit.Logger 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.MetaDetails
import com.nuvio.app.features.details.MetaVideo 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") private val log = Logger.withTag("SeriesContent")
@ -47,6 +50,7 @@ private val log = Logger.withTag("SeriesContent")
fun DetailSeriesContent( fun DetailSeriesContent(
meta: MetaDetails, meta: MetaDetails,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeClick: ((MetaVideo) -> Unit)? = null,
) { ) {
val groupedEpisodes = remember(meta.videos) { val groupedEpisodes = remember(meta.videos) {
@ -146,9 +150,16 @@ fun DetailSeriesContent(
verticalArrangement = Arrangement.spacedBy(sizing.cardGap), verticalArrangement = Arrangement.spacedBy(sizing.cardGap),
) { ) {
episodes.forEach { episode -> episodes.forEach { episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
EpisodeCard( EpisodeCard(
video = episode, video = episode,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
sizing = sizing, sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
) )
@ -162,12 +173,13 @@ fun DetailSeriesContent(
private fun EpisodeCard( private fun EpisodeCard(
video: MetaVideo, video: MetaVideo,
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?,
sizing: SeriesContentSizing, sizing: SeriesContentSizing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val cardShape = RoundedCornerShape(sizing.cardRadius) val cardShape = RoundedCornerShape(sizing.cardRadius)
Row( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(sizing.cardHeight) .height(sizing.cardHeight)
@ -180,111 +192,126 @@ private fun EpisodeCard(
) )
.clickable(enabled = onClick != null) { onClick?.invoke() }, .clickable(enabled = onClick != null) { onClick?.invoke() },
) { ) {
// Image area - fixed width matching card height per spec Row(
Box( modifier = Modifier.fillMaxSize(),
modifier = Modifier
.width(sizing.imageWidth)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage // Image area - fixed width matching card height per spec
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
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .width(sizing.imageWidth)
.padding(bottom = 8.dp, end = 4.dp) .fillMaxHeight()
.clip(RoundedCornerShape(sizing.badgeRadius)) .clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
.background(Color.Black.copy(alpha = 0.85f)) ) {
.border( val imageUrl = video.thumbnail ?: fallbackImage
width = 1.dp, if (imageUrl != null) {
color = Color.White.copy(alpha = 0.2f), AsyncImage(
shape = RoundedCornerShape(sizing.badgeRadius), 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( .padding(
horizontal = sizing.badgeHorizontalPadding, start = sizing.contentHorizontalPadding,
vertical = sizing.badgeVerticalPadding, end = sizing.contentHorizontalPadding,
top = sizing.contentVerticalPadding,
bottom = sizing.contentVerticalPadding,
), ),
verticalArrangement = Arrangement.spacedBy(sizing.contentSpacing),
) { ) {
Text( Text(
text = video.episodeBadge(), text = video.title,
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.titleMedium.copy(
fontSize = sizing.badgeTextSize, fontSize = sizing.titleTextSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp, 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 progressEntry
Column( ?.takeIf { it.durationMs > 0L }
modifier = Modifier ?.let { entry ->
.fillMaxHeight() NuvioProgressBar(
.weight(1f) progress = entry.progressFraction,
.padding( modifier = Modifier
start = sizing.contentHorizontalPadding, .align(Alignment.BottomCenter)
end = sizing.contentHorizontalPadding, .padding(horizontal = 12.dp, vertical = 10.dp),
top = sizing.contentVerticalPadding, height = 5.dp,
bottom = sizing.contentVerticalPadding, trackColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.14f),
), fillColor = MaterialTheme.colorScheme.primary,
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,
) )
} }
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.core.ui.NuvioScreen
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.components.HomeCatalogRowSection 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.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonRow 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 @Composable
fun HomeScreen( fun HomeScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) { ) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AddonRepository.initialize() AddonRepository.initialize()
WatchProgressRepository.ensureLoaded()
} }
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homeUiState by HomeRepository.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) { val catalogRefreshKey = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon -> addonsUiState.addons.mapNotNull { addon ->
@ -53,6 +64,16 @@ fun HomeScreen(
) { ) {
when { when {
addonsUiState.addons.none { it.manifest != null } -> { addonsUiState.addons.none { it.manifest != null } -> {
if (continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
item { item {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
@ -63,12 +84,22 @@ fun HomeScreen(
} }
homeUiState.isLoading && homeUiState.sections.isEmpty() -> { homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
if (continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
items(3) { items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
} }
} }
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() -> { homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && continueWatchingItems.isEmpty() -> {
item { item {
HomeEmptyStateCard( HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp), 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( items(
count = homeUiState.sections.size, count = homeUiState.sections.size,
key = { index -> homeUiState.sections[index].key }, 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 seasonNumber: Int? = null,
val episodeNumber: Int? = null, val episodeNumber: Int? = null,
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val streamTitle: String, val streamTitle: String,
val streamSubtitle: String? = null, val streamSubtitle: String? = null,
val providerName: String, val providerName: String,
val providerAddonId: String? = null,
val contentType: String? = null, val contentType: String? = null,
val videoId: String? = null, val videoId: String? = null,
val parentMetaId: String,
val parentMetaType: String,
val initialPositionMs: Long = 0L,
) )
enum class PlayerResizeMode { 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.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,8 +54,13 @@ fun PlayerScreen(
seasonNumber: Int? = null, seasonNumber: Int? = null,
episodeNumber: Int? = null, episodeNumber: Int? = null,
episodeTitle: String? = null, episodeTitle: String? = null,
episodeThumbnail: String? = null,
contentType: String? = null, contentType: String? = null,
videoId: String? = null, videoId: String? = null,
parentMetaId: String,
parentMetaType: String,
providerAddonId: String? = null,
initialPositionMs: Long = 0L,
) { ) {
LockPlayerToLandscape() LockPlayerToLandscape()
EnterImmersivePlayerMode() EnterImmersivePlayerMode()
@ -79,9 +89,72 @@ fun PlayerScreen(
var gestureMessage by remember { mutableStateOf<String?>(null) } var gestureMessage by remember { mutableStateOf<String?>(null) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) } var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var initialLoadCompleted by remember(sourceUrl) { mutableStateOf(false) } 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 backdropArtwork = background ?: poster
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = seasonNumber != null && episodeNumber != null 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 showAudioModal by remember { mutableStateOf(false) }
var showSubtitleModal by remember { mutableStateOf(false) } var showSubtitleModal by remember { mutableStateOf(false) }
@ -160,7 +233,10 @@ fun PlayerScreen(
errorMessage = null errorMessage = null
scrubbingPositionMs = null scrubbingPositionMs = null
initialLoadCompleted = false initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
SubtitleRepository.clear() SubtitleRepository.clear()
WatchProgressRepository.ensureLoaded()
} }
LaunchedEffect(playbackSnapshot.isLoading, playerController) { 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) { LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) {
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) { if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) {
return@LaunchedEffect return@LaunchedEffect
@ -186,6 +271,40 @@ fun PlayerScreen(
pausedOverlayVisible = true 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -268,7 +387,7 @@ fun PlayerScreen(
displayedPositionMs = displayedPositionMs, displayedPositionMs = displayedPositionMs,
metrics = metrics, metrics = metrics,
resizeMode = resizeMode, resizeMode = resizeMode,
onBack = onBack, onBack = onBackWithProgress,
onTogglePlayback = ::togglePlayback, onTogglePlayback = ::togglePlayback,
onSeekBack = { seekBy(-10_000L) }, onSeekBack = { seekBy(-10_000L) },
onSeekForward = { seekBy(10_000L) }, onSeekForward = { seekBy(10_000L) },
@ -300,7 +419,7 @@ fun PlayerScreen(
OpeningOverlay( OpeningOverlay(
artwork = backdropArtwork, artwork = backdropArtwork,
logo = logo, logo = logo,
onBack = onBack, onBack = onBackWithProgress,
horizontalSafePadding = horizontalSafePadding, horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@ -328,7 +447,7 @@ fun PlayerScreen(
if (errorMessage != null) { if (errorMessage != null) {
ErrorModal( ErrorModal(
message = errorMessage.orEmpty(), message = errorMessage.orEmpty(),
onDismiss = onBack, onDismiss = onBackWithProgress,
modifier = Modifier.align(Alignment.Center), 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
@ -58,6 +61,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlin.math.round import kotlin.math.round
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -68,6 +72,8 @@ import kotlin.math.round
fun StreamsScreen( fun StreamsScreen(
type: String, type: String,
videoId: String, videoId: String,
parentMetaId: String,
parentMetaType: String,
title: String, title: String,
logo: String? = null, logo: String? = null,
poster: String? = null, poster: String? = null,
@ -76,17 +82,34 @@ fun StreamsScreen(
episodeNumber: Int? = null, episodeNumber: Int? = null,
episodeTitle: String? = null, episodeTitle: String? = null,
episodeThumbnail: String? = null, episodeThumbnail: String? = null,
resumePositionMs: Long? = null,
onStreamSelected: (StreamItem) -> Unit = {}, onStreamSelected: (StreamItem) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val isEpisode = seasonNumber != null && episodeNumber != null 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) { LaunchedEffect(type, videoId) {
StreamsRepository.load(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) { val heroArtwork = if (isEpisode) {
episodeThumbnail ?: background ?: poster episodeThumbnail ?: background ?: poster
} else { } else {
@ -121,7 +144,7 @@ fun StreamsScreen(
// Main content column // Main content column
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Hero block // Hero block
if (isEpisode && seasonNumber != null && episodeNumber != null) { if (isEpisode) {
EpisodeHeroBlock( EpisodeHeroBlock(
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
@ -160,6 +183,12 @@ fun StreamsScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (effectiveResumePositionMs != null && effectiveResumePositionMs > 0L) {
ResumeBanner(
positionMs = effectiveResumePositionMs,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
ProviderFilterRow( ProviderFilterRow(
groups = uiState.groups, groups = uiState.groups,
selectedFilter = uiState.selectedFilter, 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 // 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 // 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)
}
}