mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-18 05:15:41 +00:00
feat: Add pause description handling and context management for improved playback experience
This commit is contained in:
parent
b669c3ddfd
commit
ec2980d70e
8 changed files with 176 additions and 35 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue