feat: init continue where you left off popup after exiting directly the app from player

This commit is contained in:
tapframe 2026-04-09 20:24:21 +05:30
parent 2844f74503
commit e54f90ee63
13 changed files with 442 additions and 3 deletions

View file

@ -32,6 +32,7 @@ import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ResumePromptStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : ComponentActivity() {
@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() {
TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
EpisodeReleaseNotificationsStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.watchprogress
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object ResumePromptStorage {
private const val preferencesName = "nuvio_resume_prompt"
private const val wasInPlayerKey = "was_in_player"
private const val lastPlayerVideoIdKey = "last_player_video_id"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadWasInPlayer(): Boolean =
preferences?.getBoolean(ProfileScopedKey.of(wasInPlayerKey), false) ?: false
actual fun saveWasInPlayer(value: Boolean) {
preferences?.edit()?.putBoolean(ProfileScopedKey.of(wasInPlayerKey), value)?.apply()
}
actual fun loadLastPlayerVideoId(): String? =
preferences?.getString(ProfileScopedKey.of(lastPlayerVideoIdKey), null)
actual fun saveLastPlayerVideoId(videoId: String?) {
preferences?.edit()?.apply {
if (videoId != null) {
putString(ProfileScopedKey.of(lastPlayerVideoIdKey), videoId)
} else {
remove(ProfileScopedKey.of(lastPlayerVideoIdKey))
}
apply()
}
}
}

View file

@ -79,6 +79,7 @@ import com.nuvio.app.core.ui.NuvioPosterActionSheet
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.configurePlatformImageLoader
import com.nuvio.app.core.ui.NuvioToastHost
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.features.auth.AuthScreen
@ -142,6 +143,7 @@ import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ResumePromptRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watching.application.WatchingActions
@ -450,6 +452,24 @@ private fun MainAppContent(
}
}
var profileSwitchLoading by remember { mutableStateOf(false) }
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
val continueWatchingPreferencesUiState by remember {
ContinueWatchingPreferencesRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(
initialHomeReady,
profileSwitchLoading,
profileState.activeProfile?.profileIndex,
continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) {
if (!initialHomeReady || profileSwitchLoading) return@LaunchedEffect
if (resumePromptItem != null) return@LaunchedEffect
if (continueWatchingPreferencesUiState.showResumePromptOnLaunch) {
resumePromptItem = ResumePromptRepository.consumeResumePrompt()
}
}
LaunchedEffect(navController) {
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
@ -1120,6 +1140,9 @@ private fun MainAppContent(
Box(modifier = Modifier.fillMaxSize())
return@composable
}
LaunchedEffect(launch.videoId) {
launch.videoId?.let { ResumePromptRepository.markPlayerEntered(it) }
}
PlayerScreen(
title = launch.title,
sourceUrl = launch.sourceUrl,
@ -1146,6 +1169,7 @@ private fun MainAppContent(
initialPositionMs = launch.initialPositionMs,
initialProgressFraction = launch.initialProgressFraction,
onBack = {
ResumePromptRepository.markPlayerExitedNormally()
PlayerLaunchStore.remove(route.launchId)
navController.popBackStack()
},
@ -1412,6 +1436,39 @@ private fun MainAppContent(
}
}
NuvioFloatingPrompt(
visible = resumePromptItem != null,
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
title = resumePromptItem?.title.orEmpty(),
subtitle = resumePromptItem?.subtitle.orEmpty(),
progressFraction = resumePromptItem?.progressFraction ?: 0f,
actionLabel = "Resume",
onAction = {
val item = resumePromptItem ?: return@NuvioFloatingPrompt
resumePromptItem = null
onPlay(
item.parentMetaType,
item.videoId,
item.parentMetaId,
item.parentMetaType,
item.title,
item.logo,
item.poster,
item.background,
item.seasonNumber,
item.episodeNumber,
item.episodeTitle,
item.episodeThumbnail,
item.pauseDescription,
item.resumePositionMs,
)
},
onDismiss = { resumePromptItem = null },
modifier = Modifier
.align(Alignment.BottomCenter)
.zIndex(15f),
)
NuvioToastHost(
modifier = Modifier
.align(Alignment.TopCenter)

View file

@ -0,0 +1,246 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
private const val AutoDismissDelayMs = 15_000L
private const val SwipeDismissThreshold = 80f
@Composable
fun NuvioFloatingPrompt(
visible: Boolean,
imageUrl: String?,
title: String,
subtitle: String,
progressFraction: Float,
actionLabel: String,
onAction: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
autoDismissMs: Long = AutoDismissDelayMs,
) {
val visibilityState = remember { MutableTransitionState(false) }
val hapticFeedback = LocalHapticFeedback.current
val dismissWithHaptic = remember(hapticFeedback, onDismiss) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onDismiss()
}
}
val actionWithHaptic = remember(hapticFeedback, onAction) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onAction()
}
}
LaunchedEffect(visible) {
visibilityState.targetState = visible
if (visible) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
if (visible) {
LaunchedEffect(Unit) {
delay(autoDismissMs)
onDismiss()
}
}
val navBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues()
.calculateBottomPadding()
AnimatedVisibility(
visibleState = visibilityState,
modifier = modifier,
enter = fadeIn(tween(400)) + slideInVertically(tween(400)) { it },
exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { it },
) {
var dragOffsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = navBarBottom + 72.dp)
.padding(horizontal = 16.dp)
.offset { IntOffset(0, dragOffsetY.roundToInt().coerceAtLeast(0)) }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragOffsetY > SwipeDismissThreshold) {
dismissWithHaptic()
}
dragOffsetY = 0f
},
onDragCancel = { dragOffsetY = 0f },
) { _, dragAmount ->
dragOffsetY = (dragOffsetY + dragAmount).coerceAtLeast(0f)
}
},
contentAlignment = Alignment.BottomCenter,
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(width = 54.dp, height = 78.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Continue where you left off",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(
onClick = dismissWithHaptic,
modifier = Modifier.size(28.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Dismiss",
modifier = Modifier.size(14.dp),
)
}
FilledIconButton(
onClick = actionWithHaptic,
modifier = Modifier.size(42.dp),
shape = CircleShape,
) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = actionLabel,
modifier = Modifier.size(22.dp),
)
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f))
.padding(1.dp),
) {
LinearProgressIndicator(
progress = { progressFraction.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(999.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
)
}
}
}
}
}
}

