From 2e7da22c2511f4e42a423a1cff948d06faa25036 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:28:53 +0530 Subject: [PATCH] watchprogress init --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../WatchProgressClock.android.kt | 5 + .../WatchProgressStorage.android.kt | 25 +++ .../commonMain/kotlin/com/nuvio/app/App.kt | 59 ++++- .../com/nuvio/app/core/ui/NuvioProgressBar.kt | 42 ++++ .../app/features/details/MetaDetailsScreen.kt | 143 ++++++++++-- .../details/components/DetailActionButtons.kt | 3 +- .../details/components/DetailSeriesContent.kt | 207 ++++++++++-------- .../com/nuvio/app/features/home/HomeScreen.kt | 43 +++- .../components/HomeContinueWatchingSection.kt | 134 ++++++++++++ .../nuvio/app/features/player/PlayerModels.kt | 5 + .../nuvio/app/features/player/PlayerScreen.kt | 125 ++++++++++- .../app/features/streams/StreamsScreen.kt | 73 +++++- .../watchprogress/WatchProgressClock.kt | 5 + .../watchprogress/WatchProgressModels.kt | 131 +++++++++++ .../watchprogress/WatchProgressRepository.kt | 122 +++++++++++ .../watchprogress/WatchProgressRules.kt | 70 ++++++ .../watchprogress/WatchProgressStorage.kt | 6 + .../watchprogress/WatchProgressRulesTest.kt | 100 +++++++++ .../watchprogress/WatchProgressClock.ios.kt | 9 + .../watchprogress/WatchProgressStorage.ios.kt | 14 ++ 21 files changed, 1206 insertions(+), 117 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 5dca1bfd..c080183d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.android.kt new file mode 100644 index 00000000..2a6dd455 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.android.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.watchprogress + +actual object WatchProgressClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.android.kt new file mode 100644 index 00000000..03ab2fb3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index d5da9b3c..1e8df0fb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt new file mode 100644 index 00000000..fc711ea0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt @@ -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), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 888dc1bc..63987ef3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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( + { 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" + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 6d77bb61..8c739847 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 5485ac01..f924c19b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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 = 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, - ) - } - } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 8c9ab3e5..e6298e19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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 }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt new file mode 100644 index 00000000..6280d4e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -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, + 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)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 4dcb94be..2c74c79b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fb04269e..0eb7abd2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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(null) } var gestureMessageJob by remember { mutableStateOf(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), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 1a990b75..3e1261db 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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 // --------------------------------------------------------------------------- diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.kt new file mode 100644 index 00000000..13d6ba08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.watchprogress + +internal expect object WatchProgressClock { + fun nowEpochMs(): Long +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt new file mode 100644 index 00000000..d78212ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -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 = emptyList(), +) { + val byVideoId: Map + 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 + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt new file mode 100644 index 00000000..1cf28a43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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 = _uiState.asStateFlow() + + private var hasLoaded = false + private var entriesByVideoId: MutableMap = 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 { + 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), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt new file mode 100644 index 00000000..6ea6bda0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -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 = emptyList(), +) + +internal object WatchProgressCodec { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun decodeEntries(payload: String): List = + runCatching { + json.decodeFromString(payload).entries + }.getOrDefault(emptyList()) + + fun encodeEntries(entries: Collection): 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.resumeEntryForSeries(metaId: String): WatchProgressEntry? = + filter { it.parentMetaId == metaId } + .maxByOrNull { it.lastUpdatedEpochMs } + +internal fun List.continueWatchingEntries( + limit: Int = ContinueWatchingLimit, +): List = + sortedByDescending { it.lastUpdatedEpochMs } + .take(limit) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.kt new file mode 100644 index 00000000..260e8b73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.watchprogress + +internal expect object WatchProgressStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt new file mode 100644 index 00000000..873044b2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -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, + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.ios.kt new file mode 100644 index 00000000..c24cbcc2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressClock.ios.kt @@ -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 +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.ios.kt new file mode 100644 index 00000000..e082bc74 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressStorage.ios.kt @@ -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) + } +}