diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6a8e1125..255b8a6f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -55,6 +56,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController @@ -139,9 +142,9 @@ import com.nuvio.app.features.collection.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository -import com.nuvio.app.features.streams.StreamContext -import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.streams.StreamLaunch +import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen @@ -223,23 +226,7 @@ data class FolderDetailRoute(val collectionId: String, val folderId: String) @Serializable 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, - val background: String? = null, - val seasonNumber: Int? = null, - val episodeNumber: Int? = null, - val episodeTitle: String? = null, - val episodeThumbnail: String? = null, - val streamContextId: Long? = null, - val resumePositionMs: Long? = null, - val resumeProgressFraction: Float? = null, - val manualSelection: Boolean = false, - val startFromBeginning: Boolean = false, + val launchId: Long, ) @Serializable @@ -676,11 +663,8 @@ private fun MainAppContent( } } - val streamContextId = pauseDescription - ?.takeIf { it.isNotBlank() } - ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } - navController.navigate( - StreamRoute( + val streamLaunchId = StreamLaunchStore.put( + StreamLaunch( type = type, videoId = videoId, parentMetaId = parentMetaId, @@ -693,13 +677,16 @@ private fun MainAppContent( episodeNumber = episodeNumber, episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, - streamContextId = streamContextId, + pauseDescription = pauseDescription, resumePositionMs = if (startFromBeginning) 0L else resumePositionMs, resumeProgressFraction = targetResumeProgressFraction, manualSelection = manualSelection, startFromBeginning = startFromBeginning, ), ) + navController.navigate( + StreamRoute(launchId = streamLaunchId), + ) } val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = @@ -1078,61 +1065,79 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() - val pauseDescription = remember(route.streamContextId) { - route.streamContextId?.let { contextId -> - StreamContextStore.get(contextId)?.pauseDescription + val launch = remember(route.launchId) { + StreamLaunchStore.get(route.launchId) + } + if (launch == null) { + LaunchedEffect(route.launchId) { + StreamsRepository.clear() + navController.popBackStack() + } + return@composable + } + val pauseDescription = launch.pauseDescription + val lifecycleOwner = backStackEntry + DisposableEffect(lifecycleOwner, route.launchId) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) { + StreamLaunchStore.remove(route.launchId) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } val shouldResolveEpisodeVideoId = - route.parentMetaId != null && - route.seasonNumber != null && - route.episodeNumber != null + launch.parentMetaId != null && + launch.seasonNumber != null && + launch.episodeNumber != null var effectiveVideoId by rememberSaveable( - route.videoId, - route.parentMetaId, - route.seasonNumber, - route.episodeNumber, + launch.videoId, + launch.parentMetaId, + launch.seasonNumber, + launch.episodeNumber, ) { - mutableStateOf(route.videoId) + mutableStateOf(launch.videoId) } var hasResolvedVideoId by rememberSaveable( - route.videoId, - route.parentMetaId, - route.seasonNumber, - route.episodeNumber, + launch.videoId, + launch.parentMetaId, + launch.seasonNumber, + launch.episodeNumber, ) { mutableStateOf(!shouldResolveEpisodeVideoId) } LaunchedEffect( - route.videoId, - route.parentMetaId, - route.parentMetaType, - route.type, - route.seasonNumber, - route.episodeNumber, + launch.videoId, + launch.parentMetaId, + launch.parentMetaType, + launch.type, + launch.seasonNumber, + launch.episodeNumber, ) { - effectiveVideoId = route.videoId + effectiveVideoId = launch.videoId if (!shouldResolveEpisodeVideoId) { hasResolvedVideoId = true return@LaunchedEffect } hasResolvedVideoId = false - val metaType = route.parentMetaType ?: route.type - val metaId = route.parentMetaId ?: return@LaunchedEffect + val metaType = launch.parentMetaType ?: launch.type + val metaId = launch.parentMetaId ?: return@LaunchedEffect val resolvedVideoId = runCatching { MetaDetailsRepository.fetch(metaType, metaId) }.getOrNull() ?.videos ?.firstOrNull { video -> - video.season == route.seasonNumber && - video.episode == route.episodeNumber + video.season == launch.seasonNumber && + video.episode == launch.episodeNumber } ?.id ?.takeIf { it.isNotBlank() } - effectiveVideoId = resolvedVideoId ?: route.videoId + effectiveVideoId = resolvedVideoId ?: launch.videoId hasResolvedVideoId = true } @@ -1142,15 +1147,15 @@ private fun MainAppContent( }.collectAsStateWithLifecycle() // Reuse Last Link: auto-play from cache if enabled (only on first entry) - var reuseHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } + var reuseHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } var reuseNavigated by remember { mutableStateOf(false) } - LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, route.manualSelection) { + LaunchedEffect(effectiveVideoId, hasResolvedVideoId, playerSettings.streamReuseLastLinkEnabled, launch.manualSelection) { if (!hasResolvedVideoId) return@LaunchedEffect if (reuseHandled) return@LaunchedEffect reuseHandled = true - if (route.manualSelection) return@LaunchedEffect + if (launch.manualSelection) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect - val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { @@ -1158,32 +1163,31 @@ private fun MainAppContent( StreamsRepository.clear() val launchId = PlayerLaunchStore.put( PlayerLaunch( - title = route.title, + title = launch.title, sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), - logo = route.logo, - poster = route.poster, - background = route.background, - seasonNumber = route.seasonNumber, - episodeNumber = route.episodeNumber, - episodeTitle = route.episodeTitle, - episodeThumbnail = route.episodeThumbnail, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, streamTitle = cached.streamName, streamSubtitle = null, bingeGroup = cached.bingeGroup, pauseDescription = pauseDescription, providerName = cached.addonName, providerAddonId = cached.addonId, - contentType = route.type, + contentType = launch.type, videoId = effectiveVideoId, - parentMetaId = route.parentMetaId ?: effectiveVideoId, - parentMetaType = route.parentMetaType ?: route.type, - initialPositionMs = route.resumePositionMs ?: 0L, - initialProgressFraction = route.resumeProgressFraction, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + initialPositionMs = launch.resumePositionMs ?: 0L, + initialProgressFraction = launch.resumeProgressFraction, ) ) - route.streamContextId?.let(StreamContextStore::remove) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1191,17 +1195,17 @@ private fun MainAppContent( } val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() - var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } - LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, route.manualSelection) { + var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } + LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) { if (!reuseHandled) return@LaunchedEffect - if (route.manualSelection) return@LaunchedEffect + if (launch.manualSelection) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -1217,33 +1221,32 @@ private fun MainAppContent( } val launchId = PlayerLaunchStore.put( PlayerLaunch( - title = route.title, + title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - logo = route.logo, - poster = route.poster, - background = route.background, - seasonNumber = route.seasonNumber, - episodeNumber = route.episodeNumber, - episodeTitle = route.episodeTitle, - episodeThumbnail = route.episodeThumbnail, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, - contentType = route.type, + contentType = launch.type, videoId = effectiveVideoId, - parentMetaId = route.parentMetaId ?: effectiveVideoId, - parentMetaType = route.parentMetaType ?: route.type, - initialPositionMs = route.resumePositionMs ?: 0L, - initialProgressFraction = route.resumeProgressFraction, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + initialPositionMs = launch.resumePositionMs ?: 0L, + initialProgressFraction = launch.resumeProgressFraction, ) ) StreamsRepository.consumeAutoPlay() - route.streamContextId?.let(StreamContextStore::remove) navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1260,28 +1263,28 @@ private fun MainAppContent( } StreamsScreen( - type = route.type, + type = launch.type, videoId = effectiveVideoId, - parentMetaId = route.parentMetaId ?: effectiveVideoId, - parentMetaType = route.parentMetaType ?: route.type, - title = route.title, - logo = route.logo, - poster = route.poster, - background = route.background, - seasonNumber = route.seasonNumber, - episodeNumber = route.episodeNumber, - episodeTitle = route.episodeTitle, - episodeThumbnail = route.episodeThumbnail, - resumePositionMs = route.resumePositionMs, - resumeProgressFraction = route.resumeProgressFraction, - manualSelection = route.manualSelection, - startFromBeginning = route.startFromBeginning, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + title = launch.title, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + resumePositionMs = launch.resumePositionMs, + resumeProgressFraction = launch.resumeProgressFraction, + manualSelection = launch.manualSelection, + startFromBeginning = launch.startFromBeginning, onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> val sourceUrl = stream.directPlaybackUrl if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -1297,39 +1300,37 @@ private fun MainAppContent( } val launchId = PlayerLaunchStore.put( PlayerLaunch( - title = route.title, + title = launch.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - logo = route.logo, - poster = route.poster, - background = route.background, - seasonNumber = route.seasonNumber, - episodeNumber = route.episodeNumber, - episodeTitle = route.episodeTitle, - episodeThumbnail = route.episodeThumbnail, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, - contentType = route.type, + contentType = launch.type, videoId = effectiveVideoId, - parentMetaId = route.parentMetaId ?: effectiveVideoId, - parentMetaType = route.parentMetaType ?: route.type, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, ) ) - route.streamContextId?.let(StreamContextStore::remove) navController.navigate( PlayerRoute(launchId = launchId) ) } }, onBack = { - route.streamContextId?.let(StreamContextStore::remove) StreamsRepository.clear() navController.popBackStack() }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index ca9f2609..96e2a31e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -18,6 +18,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.search.SearchRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContextStore +import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.core.ui.PosterCardStyleRepository @@ -53,6 +54,7 @@ internal object LocalAccountDataCleaner { SearchRepository.reset() SubtitleRepository.clear() PlayerLaunchStore.clear() + StreamLaunchStore.clear() StreamContextStore.clear() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt new file mode 100644 index 00000000..bed94307 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLaunchStore.kt @@ -0,0 +1,43 @@ +package com.nuvio.app.features.streams + +data class StreamLaunch( + 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, + val background: String? = null, + val seasonNumber: Int? = null, + val episodeNumber: Int? = null, + val episodeTitle: String? = null, + val episodeThumbnail: String? = null, + val pauseDescription: String? = null, + val resumePositionMs: Long? = null, + val resumeProgressFraction: Float? = null, + val manualSelection: Boolean = false, + val startFromBeginning: Boolean = false, +) + +object StreamLaunchStore { + private var nextLaunchId = 1L + private val launches = mutableMapOf() + + fun put(launch: StreamLaunch): Long { + val launchId = nextLaunchId++ + launches[launchId] = launch + return launchId + } + + fun get(launchId: Long): StreamLaunch? = launches[launchId] + + fun remove(launchId: Long) { + launches.remove(launchId) + } + + fun clear() { + nextLaunchId = 1L + launches.clear() + } +} \ No newline at end of file