mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-29 12:23:01 +00:00
watchprogress init
This commit is contained in:
parent
6308a7431d
commit
2e7da22c25
21 changed files with 1206 additions and 117 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
actual object WatchProgressClock {
|
||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
internal expect object WatchProgressClock {
|
||||
fun nowEpochMs(): Long
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
internal expect object WatchProgressStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue