diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 035104c2d..46c92cd65 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -83,6 +83,8 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository +import com.nuvio.app.features.streams.StreamContext +import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.watched.WatchedRepository @@ -123,6 +125,7 @@ data class StreamRoute( val episodeNumber: Int? = null, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val streamContextId: Long? = null, val resumePositionMs: Long? = null, ) @@ -276,8 +279,11 @@ private fun MainAppContent( WatchedRepository.uiState }.collectAsStateWithLifecycle() - 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 -> + val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = + { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> + val streamContextId = pauseDescription + ?.takeIf { it.isNotBlank() } + ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } navController.navigate( StreamRoute( type = type, @@ -292,6 +298,7 @@ private fun MainAppContent( episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, + streamContextId = streamContextId, resumePositionMs = resumePositionMs, ) ) @@ -311,6 +318,9 @@ private fun MainAppContent( } val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> + val streamContextId = item.pauseDescription + ?.takeIf { it.isNotBlank() } + ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } navController.navigate( StreamRoute( type = item.parentMetaType, @@ -325,6 +335,7 @@ private fun MainAppContent( episodeNumber = item.episodeNumber, episodeTitle = item.episodeTitle, episodeThumbnail = item.episodeThumbnail, + streamContextId = streamContextId, resumePositionMs = item.resumePositionMs, ), ) @@ -438,6 +449,11 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val pauseDescription = remember(route.streamContextId) { + route.streamContextId?.let { contextId -> + StreamContextStore.get(contextId)?.pauseDescription + } + } StreamsScreen( type = route.type, videoId = route.videoId, @@ -468,6 +484,7 @@ private fun MainAppContent( episodeThumbnail = route.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, + pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, contentType = route.type, @@ -477,12 +494,14 @@ private fun MainAppContent( initialPositionMs = route.resumePositionMs ?: 0L, ) ) + route.streamContextId?.let(StreamContextStore::remove) navController.navigate( PlayerRoute(launchId = launchId) ) } }, onBack = { + route.streamContextId?.let(StreamContextStore::remove) StreamsRepository.clear() navController.popBackStack() }, @@ -511,6 +530,7 @@ private fun MainAppContent( episodeThumbnail = launch.episodeThumbnail, streamTitle = launch.streamTitle, streamSubtitle = launch.streamSubtitle, + pauseDescription = launch.pauseDescription, providerName = launch.providerName, providerAddonId = launch.providerAddonId, contentType = launch.contentType, 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 a7e3c5559..2d28ffe45 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 @@ -52,7 +52,7 @@ fun MetaDetailsScreen( type: String, id: String, onBack: () -> Unit, - 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, + 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?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null, modifier: Modifier = Modifier, ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() @@ -136,6 +136,17 @@ fun MetaDetailsScreen( todayIsoDate = CurrentDateProvider.todayIsoDate(), ) } + val seriesPauseDescription = remember(seriesAction, meta.id, meta.videos) { + val action = seriesAction ?: return@remember null + meta.videos.firstOrNull { video -> + buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = video.season, + episodeNumber = video.episode, + fallbackVideoId = video.id, + ) == action.videoId + }?.overview + } val playButtonLabel = remember(movieProgress, seriesAction, meta.type) { when { meta.type == "series" && seriesAction != null -> @@ -203,6 +214,7 @@ fun MetaDetailsScreen( seriesAction.episodeNumber, seriesAction.episodeTitle, seriesAction.episodeThumbnail, + seriesPauseDescription, seriesAction.resumePositionMs, ) } @@ -221,6 +233,7 @@ fun MetaDetailsScreen( null, null, null, + meta.description, movieProgress?.lastPositionMs, ) } @@ -260,6 +273,7 @@ fun MetaDetailsScreen( episode, video.title, video.thumbnail, + video.overview, savedProgress?.lastPositionMs, ) }, 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 e522606bd..d47915669 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 @@ -19,6 +19,7 @@ data class PlayerLaunch( val episodeThumbnail: String? = null, val streamTitle: String, val streamSubtitle: String? = null, + val pauseDescription: String? = null, val providerName: String, val providerAddonId: String? = null, val contentType: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index 3b0bff4dd..2b996dfc7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -1,5 +1,12 @@ package com.nuvio.app.features.player +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -29,11 +36,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -50,10 +59,27 @@ import kotlin.math.max internal fun OpeningOverlay( artwork: String?, logo: String?, + title: String?, onBack: () -> Unit, horizontalSafePadding: Dp, modifier: Modifier = Modifier, ) { + val contentAlpha by animateFloatAsState( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 400, easing = LinearEasing), + label = "openingOverlayContentAlpha", + ) + val pulse = rememberInfiniteTransition(label = "openingOverlayContentPulse") + val contentScale by pulse.animateFloat( + initialValue = 1f, + targetValue = 1.04f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "openingOverlayContentScale", + ) + Box( modifier = modifier .background(Color.Black.copy(alpha = 0.85f)), @@ -108,9 +134,32 @@ internal fun OpeningOverlay( contentDescription = null, modifier = Modifier .width(300.dp) - .height(180.dp), + .height(180.dp) + .graphicsLayer { + alpha = contentAlpha + scaleX = contentScale + scaleY = contentScale + }, contentScale = ContentScale.Fit, ) + } else if (!title.isNullOrBlank()) { + Text( + text = title, + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + style = MaterialTheme.nuvioTypeScale.displayMd.copy( + fontSize = 42.sp, + fontWeight = FontWeight.ExtraBold, + ), + modifier = Modifier + .padding(horizontal = 24.dp) + .graphicsLayer { + alpha = contentAlpha + scaleX = contentScale + scaleY = contentScale + }, + ) } else { CircularProgressIndicator( color = Color(0xFFE50914), @@ -160,11 +209,12 @@ internal fun GestureFeedbackPill( @Composable internal fun PauseMetadataOverlay( title: String, + logo: String?, isEpisode: Boolean, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, - streamSubtitle: String?, + pauseDescription: String?, providerName: String, metrics: PlayerLayoutMetrics, horizontalSafePadding: Dp, @@ -181,12 +231,11 @@ internal fun PauseMetadataOverlay( ), ), ) - .windowInsetsPadding(WindowInsets.safeContent) .padding( - start = 24.dp + horizontalSafePadding, - end = 24.dp + horizontalSafePadding, - top = 24.dp, - bottom = 24.dp, + start = horizontalSafePadding + metrics.horizontalPadding, + end = horizontalSafePadding + metrics.horizontalPadding, + top = 40.dp, + bottom = 120.dp, ), verticalArrangement = Arrangement.Bottom, ) { @@ -196,44 +245,64 @@ internal fun PauseMetadataOverlay( color = Color(0xFFB8B8B8), ) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp)) - Text( - text = title, - style = MaterialTheme.nuvioTypeScale.displayMd.copy( - fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp, - fontWeight = FontWeight.ExtraBold, - ), - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp)) + + if (!logo.isNullOrBlank()) { + AsyncImage( + model = logo, + contentDescription = title, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + modifier = Modifier.height(96.dp), + ) + } else { + Text( + text = title, + style = MaterialTheme.nuvioTypeScale.displayMd.copy( + fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp, + fontWeight = FontWeight.ExtraBold, + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { - buildString { - append("S") - append(seasonNumber) - append(" • E") - append(episodeNumber) - if (!episodeTitle.isNullOrBlank()) { - append(" • ") - append(episodeTitle) - } - } + "S${seasonNumber}E${episodeNumber}" } else { providerName } + Text( text = episodeInfo, style = MaterialTheme.nuvioTypeScale.bodyLg, color = Color(0xFFCCCCCC), + modifier = Modifier.padding(top = 8.dp), ) - if (!streamSubtitle.isNullOrBlank()) { - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(10.dp)) + + if (!episodeTitle.isNullOrBlank()) { Text( - text = streamSubtitle, + text = episodeTitle, + style = MaterialTheme.nuvioTypeScale.titleLg, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp), + ) + } + + if (!pauseDescription.isNullOrBlank()) { + Text( + text = pauseDescription, style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp), color = Color(0xFFD6D6D6), + softWrap = true, + textAlign = TextAlign.Start, maxLines = 3, overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(0.62f), ) } } 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 641ba2ea6..69ecd5ab6 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 @@ -46,6 +46,7 @@ fun PlayerScreen( providerName: String, streamTitle: String, streamSubtitle: String?, + pauseDescription: String? = null, onBack: () -> Unit, modifier: Modifier = Modifier, logo: String? = null, @@ -114,6 +115,7 @@ fun PlayerScreen( providerAddonId, streamTitle, streamSubtitle, + pauseDescription, sourceUrl, ) { WatchProgressPlaybackSession( @@ -138,6 +140,7 @@ fun PlayerScreen( providerAddonId = providerAddonId, lastStreamTitle = streamTitle, lastStreamSubtitle = streamSubtitle, + pauseDescription = pauseDescription, lastSourceUrl = sourceUrl, ) } @@ -448,11 +451,12 @@ fun PlayerScreen( if (pausedOverlayVisible && !controlsVisible) { PauseMetadataOverlay( title = title, + logo = logo, isEpisode = isEpisode, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = episodeTitle, - streamSubtitle = streamSubtitle, + pauseDescription = pauseDescription ?: streamSubtitle, providerName = providerName, metrics = metrics, horizontalSafePadding = horizontalSafePadding, @@ -508,6 +512,7 @@ fun PlayerScreen( OpeningOverlay( artwork = backdropArtwork, logo = logo, + title = title, onBack = onBackWithProgress, horizontalSafePadding = horizontalSafePadding, modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamContextStore.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamContextStore.kt new file mode 100644 index 000000000..40bc24ca6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamContextStore.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.streams + +data class StreamContext( + val pauseDescription: String? = null, +) + +object StreamContextStore { + private var nextContextId = 1L + private val contexts = mutableMapOf() + + fun put(context: StreamContext): Long { + val contextId = nextContextId++ + contexts[contextId] = context + return contextId + } + + fun get(contextId: Long): StreamContext? = contexts[contextId] + + fun remove(contextId: Long) { + contexts.remove(contextId) + } +} 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 index 256bab868..ed8d6ca92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -30,6 +30,7 @@ data class WatchProgressEntry( val providerAddonId: String? = null, val lastStreamTitle: String? = null, val lastStreamSubtitle: String? = null, + val pauseDescription: String? = null, val lastSourceUrl: String? = null, val isCompleted: Boolean = false, ) { @@ -74,6 +75,7 @@ data class WatchProgressPlaybackSession( val providerAddonId: String? = null, val lastStreamTitle: String? = null, val lastStreamSubtitle: String? = null, + val pauseDescription: String? = null, val lastSourceUrl: String? = null, ) @@ -91,6 +93,7 @@ data class ContinueWatchingItem( val episodeNumber: Int? = null, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val pauseDescription: String? = null, val resumePositionMs: Long, val durationMs: Long, val progressFraction: Float, @@ -131,6 +134,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, + pauseDescription = pauseDescription, resumePositionMs = lastPositionMs, durationMs = durationMs, progressFraction = progressFraction, @@ -173,6 +177,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeNumber = nextEpisode.episode, episodeTitle = nextEpisode.title, episodeThumbnail = nextEpisode.thumbnail, + pauseDescription = nextEpisode.overview, resumePositionMs = 0L, durationMs = 0L, progressFraction = 0f, 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 index f2262357b..e7656be6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -105,6 +105,7 @@ object WatchProgressRepository { providerAddonId = cached?.providerAddonId, lastStreamTitle = cached?.lastStreamTitle, lastStreamSubtitle = cached?.lastStreamSubtitle, + pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, isCompleted = entry.duration > 0 && entry.position >= entry.duration, ) @@ -156,6 +157,9 @@ object WatchProgressRepository { logo = meta.logo, episodeTitle = episodeVideo?.title ?: entry.episodeTitle, episodeThumbnail = episodeVideo?.thumbnail ?: entry.episodeThumbnail, + pauseDescription = episodeVideo?.overview + ?: meta.description + ?: entry.pauseDescription, ) } @@ -242,6 +246,7 @@ object WatchProgressRepository { providerAddonId = session.providerAddonId, lastStreamTitle = session.lastStreamTitle, lastStreamSubtitle = session.lastStreamSubtitle, + pauseDescription = session.pauseDescription, lastSourceUrl = session.lastSourceUrl, isCompleted = isCompleted, )