feat: Add pause description handling and context management for improved playback experience

This commit is contained in:
tapframe 2026-03-29 15:44:43 +05:30
parent b669c3ddfd
commit ec2980d70e
8 changed files with 176 additions and 35 deletions

View file

@ -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<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
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,

View file

@ -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,
)
},

View file

@ -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,

View file

@ -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),
)
}
}

View file

@ -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(),

View file

@ -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<Long, StreamContext>()
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)
}
}

View file

@ -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,

View file

@ -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,
)