mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-02 21:54:46 +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.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()
|
||||||
|
|
|
||||||
|
|
@ -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.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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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 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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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