View file

@ -31,6 +31,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
showResumePromptOnLaunch: Boolean,
) {
item {
SettingsSection(
@ -76,6 +77,22 @@ internal fun LazyListScope.continueWatchingSettingsContent(
}
}
}
item {
SettingsSection(
title = "ON LAUNCH",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Resume prompt on launch",
description = "Show a popup to continue where you left off when opening the app after leaving from the player.",
checked = showResumePromptOnLaunch,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch,
)
}
}
}
}
@Composable

View file

@ -119,6 +119,7 @@ fun ContinueWatchingSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
}
}

View file

@ -315,6 +315,7 @@ private fun MobileSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = false,
@ -516,6 +517,7 @@ private fun TabletSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = true,

View file

@ -14,6 +14,7 @@ private data class StoredContinueWatchingPreferences(
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
)
object ContinueWatchingPreferencesRepository {
@ -48,7 +49,7 @@ object ContinueWatchingPreferencesRepository {
dismissedNextUpKeys: Set<String>,
) {
ensureLoaded()
_uiState.value = ContinueWatchingPreferencesUiState(
_uiState.value = _uiState.value.copy(
isVisible = isVisible,
style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
@ -79,6 +80,7 @@ object ContinueWatchingPreferencesRepository {
style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
)
} else {
ContinueWatchingPreferencesUiState()
@ -113,6 +115,12 @@ object ContinueWatchingPreferencesRepository {
persist()
}
fun setShowResumePromptOnLaunch(enabled: Boolean) {
ensureLoaded()
_uiState.value = _uiState.value.copy(showResumePromptOnLaunch = enabled)
persist()
}
fun removeDismissedNextUpKeysForContent(contentId: String) {
ensureLoaded()
val normalizedContentId = contentId.trim()
@ -132,6 +140,7 @@ object ContinueWatchingPreferencesRepository {
style = _uiState.value.style,
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
),
),
)

View file

@ -0,0 +1,31 @@
package com.nuvio.app.features.watchprogress
object ResumePromptRepository {
fun markPlayerEntered(videoId: String) {
ResumePromptStorage.saveWasInPlayer(true)
ResumePromptStorage.saveLastPlayerVideoId(videoId)
}
fun markPlayerExitedNormally() {
ResumePromptStorage.saveWasInPlayer(false)
ResumePromptStorage.saveLastPlayerVideoId(null)
}
fun consumeResumePrompt(): ContinueWatchingItem? {
val wasInPlayer = ResumePromptStorage.loadWasInPlayer()
if (!wasInPlayer) return null
val videoId = ResumePromptStorage.loadLastPlayerVideoId()
ResumePromptStorage.saveWasInPlayer(false)
ResumePromptStorage.saveLastPlayerVideoId(null)
if (videoId.isNullOrBlank()) return null
WatchProgressRepository.ensureLoaded()
val entry = WatchProgressRepository.progressForVideo(videoId) ?: return null
if (!entry.isResumable) return null
return entry.toContinueWatchingItem()
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.features.watchprogress
internal expect object ResumePromptStorage {
fun loadWasInPlayer(): Boolean
fun saveWasInPlayer(value: Boolean)
fun loadLastPlayerVideoId(): String?
fun saveLastPlayerVideoId(videoId: String?)
}

View file

@ -164,6 +164,7 @@ data class ContinueWatchingPreferencesUiState(
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
)
internal fun nextUpDismissKey(

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object ResumePromptStorage {
private const val wasInPlayerKey = "nuvio_resume_prompt_was_in_player"
private const val lastPlayerVideoIdKey = "nuvio_resume_prompt_last_player_video_id"
actual fun loadWasInPlayer(): Boolean =
NSUserDefaults.standardUserDefaults.boolForKey(ProfileScopedKey.of(wasInPlayerKey))
actual fun saveWasInPlayer(value: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(value, forKey = ProfileScopedKey.of(wasInPlayerKey))
}
actual fun loadLastPlayerVideoId(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(lastPlayerVideoIdKey))
actual fun saveLastPlayerVideoId(videoId: String?) {
if (videoId != null) {
NSUserDefaults.standardUserDefaults.setObject(videoId, forKey = ProfileScopedKey.of(lastPlayerVideoIdKey))
} else {
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(lastPlayerVideoIdKey))
}
}
}

View file

@ -1,2 +1,2 @@
CURRENT_PROJECT_VERSION=15
MARKETING_VERSION=0.1.0-alpha15
CURRENT_PROJECT_VERSION=16
MARKETING_VERSION=0.1.0-alpha16