From 29654c5b85af93759f1a05d9fa58100a2f979383 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sun, 22 Mar 2026 20:00:43 +0000 Subject: [PATCH 01/17] Add random episode button to details page --- .../nuvio/tv/ui/screens/detail/HeroSection.kt | 25 ++++++++++++ .../tv/ui/screens/detail/MetaDetailsScreen.kt | 39 ++++++++++++++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt index 650626022f..814ed547d0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt @@ -70,6 +70,7 @@ import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.ui.platform.LocalContext @@ -86,6 +87,8 @@ fun HeroContentSection( nextToWatch: NextToWatch?, onPlayClick: () -> Unit, onPlayLongPress: (() -> Unit)? = null, + onRandomEpisodeClick: ((Video) -> Unit)? = null, + onRandomEpisodeLongPress: ((Video) -> Unit)? = null, isInLibrary: Boolean, onToggleLibrary: () -> Unit, onLibraryLongPress: () -> Unit, @@ -130,6 +133,14 @@ fun HeroContentSection( context = context, rawRes = com.nuvio.tv.R.raw.trailer_play_button ) + val randomEpisode = remember(meta.videos) { + meta.videos + .filter { + it.season != null && it.season > 0 && + it.episode != null && it.episode > 0 + } + .randomOrNull() + } val strCreator = stringResource(R.string.hero_creator) val strDirector = stringResource(R.string.hero_director) val strWriter = stringResource(R.string.hero_writer) @@ -244,6 +255,20 @@ fun HeroContentSection( } ) + if (randomEpisode != null && onRandomEpisodeClick != null && isSeriesApi) { + ActionIconButton( + icon = Icons.Default.Shuffle, + contentDescription = "Play random episode", + onClick = { + onRandomEpisodeClick(randomEpisode) + }, + onLongPress = { + onRandomEpisodeLongPress?.invoke(randomEpisode) + }, + onFocused = onHeroActionFocused + ) + } + ActionIconButton( icon = if (isInLibrary) Icons.Default.Check else null, painter = if (!isInLibrary) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 359e9c3f8e..64d6384a61 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -731,6 +731,7 @@ private fun MetaDetailsContent( var restoreFocusToken by rememberSaveable { mutableIntStateOf(0) } var initialHeroFocusRequested by rememberSaveable(meta.id) { mutableStateOf(false) } var showHeroPlayOptionsDialog by rememberSaveable(meta.id) { mutableStateOf(false) } + var manualPlayEpisode by remember { mutableStateOf(null) } var initialDetailReturnFocusHandled by remember( meta.id, detailReturnEpisodeFocusRequest?.season, @@ -1074,6 +1075,13 @@ private fun MetaDetailsContent( } } + val heroPlayEpisodeClick: (Video) -> Unit = remember(onEpisodeClick) { + { episode -> + markHeroRestore() + onEpisodeClick(episode) + } + } + val episodeClick = remember(onEpisodeClick) { { video: Video -> markEpisodeRestore(video.id) @@ -1225,9 +1233,17 @@ private fun MetaDetailsContent( nextToWatch = nextToWatch, onPlayClick = heroPlayClick, onPlayLongPress = if (showManualPlayOption) { - { showHeroPlayOptionsDialog = true } - } else { - null + { + manualPlayEpisode = heroVideo // normal behavior + showHeroPlayOptionsDialog = true + } + } else null, + onRandomEpisodeClick = { episode -> + heroPlayEpisodeClick(episode) + }, + onRandomEpisodeLongPress = { episode -> + manualPlayEpisode = episode + showHeroPlayOptionsDialog = true }, isInLibrary = isInLibrary, onToggleLibrary = onToggleLibrary, @@ -1539,15 +1555,28 @@ private fun MetaDetailsContent( if (showHeroPlayOptionsDialog) { PlayManualOverrideDialog( title = meta.name, - subtitle = nextToWatch?.displayText ?: stringResource(R.string.hero_play), + subtitle = manualPlayEpisode?.let { episode -> + stringResource( + R.string.hero_play_episode, + episode.season ?: 0, + episode.episode ?: 0 + ) + } ?: nextToWatch?.displayText ?: stringResource(R.string.hero_play), onDismiss = { showHeroPlayOptionsDialog = false }, onPlayManually = { showHeroPlayOptionsDialog = false - heroPlayManualClick() + + val episode = manualPlayEpisode + if (episode != null) { + onEpisodeManualPlayClick(episode) + } else { + heroPlayManualClick() + } } ) } + selectedComment?.let { review -> CommentOverlay( review = review, From 5bc51d17fb47625594609de0a676e4b5a7d48cc3 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sun, 22 Mar 2026 20:48:01 +0000 Subject: [PATCH 02/17] Add random episode button to settings --- .../tv/data/local/LayoutPreferenceDataStore.kt | 11 +++++++++++ .../com/nuvio/tv/ui/screens/detail/HeroSection.kt | 3 ++- .../tv/ui/screens/detail/MetaDetailsScreen.kt | 3 +++ .../tv/ui/screens/detail/MetaDetailsUiState.kt | 1 + .../tv/ui/screens/detail/MetaDetailsViewModel.kt | 13 +++++++++++++ .../ui/screens/settings/LayoutSettingsScreen.kt | 14 ++++++++++++++ .../screens/settings/LayoutSettingsViewModel.kt | 15 +++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 8 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt index 13985af056..026a5ba891 100644 --- a/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt +++ b/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt @@ -61,6 +61,7 @@ class LayoutPreferenceDataStore @Inject constructor( private val posterCardCornerRadiusDpKey = intPreferencesKey("poster_card_corner_radius_dp") private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes") private val detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled") + private val detailPageRandomButtonEnabledKey = booleanPreferencesKey("detail_page_random_button_enabled") private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail") private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content") private val showFullReleaseDateKey = booleanPreferencesKey("show_full_release_date") @@ -196,6 +197,10 @@ class LayoutPreferenceDataStore @Inject constructor( prefs[detailPageTrailerButtonEnabledKey] ?: false } + val detailPageRandomButtonEnabled: Flow = profileFlow { prefs -> + prefs[detailPageRandomButtonEnabledKey] ?: false + } + val preferExternalMetaAddonDetail: Flow = profileFlow { prefs -> prefs[preferExternalMetaAddonDetailKey] ?: false } @@ -393,6 +398,12 @@ class LayoutPreferenceDataStore @Inject constructor( } } + suspend fun setDetailPageRandomButtonEnabled(enabled: Boolean) { + store().edit { prefs -> + prefs[detailPageRandomButtonEnabledKey] = enabled + } + } + suspend fun setPreferExternalMetaAddonDetail(enabled: Boolean) { store().edit { prefs -> prefs[preferExternalMetaAddonDetailKey] = enabled diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt index 814ed547d0..ee447830c6 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt @@ -96,6 +96,7 @@ fun HeroContentSection( isMovieWatchedPending: Boolean, onToggleMovieWatched: () -> Unit, trailerAvailable: Boolean = false, + randomAvailable: Boolean = false, onTrailerClick: () -> Unit = {}, hideLogoDuringTrailer: Boolean = false, mdbListRatings: MDBListRatings? = null, @@ -255,7 +256,7 @@ fun HeroContentSection( } ) - if (randomEpisode != null && onRandomEpisodeClick != null && isSeriesApi) { + if (randomAvailable && randomEpisode != null && onRandomEpisodeClick != null && isSeriesApi) { ActionIconButton( icon = Icons.Default.Shuffle, contentDescription = "Play random episode", diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 64d6384a61..a47819b010 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -492,6 +492,7 @@ fun MetaDetailsScreen( showTrailerControls = uiState.showTrailerControls, hideLogoDuringTrailer = uiState.hideLogoDuringTrailer, trailerButtonEnabled = uiState.trailerButtonEnabled, + randomButtonEnabled = uiState.randomButtonEnabled, trailerSeekToken = trailerSeekToken, trailerSeekDeltaMs = trailerSeekDeltaMs, onTrailerControlKey = { keyCode, action, repeatCount -> @@ -661,6 +662,7 @@ private fun MetaDetailsContent( showTrailerControls: Boolean, hideLogoDuringTrailer: Boolean, trailerButtonEnabled: Boolean, + randomButtonEnabled: Boolean, trailerSeekToken: Int, trailerSeekDeltaMs: Long, onTrailerControlKey: (keyCode: Int, action: Int, repeatCount: Int) -> Boolean, @@ -1259,6 +1261,7 @@ private fun MetaDetailsContent( hideMetaInfoImdb = showMdbListImdb, showFullReleaseDate = showFullReleaseDate, trailerAvailable = trailerButtonEnabled && !trailerUrl.isNullOrBlank(), + randomAvailable = randomButtonEnabled, onTrailerClick = onTrailerButtonClick, hideLogoDuringTrailer = hideLogoDuringTrailer, isTrailerPlaying = isTrailerPlaying, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt index 4ef31dcc5a..c0566d33da 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt @@ -27,6 +27,7 @@ data class MetaDetailsUiState( val showTrailerControls: Boolean = false, val hideLogoDuringTrailer: Boolean = false, val trailerButtonEnabled: Boolean = false, + val randomButtonEnabled: Boolean = false, val librarySourceMode: LibrarySourceMode = LibrarySourceMode.LOCAL, val libraryListTabs: List = emptyList(), val isInWatchlist: Boolean = false, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt index d06d5c2c96..ded9eca2f0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt @@ -146,6 +146,19 @@ class MetaDetailsViewModel @Inject constructor( } } } + viewModelScope.launch { + layoutPreferenceDataStore.detailPageRandomButtonEnabled + .distinctUntilChanged() + .collectLatest { enabled -> + _uiState.update { state -> + if (state.randomButtonEnabled == enabled) { + state + } else { + state.copy(randomButtonEnabled = enabled) + } + } + } + } } private fun observeTraktCommentsAvailability() { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt index b13eac1c35..be310a50b7 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt @@ -412,6 +412,20 @@ fun LayoutSettingsContent( onFocused = { focusedSection = LayoutSettingsSection.DETAIL_PAGE } ) + CompactToggleRow( + title = stringResource(R.string.layout_random_button), + subtitle = stringResource(R.string.layout_random_button_sub), + checked = uiState.detailPageRandomButtonEnabled, + onToggle = { + viewModel.onEvent( + LayoutSettingsEvent.SetDetailPageRandomButtonEnabled( + !uiState.detailPageRandomButtonEnabled + ) + ) + }, + onFocused = { focusedSection = LayoutSettingsSection.DETAIL_PAGE } + ) + CompactToggleRow( title = stringResource(R.string.layout_prefer_external_meta), subtitle = stringResource(R.string.layout_prefer_external_meta_sub), diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt index f0a01c995f..0ee5b1688b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt @@ -41,6 +41,7 @@ data class LayoutSettingsUiState( val posterCardCornerRadiusDp: Int = 12, val blurUnwatchedEpisodes: Boolean = false, val detailPageTrailerButtonEnabled: Boolean = false, + val detailPageRandomButtonEnabled: Boolean = false, val preferExternalMetaAddonDetail: Boolean = false, val hideUnreleasedContent: Boolean = false, val showFullReleaseDate: Boolean = true @@ -75,6 +76,7 @@ sealed class LayoutSettingsEvent { data class SetPosterCardCornerRadius(val cornerRadiusDp: Int) : LayoutSettingsEvent() data class SetBlurUnwatchedEpisodes(val enabled: Boolean) : LayoutSettingsEvent() data class SetDetailPageTrailerButtonEnabled(val enabled: Boolean) : LayoutSettingsEvent() + data class SetDetailPageRandomButtonEnabled(val enabled: Boolean) : LayoutSettingsEvent() data class SetPreferExternalMetaAddonDetail(val enabled: Boolean) : LayoutSettingsEvent() data class SetHideUnreleasedContent(val enabled: Boolean) : LayoutSettingsEvent() data class SetShowFullReleaseDate(val enabled: Boolean) : LayoutSettingsEvent() @@ -211,6 +213,11 @@ class LayoutSettingsViewModel @Inject constructor( updateUiStateIfChanged { it.copy(detailPageTrailerButtonEnabled = enabled) } } } + viewModelScope.launch { + layoutPreferenceDataStore.detailPageRandomButtonEnabled.distinctUntilChanged().collectLatest { enabled -> + updateUiStateIfChanged { it.copy(detailPageRandomButtonEnabled = enabled) } + } + } viewModelScope.launch { layoutPreferenceDataStore.preferExternalMetaAddonDetail.distinctUntilChanged().collectLatest { enabled -> updateUiStateIfChanged { it.copy(preferExternalMetaAddonDetail = enabled) } @@ -252,6 +259,7 @@ class LayoutSettingsViewModel @Inject constructor( is LayoutSettingsEvent.SetPosterCardCornerRadius -> setPosterCardCornerRadius(event.cornerRadiusDp) is LayoutSettingsEvent.SetBlurUnwatchedEpisodes -> setBlurUnwatchedEpisodes(event.enabled) is LayoutSettingsEvent.SetDetailPageTrailerButtonEnabled -> setDetailPageTrailerButtonEnabled(event.enabled) + is LayoutSettingsEvent.SetDetailPageRandomButtonEnabled -> setDetailPageRandomButtonEnabled(event.enabled) is LayoutSettingsEvent.SetPreferExternalMetaAddonDetail -> setPreferExternalMetaAddonDetail(event.enabled) is LayoutSettingsEvent.SetHideUnreleasedContent -> setHideUnreleasedContent(event.enabled) is LayoutSettingsEvent.SetShowFullReleaseDate -> setShowFullReleaseDate(event.enabled) @@ -398,6 +406,13 @@ class LayoutSettingsViewModel @Inject constructor( } } + private fun setDetailPageRandomButtonEnabled(enabled: Boolean) { + if (_uiState.value.detailPageRandomButtonEnabled == enabled) return + viewModelScope.launch { + layoutPreferenceDataStore.setDetailPageRandomButtonEnabled(enabled) + } + } + private fun setBlurUnwatchedEpisodes(enabled: Boolean) { if (_uiState.value.blurUnwatchedEpisodes == enabled) return viewModelScope.launch { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48ca2f5ae9..8c110483b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,8 @@ Blur episode thumbnails until watched to avoid spoilers. Show Trailer Button Show trailer button on detail page (only when trailer is available). + Show Random Episode Button + Show random episode button on detail page. Prefer meta from external addon Use metadata from external addon instead of catalog addon. Show full release date From f9d02969f8732918e89217d990d972ea5deb505e Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Wed, 25 Mar 2026 17:13:18 +0000 Subject: [PATCH 03/17] Pass isRandom to Stream and Player screens --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 67 +++++++++++++- .../java/com/nuvio/tv/ui/navigation/Screen.kt | 14 +-- .../tv/ui/screens/detail/MetaDetailsScreen.kt | 92 ++++++++++++++++++- .../tv/ui/screens/player/PlayerScreen.kt | 3 +- .../tv/ui/screens/stream/StreamScreen.kt | 5 +- 5 files changed, 165 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 6373019d17..d65dc43fbd 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -1,5 +1,6 @@ package com.nuvio.tv.ui.navigation +import android.util.Log import androidx.compose.animation.core.tween import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -302,6 +303,51 @@ fun NuvioNavHost( returnToDetailOnBack = contentType.equals("series", ignoreCase = true) ) ) + }, + onRandomPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, isRandom -> + navController.navigate( + Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = title, + poster = poster, + backdrop = backdrop, + logo = logo, + season = season, + episode = episode, + episodeName = episodeName, + genres = genres, + year = year, + contentId = contentId, + contentName = title, + runtime = runtime, + returnToDetailOnBack = contentType.equals("series", ignoreCase = true), + isRandom = true + ) + ) + }, + onRandomManualPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, isRandom -> + navController.navigate( + Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = title, + poster = poster, + backdrop = backdrop, + logo = logo, + season = season, + episode = episode, + episodeName = episodeName, + genres = genres, + year = year, + contentId = contentId, + contentName = title, + runtime = runtime, + manualSelection = true, + returnToDetailOnBack = contentType.equals("series", ignoreCase = true), + isRandom = true + ) + ) } ) } @@ -386,6 +432,11 @@ fun NuvioNavHost( type = NavType.StringType nullable = true defaultValue = "false" + }, + navArgument("isRandom") { + type = NavType.BoolType + nullable = false + defaultValue = false } ) ) { backStackEntry -> @@ -399,7 +450,9 @@ fun NuvioNavHost( val startFromBeginning = streamArgs ?.getString("startFromBeginning") ?.toBooleanStrictOrNull() == true + val isRandom = streamArgs?.getBoolean("isRandom") ?: false StreamScreen( + isRandom = isRandom, onBackPress = { val streamContentType = streamArgs?.getString("contentType").orEmpty() val streamContentId = streamArgs?.getString("contentId").orEmpty() @@ -459,7 +512,8 @@ fun NuvioNavHost( startFromBeginning = startFromBeginning, addonName = playbackInfo.addonName, addonLogo = playbackInfo.addonLogo, - streamDescription = playbackInfo.streamDescription + streamDescription = playbackInfo.streamDescription, + isRandom = isRandom ) ) } @@ -493,7 +547,8 @@ fun NuvioNavHost( startFromBeginning = startFromBeginning, addonName = playbackInfo.addonName, addonLogo = playbackInfo.addonLogo, - streamDescription = playbackInfo.streamDescription + streamDescription = playbackInfo.streamDescription, + isRandom = isRandom ) ) { popUpTo(Screen.Stream.route) { inclusive = true } @@ -627,10 +682,18 @@ fun NuvioNavHost( type = NavType.StringType nullable = true defaultValue = null + }, + navArgument("isRandom") { + type = NavType.BoolType + nullable = false + defaultValue = false } ) ) { backStackEntry -> + val args = backStackEntry.arguments + val isRandom = args?.getBoolean("isRandom") ?: false PlayerScreen( + isRandom = isRandom, onBackPress = { currentSeason, currentEpisode, autoPlayEnabled -> val args = backStackEntry.arguments val initialSeason = args?.getString("season")?.toIntOrNull() diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt index 6668bad164..158a6049e0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt @@ -22,7 +22,7 @@ sealed class Screen(val route: String) { return "detail/$encodedItemId/$encodedItemType?addonBaseUrl=$encodedAddon&returnFocusSeason=${returnFocusSeason ?: ""}&returnFocusEpisode=${returnFocusEpisode ?: ""}&returnToHomeOnBack=$returnToHomeOnBack" } } - data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}&runtime={runtime}&manualSelection={manualSelection}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&startFromBeginning={startFromBeginning}") { + data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}&runtime={runtime}&manualSelection={manualSelection}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&startFromBeginning={startFromBeginning}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -44,7 +44,8 @@ sealed class Screen(val route: String) { manualSelection: Boolean = false, returnToDetailOnBack: Boolean = false, returnToHomeOnBack: Boolean = false, - startFromBeginning: Boolean = false + startFromBeginning: Boolean = false, + isRandom: Boolean = false ): String { val encodedVideoId = encode(videoId) val encodedContentTypePath = encode(contentType) @@ -57,10 +58,10 @@ sealed class Screen(val route: String) { val encodedYear = year?.let { encode(it) } ?: "" val encodedContentId = contentId?.let { encode(it) } ?: "" val encodedContentName = contentName?.let { encode(it) } ?: "" - return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning" + return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning&isRandom=$isRandom" } } - data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}") { + data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -90,7 +91,8 @@ sealed class Screen(val route: String) { startFromBeginning: Boolean = false, addonName: String? = null, addonLogo: String? = null, - streamDescription: String? = null + streamDescription: String? = null, + isRandom: Boolean = false ): String { val encodedUrl = encode(streamUrl) val encodedTitle = encode(title) @@ -113,7 +115,7 @@ sealed class Screen(val route: String) { val encodedAddonName = addonName?.let { encode(it) } ?: "" val encodedAddonLogo = addonLogo?.let { encode(it) } ?: "" val encodedStreamDescription = streamDescription?.let { encode(it) } ?: "" - return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription" + return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription&isRandom=$isRandom" } } data object Search : Screen("search") diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index a47819b010..22dd35f5a0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -222,7 +222,39 @@ fun MetaDetailsScreen( genres: String?, year: String?, runtime: Int? - ) -> Unit = { _, _, _, _, _, _, _, _, _, _, _, _, _ -> } + ) -> Unit = { _, _, _, _, _, _, _, _, _, _, _, _, _ -> }, + onRandomPlayClick: ( + videoId: String, + contentType: String, + contentId: String, + title: String, + poster: String?, + backdrop: String?, + logo: String?, + season: Int?, + episode: Int?, + episodeName: String?, + genres: String?, + year: String?, + runtime: Int?, + isRandom: Boolean + ) -> Unit = { _, _, _, _, _, _, _, _, _, _, _, _, _, _ -> }, + onRandomManualPlayClick: ( + videoId: String, + contentType: String, + contentId: String, + title: String, + poster: String?, + backdrop: String?, + logo: String?, + season: Int?, + episode: Int?, + episodeName: String?, + genres: String?, + year: String?, + runtime: Int?, + isRandom: Boolean + ) -> Unit = { _, _, _, _, _, _, _, _, _, _, _, _, _, _ -> } ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val effectiveAutoplayEnabled by viewModel.effectiveAutoplayEnabled.collectAsStateWithLifecycle( @@ -466,6 +498,42 @@ fun MetaDetailsScreen( null ) }, + onRandomPlayClick = { video -> + onRandomPlayClick( + video.id, + meta.apiType, + meta.id, + meta.name, + video.thumbnail ?: meta.poster, + meta.backdropUrl, + meta.logo, + video.season, + video.episode, + video.title, + null, + null, + video.runtime, + true + ) + }, + onRandomManualPlayClick = { video -> + onRandomManualPlayClick( + video.id, + meta.apiType, + meta.id, + meta.name, + video.thumbnail ?: meta.poster, + meta.backdropUrl, + meta.logo, + video.season, + video.episode, + video.title, + null, + null, + video.runtime, + true + ) + }, showManualPlayOption = effectiveAutoplayEnabled, onPlayButtonFocused = { viewModel.onEvent(MetaDetailsEvent.OnPlayButtonFocused) }, onToggleLibrary = { viewModel.onEvent(MetaDetailsEvent.OnToggleLibrary) }, @@ -646,6 +714,8 @@ private fun MetaDetailsContent( onEpisodeManualPlayClick: (Video) -> Unit, onPlayClick: (String) -> Unit, onPlayManuallyClick: (String) -> Unit, + onRandomPlayClick: (Video) -> Unit, + onRandomManualPlayClick: (Video) -> Unit, showManualPlayOption: Boolean, onPlayButtonFocused: () -> Unit, onToggleLibrary: () -> Unit, @@ -685,6 +755,7 @@ private fun MetaDetailsContent( meta.videos.firstOrNull { it.id == defaultVideoId && it.available != false } } val nextEpisode = remember(episodesForSeason) { episodesForSeason.firstOrNull() } + var manualPlayIsRandom by remember { mutableStateOf(false) } val heroVideo = remember(meta.videos, nextToWatch, nextEpisode, defaultSeriesVideo, isSeries) { if (!isSeries) return@remember null val byId = nextToWatch?.nextVideoId?.let { id -> @@ -1241,10 +1312,11 @@ private fun MetaDetailsContent( } } else null, onRandomEpisodeClick = { episode -> - heroPlayEpisodeClick(episode) + onRandomPlayClick(episode) }, onRandomEpisodeLongPress = { episode -> manualPlayEpisode = episode + manualPlayIsRandom = true showHeroPlayOptionsDialog = true }, isInLibrary = isInLibrary, @@ -1565,16 +1637,26 @@ private fun MetaDetailsContent( episode.episode ?: 0 ) } ?: nextToWatch?.displayText ?: stringResource(R.string.hero_play), - onDismiss = { showHeroPlayOptionsDialog = false }, + onDismiss = { + showHeroPlayOptionsDialog = false + manualPlayIsRandom = false + manualPlayEpisode = null + }, onPlayManually = { showHeroPlayOptionsDialog = false - val episode = manualPlayEpisode if (episode != null) { - onEpisodeManualPlayClick(episode) + if (manualPlayIsRandom) { + onRandomManualPlayClick(episode) + } else { + onEpisodeManualPlayClick(episode) + } } else { + // Movie case (non‑series) – only occurs when manualPlayIsRandom is false heroPlayManualClick() } + manualPlayEpisode = null + manualPlayIsRandom = false } ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index eed1358c3a..4e4096f3e8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -118,7 +118,8 @@ fun PlayerScreen( viewModel: PlayerViewModel = hiltViewModel(), onBackPress: (currentSeason: Int?, currentEpisode: Int?, autoPlayEnabled: Boolean) -> Unit, onPlaybackErrorBack: () -> Unit = { onBackPress(null, null, false) }, - onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?) -> Unit)? = null + onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?) -> Unit)? = null, + isRandom: Boolean ) { val uiState by viewModel.uiState.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt index 47e46006d4..b58bd9ecae 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt @@ -107,7 +107,8 @@ fun StreamScreen( viewModel: StreamScreenViewModel = hiltViewModel(), onBackPress: () -> Unit, onStreamSelected: (StreamPlaybackInfo) -> Unit, - onAutoPlayResolved: (StreamPlaybackInfo) -> Unit + onAutoPlayResolved: (StreamPlaybackInfo) -> Unit, + isRandom: Boolean ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val playerPreference by viewModel.playerPreference.collectAsStateWithLifecycle( @@ -299,7 +300,7 @@ fun StreamScreen( @Composable private fun StreamBackdrop( backdrop: String?, - isLoading: Boolean + isLoading: Boolean, ) { val context = LocalContext.current val backgroundColor = NuvioColors.Background From 106f56c9cd512625b16f385e26fd3e8524705855 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Wed, 25 Mar 2026 22:54:13 +0000 Subject: [PATCH 04/17] Add Random episode logic for: Next Episode, No Tracking/Scrobbling, Back Button, Error Handling --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 155 ++++++++++++++---- .../nuvio/tv/ui/screens/detail/HeroSection.kt | 36 ++-- .../screens/player/PlayerRuntimeController.kt | 12 +- .../player/PlayerRuntimeControllerMetadata.kt | 20 ++- .../PlayerRuntimeControllerPlaybackEvents.kt | 21 ++- .../player/PlayerRuntimeControllerScrobble.kt | 7 +- .../tv/ui/screens/player/PlayerScreen.kt | 13 +- .../tv/ui/screens/player/PlayerViewModel.kt | 20 +++ 8 files changed, 224 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index d65dc43fbd..91c00b2919 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -454,6 +454,26 @@ fun NuvioNavHost( StreamScreen( isRandom = isRandom, onBackPress = { + if (isRandom) { + // Random mode: go straight to detail and from detail to home on next back + val contentId = streamArgs?.getString("contentId").orEmpty() + if (contentId.isNotBlank()) { + val contentType = streamArgs?.getString("contentType").orEmpty() + navController.navigate( + Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnToHomeOnBack = true // force home on back from detail + ) + ) { + popUpTo(Screen.Stream.route) { inclusive = true } + } + } else { + navController.popBackStack() + } + return@StreamScreen + } val streamContentType = streamArgs?.getString("contentType").orEmpty() val streamContentId = streamArgs?.getString("contentId").orEmpty() val season = streamArgs?.getString("season")?.toIntOrNull() @@ -695,6 +715,26 @@ fun NuvioNavHost( PlayerScreen( isRandom = isRandom, onBackPress = { currentSeason, currentEpisode, autoPlayEnabled -> + if (isRandom) { + // Random mode: go straight to detail and from detail to home on next back + val contentId = args?.getString("contentId").orEmpty() + if (contentId.isNotBlank()) { + val contentType = args?.getString("contentType").orEmpty() + navController.navigate( + Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnToHomeOnBack = true // force home on back from detail + ) + ) { + popUpTo(Screen.Player.route) { inclusive = true } + } + } else { + navController.popBackStack() + } + return@PlayerScreen + } val args = backStackEntry.arguments val initialSeason = args?.getString("season")?.toIntOrNull() val initialEpisode = args?.getString("episode")?.toIntOrNull() @@ -824,7 +864,8 @@ fun NuvioNavHost( contentName = args?.getString("contentName"), runtime = null, returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack + returnToHomeOnBack = returnToHomeOnBack, + isRandom = isRandom ) navController.navigate(route) { popUpTo(Screen.Player.route) { inclusive = true } @@ -837,44 +878,96 @@ fun NuvioNavHost( } } }, - onPlaybackErrorBack = { - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { + onPlaybackErrorBack = { videoId, season, episode, episodeTitle -> + if (isRandom) { val args = backStackEntry.arguments - val videoId = args?.getString("videoId").orEmpty() val contentType = args?.getString("contentType").orEmpty() val title = args?.getString("title").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val contentName = args?.getString("contentName") + val poster = args?.getString("poster") + val backdrop = args?.getString("backdrop") + val logo = args?.getString("logo") + val year = args?.getString("year") + val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - if (videoId.isBlank() || contentType.isBlank() || title.isBlank()) { - navController.popBackStack() - } else { - val route = Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = title, - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = args?.getString("season")?.toIntOrNull(), - episode = args?.getString("episode")?.toIntOrNull(), - episodeName = args?.getString("episodeTitle"), - genres = null, - year = args?.getString("year"), - contentId = args?.getString("contentId"), - contentName = args?.getString("contentName"), - runtime = null, - manualSelection = true, - returnToDetailOnBack = args?.getString("returnToDetailOnBack") - ?.toBooleanStrictOrNull() == true, - returnToHomeOnBack = args?.getString("returnToHomeOnBack") - ?.toBooleanStrictOrNull() == true - ) + val streamRoute = Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = title, + poster = poster, + backdrop = backdrop, + logo = logo, + season = season, + episode = episode, + episodeName = episodeTitle, + year = year, + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = contentName, + manualSelection = true, + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack, + isRandom = true + ) - navController.navigate(route) { - popUpTo(Screen.Player.route) { inclusive = true } + val detailRoute = Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnToHomeOnBack = true + ) + + var poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) + + if (!poppedToDetail) { + navController.navigate(detailRoute) { + popUpTo(0) { inclusive = true } launchSingleTop = true } } + + navController.navigate(streamRoute) { + launchSingleTop = true + } + } else { + val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + if (!returnedToStream) { + val args = backStackEntry.arguments + val videoId = args?.getString("videoId").orEmpty() + val contentType = args?.getString("contentType").orEmpty() + val title = args?.getString("title").orEmpty() + + if (videoId.isBlank() || contentType.isBlank() || title.isBlank()) { + navController.popBackStack() + } else { + val route = Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = title, + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = args?.getString("season")?.toIntOrNull(), + episode = args?.getString("episode")?.toIntOrNull(), + episodeName = args?.getString("episodeTitle"), + genres = null, + year = args?.getString("year"), + contentId = args?.getString("contentId"), + contentName = args?.getString("contentName"), + runtime = null, + manualSelection = true, + returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true, + returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true, + isRandom = false + ) + + navController.navigate(route) { + popUpTo(Screen.Player.route) { inclusive = true } + launchSingleTop = true + } + } + } } } ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt index ee447830c6..08cfd48d53 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.painter.Painter import coil.decode.SvgDecoder import coil.request.ImageRequest +import com.nuvio.tv.ui.screens.player.PlayerNextEpisodeRules import java.util.Locale @OptIn(ExperimentalTvMaterial3Api::class) @@ -136,9 +137,10 @@ fun HeroContentSection( ) val randomEpisode = remember(meta.videos) { meta.videos - .filter { - it.season != null && it.season > 0 && - it.episode != null && it.episode > 0 + .filter { video -> + video.season != null && video.season > 0 && + video.episode != null && video.episode > 0 && + PlayerNextEpisodeRules.hasEpisodeAired(video.released) } .randomOrNull() } @@ -256,20 +258,6 @@ fun HeroContentSection( } ) - if (randomAvailable && randomEpisode != null && onRandomEpisodeClick != null && isSeriesApi) { - ActionIconButton( - icon = Icons.Default.Shuffle, - contentDescription = "Play random episode", - onClick = { - onRandomEpisodeClick(randomEpisode) - }, - onLongPress = { - onRandomEpisodeLongPress?.invoke(randomEpisode) - }, - onFocused = onHeroActionFocused - ) - } - ActionIconButton( icon = if (isInLibrary) Icons.Default.Check else null, painter = if (!isInLibrary) { @@ -304,6 +292,20 @@ fun HeroContentSection( ) } + if (randomAvailable && randomEpisode != null && onRandomEpisodeClick != null && isSeriesApi) { + ActionIconButton( + icon = Icons.Default.Shuffle, + contentDescription = "Play random episode", + onClick = { + onRandomEpisodeClick(randomEpisode) + }, + onLongPress = { + onRandomEpisodeLongPress?.invoke(randomEpisode) + }, + onFocused = onHeroActionFocused + ) + } + if (trailerAvailable) { ActionIconButtonPainter( painter = trailerPainter, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 83fe3c4ee3..6eb89512e6 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -137,6 +137,14 @@ class PlayerRuntimeController( fun getCurrentStreamUrl(): String = currentStreamUrl fun getCurrentHeaders(): Map = currentHeaders + fun setIsRandom(random: Boolean) { + if (isRandom != random) { + isRandom = random + // Re‑evaluate the next episode now that the mode has changed + recomputeNextEpisode(resetVisibility = true) + } + } + fun stopAndRelease() { releasePlayer() } @@ -225,7 +233,7 @@ class PlayerRuntimeController( internal var nextEpisodeThresholdMinutesBeforeEndSetting: Float = 2f internal var currentStreamBingeGroup: String? = navigationArgs.bingeGroup internal var hasInitializedAudioAmplificationForSession: Boolean = false - + internal var isRandom: Boolean = false internal var lastBufferLogTimeMs: Long = 0L internal val gainAudioProcessor = GainAudioProcessor() @@ -261,7 +269,7 @@ class PlayerRuntimeController( } init { - if (!navigationArgs.startFromBeginning) { + if (!isRandom && !navigationArgs.startFromBeginning) { loadSavedProgressFor(currentSeason, currentEpisode) } fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt index e3b46346d1..ce7ff2760b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt @@ -99,11 +99,21 @@ internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boole return } - val resolvedNext = PlayerNextEpisodeRules.resolveNextEpisode( - videos = metaVideos, - currentSeason = season, - currentEpisode = episode - ) + val resolvedNext = if (isRandom) { + val candidates = metaVideos.filter { video -> + video.season != null && video.season > 0 && // exclude season 0 + video.episode != null && video.episode > 0 && // valid episode + (video.season != season || video.episode != episode) && + PlayerNextEpisodeRules.hasEpisodeAired(video.released) + } + if (candidates.isNotEmpty()) candidates.random() else null + } else { + PlayerNextEpisodeRules.resolveNextEpisode( + videos = metaVideos, + currentSeason = season, + currentEpisode = episode + ) + } nextEpisodeVideo = resolvedNext if (resolvedNext == null) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index 8fd906983e..43df3de339 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -75,6 +75,7 @@ internal fun PlayerRuntimeController.stopProgressUpdates() { } internal fun PlayerRuntimeController.startWatchProgressSaving() { + if (isRandom) return watchProgressSaveJob?.cancel() watchProgressSaveJob = scope.launch { while (isActive) { @@ -90,6 +91,7 @@ internal fun PlayerRuntimeController.stopWatchProgressSaving() { } internal fun PlayerRuntimeController.saveWatchProgressIfNeeded() { + if (isRandom) return if (!hasRenderedFirstFrame) return val currentPosition = _exoPlayer?.currentPosition ?: return val duration = getEffectiveDuration(currentPosition) @@ -102,6 +104,7 @@ internal fun PlayerRuntimeController.saveWatchProgressIfNeeded() { } internal fun PlayerRuntimeController.saveWatchProgress() { + if (isRandom) return if (!hasRenderedFirstFrame) return val currentPosition = _exoPlayer?.currentPosition ?: return val duration = getEffectiveDuration(currentPosition) @@ -120,7 +123,8 @@ internal fun PlayerRuntimeController.getEffectiveDuration(position: Long): Long } internal fun PlayerRuntimeController.saveWatchProgressInternal(position: Long, duration: Long, syncRemote: Boolean = true) { - + if (isRandom) return + if (contentId.isNullOrEmpty() || contentType.isNullOrEmpty()) return if (position < 1000) return @@ -158,6 +162,14 @@ internal fun PlayerRuntimeController.currentPlaybackProgressPercent(): Float { } internal fun PlayerRuntimeController.refreshScrobbleItem() { + if (isRandom) { + currentScrobbleItem = null + hasSentScrobbleStartForCurrentItem = false + hasRequestedScrobbleStartForCurrentItem = false + scrobbleStartRequestGeneration++ + hasSentCompletionScrobbleForCurrentItem = false + return + } currentScrobbleItem = buildScrobbleItem() hasSentScrobbleStartForCurrentItem = false hasRequestedScrobbleStartForCurrentItem = false @@ -166,6 +178,7 @@ internal fun PlayerRuntimeController.refreshScrobbleItem() { } internal fun PlayerRuntimeController.buildScrobbleItem(): TraktScrobbleItem? { + if (isRandom) return null val rawContentId = contentId ?: return null val parsedIds = parseContentIds(rawContentId) val ids = toTraktIds(parsedIds) @@ -203,6 +216,7 @@ internal fun PlayerRuntimeController.buildScrobbleItem(): TraktScrobbleItem? { } internal fun PlayerRuntimeController.emitScrobbleStart() { + if (isRandom) return val item = currentScrobbleItem ?: buildScrobbleItem().also { currentScrobbleItem = it } if (item == null) return if (hasRequestedScrobbleStartForCurrentItem) return @@ -221,6 +235,7 @@ internal fun PlayerRuntimeController.emitScrobbleStart() { } internal fun PlayerRuntimeController.emitScrobbleStop(progressPercent: Float? = null) { + if (isRandom) return val item = currentScrobbleItem if (item == null) return @@ -240,6 +255,7 @@ internal fun PlayerRuntimeController.emitScrobbleStop(progressPercent: Float? = } internal fun PlayerRuntimeController.emitPauseScrobbleStop(progressPercent: Float) { + if (isRandom) return if (progressPercent < 1f || progressPercent >= 80f) return val item = currentScrobbleItem if (item == null) return @@ -257,6 +273,7 @@ internal fun PlayerRuntimeController.emitPauseScrobbleStop(progressPercent: Floa } internal fun PlayerRuntimeController.emitCompletionScrobbleStop(progressPercent: Float) { + if (isRandom) return if (progressPercent < 80f || hasSentCompletionScrobbleForCurrentItem) return hasSentCompletionScrobbleForCurrentItem = true emitScrobbleStop(progressPercent = progressPercent) @@ -269,11 +286,13 @@ internal fun PlayerRuntimeController.emitStopScrobbleForCurrentProgress() { } internal fun PlayerRuntimeController.flushPlaybackSnapshotForSwitchOrExit() { + if (isRandom) return emitStopScrobbleForCurrentProgress() saveWatchProgress() } internal fun PlayerRuntimeController.scheduleProgressSyncAfterSeek() { + if (isRandom) return seekProgressSyncJob?.cancel() seekProgressSyncJob = scope.launch { delay(seekProgressSyncDebounceMs) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt index db9de66d1b..57a99bdc1e 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt @@ -32,13 +32,18 @@ internal fun PlayerRuntimeController.preparePlaybackBeforeStart( ) } initializePlayer(url, headers) - if (loadSavedProgress) { + if (loadSavedProgress && !isRandom) { loadSavedProgressFor(currentSeason, currentEpisode) } } } internal suspend fun PlayerRuntimeController.warmTraktEpisodeMappingForCurrentPlayback() { + if (isRandom) { + currentTraktEpisodeMapping = null + currentTraktEpisodeMappingKey = null + return + } val normalizedType = contentType?.lowercase() if (normalizedType !in listOf("series", "tv")) { currentTraktEpisodeMapping = null diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index 4e4096f3e8..4c6e0b8984 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -117,10 +117,13 @@ import kotlinx.coroutines.delay fun PlayerScreen( viewModel: PlayerViewModel = hiltViewModel(), onBackPress: (currentSeason: Int?, currentEpisode: Int?, autoPlayEnabled: Boolean) -> Unit, - onPlaybackErrorBack: () -> Unit = { onBackPress(null, null, false) }, + onPlaybackErrorBack: (videoId: String, season: Int?, episode: Int?, episodeTitle: String?) -> Unit = { _, _, _, _ -> onBackPress(null, null, false) }, onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?) -> Unit)? = null, isRandom: Boolean ) { + LaunchedEffect(isRandom) { + viewModel.setIsRandom(isRandom) + } val uiState by viewModel.uiState.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val containerFocusRequester = remember { FocusRequester() } @@ -138,7 +141,8 @@ fun PlayerScreen( } val exitPlayerFromError: () -> Unit = { viewModel.stopAndRelease() - onPlaybackErrorBack() + val info = viewModel.getCurrentEpisodeInfo() + onPlaybackErrorBack(info.videoId, info.season, info.episode, info.episodeTitle) } val handleBackPress = { @@ -606,7 +610,10 @@ fun PlayerScreen( if (uiState.error != null) { ErrorOverlay( message = uiState.error!!, - onBack = exitPlayerFromError + onBack = { + val info = viewModel.getCurrentEpisodeInfo() + onPlaybackErrorBack(info.videoId, info.season, info.episode, info.episodeTitle) + } ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index 8f60dc18be..cde5425996 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -63,6 +63,26 @@ class PlayerViewModel @Inject constructor( scope = viewModelScope ) + data class CurrentEpisodeInfo( + val videoId: String, + val season: Int?, + val episode: Int?, + val episodeTitle: String? + ) + + fun getCurrentEpisodeInfo(): CurrentEpisodeInfo { + return CurrentEpisodeInfo( + videoId = controller.currentVideoId ?: "", + season = controller.currentSeason, + episode = controller.currentEpisode, + episodeTitle = controller.currentEpisodeTitle + ) + } + + fun setIsRandom(random: Boolean) { + controller.setIsRandom(random) + } + val uiState: StateFlow get() = controller.uiState From a86ae147ebb7e7492fe5946a931142ddb9fcd04c Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Thu, 26 Mar 2026 14:17:27 +0000 Subject: [PATCH 05/17] Update random episode playback ended route --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 325 ++++++++++++------ 1 file changed, 211 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 91c00b2919..4fdee5da55 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -716,103 +716,100 @@ fun NuvioNavHost( isRandom = isRandom, onBackPress = { currentSeason, currentEpisode, autoPlayEnabled -> if (isRandom) { - // Random mode: go straight to detail and from detail to home on next back + val args = backStackEntry.arguments + val initialSeason = args?.getString("season")?.toIntOrNull() + val initialEpisode = args?.getString("episode")?.toIntOrNull() + val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && + (currentSeason != initialSeason || currentEpisode != initialEpisode) + val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true + val contentType = args?.getString("contentType").orEmpty() val contentId = args?.getString("contentId").orEmpty() - if (contentId.isNotBlank()) { - val contentType = args?.getString("contentType").orEmpty() - navController.navigate( - Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnToHomeOnBack = true // force home on back from detail - ) - ) { - popUpTo(Screen.Player.route) { inclusive = true } - } - } else { - navController.popBackStack() - } - return@PlayerScreen - } - val args = backStackEntry.arguments - val initialSeason = args?.getString("season")?.toIntOrNull() - val initialEpisode = args?.getString("episode")?.toIntOrNull() - val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && - (currentSeason != initialSeason || currentEpisode != initialEpisode) - val returnToDetailOnBack = args?.getString("returnToDetailOnBack") - ?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack") - ?.toBooleanStrictOrNull() == true - val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val focusSeason = currentSeason ?: initialSeason - val focusEpisode = currentEpisode ?: initialEpisode + val focusSeason = currentSeason ?: initialSeason + val focusEpisode = currentEpisode ?: initialEpisode - when { - episodeChangedInPlace && autoPlayEnabled -> { - // autoplay moved to next episode — skip Stream, go to detail - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - val detailOnStack = navController.previousBackStackEntry - ?.destination?.route?.startsWith("detail/") == true - if (detailOnStack) { - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) - navController.popBackStack() - } else { + when { + episodeChangedInPlace && autoPlayEnabled -> { + if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { navController.navigate( Screen.Detail.createRoute( itemId = contentId, itemType = contentType, addonBaseUrl = null, - returnFocusSeason = focusSeason, - returnFocusEpisode = focusEpisode, - returnToHomeOnBack = returnToHomeOnBack + returnToHomeOnBack = true + ) + ) { + popUpTo(0) { inclusive = true } + } + } else { + navController.popBackStack() + } + } + episodeChangedInPlace && !autoPlayEnabled -> { + val videoId = args?.getString("videoId").orEmpty() + if (videoId.isNotBlank() && contentType.isNotBlank()) { + navController.navigate( + Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = focusSeason, + episode = focusEpisode, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack, + isRandom = true ) ) { - popUpTo(Screen.Player.route) { inclusive = true } + popUpTo(Screen.Stream.route) { inclusive = true } launchSingleTop = true } + } else { + navController.popBackStack() } - } else { - navController.popBackStack() } - } - episodeChangedInPlace && !autoPlayEnabled -> { - // manual stream switch to next episode — go to Stream of current episode - val videoId = args?.getString("videoId").orEmpty() - if (videoId.isNotBlank() && contentType.isNotBlank()) { - navController.navigate( - Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = args?.getString("title").orEmpty(), - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = focusSeason, - episode = focusEpisode, - year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = args?.getString("contentName"), - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack - ) - ) { - popUpTo(Screen.Stream.route) { inclusive = true } - launchSingleTop = true + else -> { + val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + if (!returnedToStream) { + if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + navController.navigate( + Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnToHomeOnBack = true + ) + ) { + popUpTo(0) { inclusive = true } + } + } else { + navController.popBackStack() + } } - } else { - navController.popBackStack() } } - else -> { - // normal back — try returning to Stream of this episode - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { + } else { + val args = backStackEntry.arguments + val initialSeason = args?.getString("season")?.toIntOrNull() + val initialEpisode = args?.getString("episode")?.toIntOrNull() + val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && + (currentSeason != initialSeason || currentEpisode != initialEpisode) + val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true + val contentType = args?.getString("contentType").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val focusSeason = currentSeason ?: initialSeason + val focusEpisode = currentEpisode ?: initialEpisode + + when { + episodeChangedInPlace && autoPlayEnabled -> { if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - val detailOnStack = navController.previousBackStackEntry - ?.destination?.route?.startsWith("detail/") == true + val detailOnStack = navController.previousBackStackEntry?.destination?.route?.startsWith("detail/") == true if (detailOnStack) { navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) @@ -836,45 +833,146 @@ fun NuvioNavHost( navController.popBackStack() } } + episodeChangedInPlace && !autoPlayEnabled -> { + val videoId = args?.getString("videoId").orEmpty() + if (videoId.isNotBlank() && contentType.isNotBlank()) { + navController.navigate( + Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = focusSeason, + episode = focusEpisode, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack + ) + ) { + popUpTo(Screen.Stream.route) { inclusive = true } + launchSingleTop = true + } + } else { + navController.popBackStack() + } + } + else -> { + val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + if (!returnedToStream) { + if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + val detailOnStack = navController.previousBackStackEntry?.destination?.route?.startsWith("detail/") == true + if (detailOnStack) { + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) + navController.popBackStack() + } else { + navController.navigate( + Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnFocusSeason = focusSeason, + returnFocusEpisode = focusEpisode, + returnToHomeOnBack = returnToHomeOnBack + ) + ) { + popUpTo(Screen.Player.route) { inclusive = true } + launchSingleTop = true + } + } + } else { + navController.popBackStack() + } + } + } } } }, onPlaybackEnded = { nextVideoId, nextSeason, nextEpisode -> - val args = backStackEntry.arguments - val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val returnToDetailOnBack = args?.getString("returnToDetailOnBack") - ?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack") - ?.toBooleanStrictOrNull() == true - if (nextVideoId != null && nextSeason != null && nextEpisode != null) { - val route = Screen.Stream.createRoute( - videoId = nextVideoId, - contentType = contentType, - title = args?.getString("title").orEmpty(), - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = nextSeason, - episode = nextEpisode, - episodeName = null, - genres = null, - year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = args?.getString("contentName"), - runtime = null, - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack, - isRandom = isRandom - ) - navController.navigate(route) { - popUpTo(Screen.Player.route) { inclusive = true } + if (isRandom) { + val args = backStackEntry.arguments + val contentType = args?.getString("contentType").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true + + if (nextVideoId != null && nextSeason != null && nextEpisode != null) { + val route = Screen.Stream.createRoute( + videoId = nextVideoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = nextSeason, + episode = nextEpisode, + episodeName = null, + genres = null, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + runtime = null, + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack, + isRandom = true + ) + navController.navigate(route) { + popUpTo(Screen.Player.route) { inclusive = true } + } + } else { + if (contentId.isNotBlank()) { + navController.navigate( + Screen.Detail.createRoute( + itemId = contentId, + itemType = contentType, + addonBaseUrl = null, + returnToHomeOnBack = true + ) + ) { + popUpTo(0) { inclusive = true } + } + } else { + navController.popBackStack() + } } } else { - // No next episode — pop back to detail (or home if detail not on stack) - val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) - if (!poppedToDetail) { - navController.popBackStack(Screen.Stream.route, inclusive = true) + val args = backStackEntry.arguments + val contentType = args?.getString("contentType").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true + if (nextVideoId != null && nextSeason != null && nextEpisode != null) { + val route = Screen.Stream.createRoute( + videoId = nextVideoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = nextSeason, + episode = nextEpisode, + episodeName = null, + genres = null, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + runtime = null, + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack, + isRandom = isRandom + ) + navController.navigate(route) { + popUpTo(Screen.Player.route) { inclusive = true } + } + } else { + val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) + if (!poppedToDetail) { + navController.popBackStack(Screen.Stream.route, inclusive = true) + } } } }, @@ -919,7 +1017,6 @@ fun NuvioNavHost( ) var poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) - if (!poppedToDetail) { navController.navigate(detailRoute) { popUpTo(0) { inclusive = true } From 86d3cfa0e2a3ea1e8cf9f1fdf62317aceba8eb05 Mon Sep 17 00:00:00 2001 From: DeclanSC <140628807+DeclanSC@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:07:37 +0100 Subject: [PATCH 06/17] Update StreamScreen.kt Remove extra comma --- .../main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt index 10e4f80113..8f8f27742f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt @@ -300,7 +300,7 @@ fun StreamScreen( @Composable private fun StreamBackdrop( backdrop: String?, - isLoading: Boolean, + isLoading: Boolean ) { val context = LocalContext.current val backgroundColor = NuvioColors.Background From b56d7791b1ab77daebdc9e5fe15d7767b40f2d78 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sat, 25 Apr 2026 18:08:50 +0100 Subject: [PATCH 07/17] Update random episode branch --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 451 ++++++------------ .../java/com/nuvio/tv/ui/navigation/Screen.kt | 18 +- .../nuvio/tv/ui/screens/detail/HeroSection.kt | 2 - .../tv/ui/screens/player/PlayerScreen.kt | 10 +- 4 files changed, 143 insertions(+), 338 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index e50963263b..52c2e664e0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -1,6 +1,5 @@ package com.nuvio.tv.ui.navigation -import android.util.Log import androidx.compose.animation.core.tween import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -482,26 +481,6 @@ fun NuvioNavHost( StreamScreen( isRandom = isRandom, onBackPress = { - if (isRandom) { - // Random mode: go straight to detail and from detail to home on next back - val contentId = streamArgs?.getString("contentId").orEmpty() - if (contentId.isNotBlank()) { - val contentType = streamArgs?.getString("contentType").orEmpty() - navController.navigate( - Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnToHomeOnBack = true // force home on back from detail - ) - ) { - popUpTo(Screen.Stream.route) { inclusive = true } - } - } else { - navController.popBackStack() - } - return@StreamScreen - } val streamContentType = streamArgs?.getString("contentType").orEmpty() val streamContentId = streamArgs?.getString("contentId").orEmpty() val season = streamArgs?.getString("season")?.toIntOrNull() @@ -509,8 +488,13 @@ fun NuvioNavHost( if (streamContentType.equals("series", ignoreCase = true) && streamContentId.isNotBlank()) { val detailEntry = runCatching { navController.getBackStackEntry(Screen.Detail.route) }.getOrNull() if (detailEntry != null) { - detailEntry.savedStateHandle["returnFocusSeason"] = season - detailEntry.savedStateHandle["returnFocusEpisode"] = episode + if (!isRandom) { + detailEntry.savedStateHandle["returnFocusSeason"] = season + detailEntry.savedStateHandle["returnFocusEpisode"] = episode + } else { + detailEntry.savedStateHandle.remove("returnFocusSeason") + detailEntry.savedStateHandle.remove("returnFocusEpisode") + } navController.popBackStack(Screen.Detail.route, inclusive = false) } else { navController.navigate( @@ -571,8 +555,7 @@ fun NuvioNavHost( infoHash = effectiveInfoHash, fileIdx = effectiveFileIdx, sources = playbackInfo.sources, - contentLanguage = playbackInfo.contentLanguage - streamDescription = playbackInfo.streamDescription, + contentLanguage = playbackInfo.contentLanguage, isRandom = isRandom ) ) @@ -615,8 +598,7 @@ fun NuvioNavHost( infoHash = effectiveInfoHash, fileIdx = effectiveFileIdx, sources = playbackInfo.sources, - contentLanguage = playbackInfo.contentLanguage - streamDescription = playbackInfo.streamDescription, + contentLanguage = playbackInfo.contentLanguage, isRandom = isRandom ) ) { @@ -769,104 +751,90 @@ fun NuvioNavHost( PlayerScreen( isRandom = isRandom, onBackPress = { currentVideoId, currentSeason, currentEpisode, autoPlayEnabled -> - if (isRandom) { - val args = backStackEntry.arguments - val initialSeason = args?.getString("season")?.toIntOrNull() - val initialEpisode = args?.getString("episode")?.toIntOrNull() - val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && - (currentSeason != initialSeason || currentEpisode != initialEpisode) - val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val focusSeason = currentSeason ?: initialSeason - val focusEpisode = currentEpisode ?: initialEpisode + val args = backStackEntry.arguments + val initialSeason = args?.getString("season")?.toIntOrNull() + val initialEpisode = args?.getString("episode")?.toIntOrNull() + val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && + (currentSeason != initialSeason || currentEpisode != initialEpisode) + val returnToDetailOnBack = args?.getString("returnToDetailOnBack") + ?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack") + ?.toBooleanStrictOrNull() == true + val contentType = args?.getString("contentType").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val focusSeason = currentSeason ?: initialSeason + val focusEpisode = currentEpisode ?: initialEpisode - when { - episodeChangedInPlace && autoPlayEnabled -> { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + when { + episodeChangedInPlace && autoPlayEnabled -> { + // autoplay moved to next episode — skip Stream, go to detail + if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + val detailOnStack = navController.previousBackStackEntry + ?.destination?.route?.startsWith("detail/") == true + if (detailOnStack) { + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) + navController.popBackStack() + } else { navController.navigate( Screen.Detail.createRoute( itemId = contentId, itemType = contentType, addonBaseUrl = null, - returnToHomeOnBack = true - ) - ) { - popUpTo(0) { inclusive = true } - } - } else { - navController.popBackStack() - } - } - episodeChangedInPlace && !autoPlayEnabled -> { - val videoId = args?.getString("videoId").orEmpty() - if (videoId.isNotBlank() && contentType.isNotBlank()) { - navController.navigate( - Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = args?.getString("title").orEmpty(), - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = focusSeason, - episode = focusEpisode, - year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = args?.getString("contentName"), - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack, - isRandom = true + returnFocusSeason = focusSeason, + returnFocusEpisode = focusEpisode, + returnToHomeOnBack = returnToHomeOnBack ) ) { - popUpTo(Screen.Stream.route) { inclusive = true } + popUpTo(Screen.Player.route) { inclusive = true } launchSingleTop = true } - } else { - navController.popBackStack() } + } else { + navController.popBackStack() } - else -> { - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - navController.navigate( - Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnToHomeOnBack = true - ) - ) { - popUpTo(0) { inclusive = true } - } - } else { - navController.popBackStack() - } + } + episodeChangedInPlace && !autoPlayEnabled -> { + // manual stream switch to next episode — go to Stream of current episode + val videoId = currentVideoId ?: args?.getString("videoId").orEmpty() + if (videoId.isNotBlank() && contentType.isNotBlank()) { + navController.navigate( + Screen.Stream.createRoute( + videoId = videoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = focusSeason, + episode = focusEpisode, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + manualSelection = true, + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack + ) + ) { + popUpTo(Screen.Stream.route) { inclusive = true } + launchSingleTop = true } + } else { + navController.popBackStack() } } - } else { - val args = backStackEntry.arguments - val initialSeason = args?.getString("season")?.toIntOrNull() - val initialEpisode = args?.getString("episode")?.toIntOrNull() - val episodeChangedInPlace = (currentSeason != null || currentEpisode != null) && - (currentSeason != initialSeason || currentEpisode != initialEpisode) - val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val focusSeason = currentSeason ?: initialSeason - val focusEpisode = currentEpisode ?: initialEpisode - - when { - episodeChangedInPlace && autoPlayEnabled -> { + else -> { + // normal back — try returning to Stream of this episode + val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + if (!returnedToStream) { if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - val detailOnStack = navController.previousBackStackEntry?.destination?.route?.startsWith("detail/") == true + val detailOnStack = navController.previousBackStackEntry + ?.destination?.route?.startsWith("detail/") == true if (detailOnStack) { - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) + if (!isRandom) { + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) + navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) + } navController.popBackStack() } else { navController.navigate( @@ -887,238 +855,87 @@ fun NuvioNavHost( navController.popBackStack() } } - episodeChangedInPlace && !autoPlayEnabled -> { - val videoId = currentVideoId ?: args?.getString("videoId").orEmpty() - if (videoId.isNotBlank() && contentType.isNotBlank()) { - navController.navigate( - Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = args?.getString("title").orEmpty(), - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = focusSeason, - episode = focusEpisode, - year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = args?.getString("contentName"), - manualSelection = true,returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack - ) - ) { - popUpTo(Screen.Stream.route) { inclusive = true } - launchSingleTop = true - } - } else { - navController.popBackStack() - } - } - else -> { - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - val detailOnStack = navController.previousBackStackEntry?.destination?.route?.startsWith("detail/") == true - if (detailOnStack) { - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) - navController.popBackStack() - } else { - navController.navigate( - Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnFocusSeason = focusSeason, - returnFocusEpisode = focusEpisode, - returnToHomeOnBack = returnToHomeOnBack - ) - ) { - popUpTo(Screen.Player.route) { inclusive = true } - launchSingleTop = true - } - } - } else { - navController.popBackStack() - } - } - } } } }, onPlaybackEnded = { nextVideoId, nextSeason, nextEpisode -> - if (isRandom) { - val args = backStackEntry.arguments - val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - - if (nextVideoId != null && nextSeason != null && nextEpisode != null) { - val route = Screen.Stream.createRoute( - videoId = nextVideoId, - contentType = contentType, - title = args?.getString("title").orEmpty(), - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = nextSeason, - episode = nextEpisode, - episodeName = null, - genres = null, - year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = args?.getString("contentName"), - runtime = null, - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack, - isRandom = true - ) - navController.navigate(route) { - popUpTo(Screen.Player.route) { inclusive = true } - } - } else { - if (contentId.isNotBlank()) { - navController.navigate( - Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnToHomeOnBack = true - ) - ) { - popUpTo(0) { inclusive = true } - } - } else { - navController.popBackStack() - } + val args = backStackEntry.arguments + val contentType = args?.getString("contentType").orEmpty() + val contentId = args?.getString("contentId").orEmpty() + val returnToDetailOnBack = args?.getString("returnToDetailOnBack") + ?.toBooleanStrictOrNull() == true + val returnToHomeOnBack = args?.getString("returnToHomeOnBack") + ?.toBooleanStrictOrNull() == true + if (nextVideoId != null && nextSeason != null && nextEpisode != null) { + val route = Screen.Stream.createRoute( + videoId = nextVideoId, + contentType = contentType, + title = args?.getString("title").orEmpty(), + poster = args?.getString("poster"), + backdrop = args?.getString("backdrop"), + logo = args?.getString("logo"), + season = nextSeason, + episode = nextEpisode, + episodeName = null, + genres = null, + year = args?.getString("year"), + contentId = contentId.takeIf { it.isNotBlank() }, + contentName = args?.getString("contentName"), + runtime = null, + returnToDetailOnBack = returnToDetailOnBack, + returnToHomeOnBack = returnToHomeOnBack + ) + navController.navigate(route) { + popUpTo(Screen.Player.route) { inclusive = true } } } else { + // No next episode — pop back to detail (or home if detail not on stack) + val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) + if (!poppedToDetail) { + navController.popBackStack(Screen.Stream.route, inclusive = true) + } + } + }, + onPlaybackErrorBack = { + val isRandom = args?.getBoolean("isRandom") ?: false + val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + if (!returnedToStream) { val args = backStackEntry.arguments + val videoId = args?.getString("videoId").orEmpty() val contentType = args?.getString("contentType").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - if (nextVideoId != null && nextSeason != null && nextEpisode != null) { + val title = args?.getString("title").orEmpty() + + if (videoId.isBlank() || contentType.isBlank() || title.isBlank()) { + navController.popBackStack() + } else { val route = Screen.Stream.createRoute( - videoId = nextVideoId, + videoId = videoId, contentType = contentType, - title = args?.getString("title").orEmpty(), + title = title, poster = args?.getString("poster"), backdrop = args?.getString("backdrop"), logo = args?.getString("logo"), - season = nextSeason, - episode = nextEpisode, - episodeName = null, + season = args?.getString("season")?.toIntOrNull(), + episode = args?.getString("episode")?.toIntOrNull(), + episodeName = args?.getString("episodeTitle"), genres = null, year = args?.getString("year"), - contentId = contentId.takeIf { it.isNotBlank() }, + contentId = args?.getString("contentId"), contentName = args?.getString("contentName"), runtime = null, - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack, + manualSelection = true, + returnToDetailOnBack = args?.getString("returnToDetailOnBack") + ?.toBooleanStrictOrNull() == true, + returnToHomeOnBack = args?.getString("returnToHomeOnBack") + ?.toBooleanStrictOrNull() == true, isRandom = isRandom ) + navController.navigate(route) { popUpTo(Screen.Player.route) { inclusive = true } - } - } else { - val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) - if (!poppedToDetail) { - navController.popBackStack(Screen.Stream.route, inclusive = true) - } - } - } - }, - onPlaybackErrorBack = { videoId, season, episode, episodeTitle -> - if (isRandom) { - val args = backStackEntry.arguments - val contentType = args?.getString("contentType").orEmpty() - val title = args?.getString("title").orEmpty() - val contentId = args?.getString("contentId").orEmpty() - val contentName = args?.getString("contentName") - val poster = args?.getString("poster") - val backdrop = args?.getString("backdrop") - val logo = args?.getString("logo") - val year = args?.getString("year") - val returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true - val returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true - - val streamRoute = Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = title, - poster = poster, - backdrop = backdrop, - logo = logo, - season = season, - episode = episode, - episodeName = episodeTitle, - year = year, - contentId = contentId.takeIf { it.isNotBlank() }, - contentName = contentName, - manualSelection = true, - returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack, - isRandom = true - ) - - val detailRoute = Screen.Detail.createRoute( - itemId = contentId, - itemType = contentType, - addonBaseUrl = null, - returnToHomeOnBack = true - ) - - var poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) - if (!poppedToDetail) { - navController.navigate(detailRoute) { - popUpTo(0) { inclusive = true } launchSingleTop = true } } - - navController.navigate(streamRoute) { - launchSingleTop = true - } - } else { - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { - val args = backStackEntry.arguments - val videoId = args?.getString("videoId").orEmpty() - val contentType = args?.getString("contentType").orEmpty() - val title = args?.getString("title").orEmpty() - - if (videoId.isBlank() || contentType.isBlank() || title.isBlank()) { - navController.popBackStack() - } else { - val route = Screen.Stream.createRoute( - videoId = videoId, - contentType = contentType, - title = title, - poster = args?.getString("poster"), - backdrop = args?.getString("backdrop"), - logo = args?.getString("logo"), - season = args?.getString("season")?.toIntOrNull(), - episode = args?.getString("episode")?.toIntOrNull(), - episodeName = args?.getString("episodeTitle"), - genres = null, - year = args?.getString("year"), - contentId = args?.getString("contentId"), - contentName = args?.getString("contentName"), - runtime = null, - manualSelection = true, - returnToDetailOnBack = args?.getString("returnToDetailOnBack")?.toBooleanStrictOrNull() == true, - returnToHomeOnBack = args?.getString("returnToHomeOnBack")?.toBooleanStrictOrNull() == true, - isRandom = false - ) - - navController.navigate(route) { - popUpTo(Screen.Player.route) { inclusive = true } - launchSingleTop = true - } - } - } } } ) @@ -1382,4 +1199,4 @@ fun NuvioNavHost( ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt index ad364d11f1..d29ff6dcba 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt @@ -24,8 +24,7 @@ sealed class Screen(val route: String) { return "detail/$encodedItemId/$encodedItemType?addonBaseUrl=$encodedAddon&returnFocusSeason=${returnFocusSeason ?: ""}&returnFocusEpisode=${returnFocusEpisode ?: ""}&returnToHomeOnBack=$returnToHomeOnBack&heroBackdropUrl=$encodedHeroBackdrop" } } - data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}&runtime={runtime}&manualSelection={manualSelection}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&startFromBeginning={startFromBeginning}&contentLanguage={contentLanguage}") { - data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}&runtime={runtime}&manualSelection={manualSelection}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&startFromBeginning={startFromBeginning}&isRandom={isRandom}") { + data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}&runtime={runtime}&manualSelection={manualSelection}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&startFromBeginning={startFromBeginning}&contentLanguage={contentLanguage}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -48,8 +47,7 @@ sealed class Screen(val route: String) { returnToDetailOnBack: Boolean = false, returnToHomeOnBack: Boolean = false, startFromBeginning: Boolean = false, - contentLanguage: String? = null - startFromBeginning: Boolean = false, + contentLanguage: String? = null, isRandom: Boolean = false ): String { val encodedVideoId = encode(videoId) @@ -64,12 +62,10 @@ sealed class Screen(val route: String) { val encodedContentId = contentId?.let { encode(it) } ?: "" val encodedContentName = contentName?.let { encode(it) } ?: "" val encodedContentLanguage = contentLanguage?.let { encode(it) } ?: "" - return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning&contentLanguage=$encodedContentLanguage" - return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning&isRandom=$isRandom" + return "stream/$encodedVideoId/$encodedContentTypePath/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName&runtime=${runtime ?: ""}&manualSelection=$manualSelection&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&startFromBeginning=$startFromBeginning&contentLanguage=$encodedContentLanguage&isRandom=$isRandom" } } - data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}&infoHash={infoHash}&fileIdx={fileIdx}&sources={sources}&contentLanguage={contentLanguage}") { - data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}&isRandom={isRandom}") { + data object Player : Screen("player/{streamUrl}/{title}?streamName={streamName}&year={year}&headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}&bingeGroup={bingeGroup}&autoPlayNav={autoPlayNav}&returnToDetailOnBack={returnToDetailOnBack}&returnToHomeOnBack={returnToHomeOnBack}&filename={filename}&videoHash={videoHash}&videoSize={videoSize}&startFromBeginning={startFromBeginning}&addonName={addonName}&addonLogo={addonLogo}&streamDescription={streamDescription}&infoHash={infoHash}&fileIdx={fileIdx}&sources={sources}&contentLanguage={contentLanguage}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -103,8 +99,7 @@ sealed class Screen(val route: String) { infoHash: String? = null, fileIdx: Int? = null, sources: List? = null, - contentLanguage: String? = null - streamDescription: String? = null, + contentLanguage: String? = null, isRandom: Boolean = false ): String { val encodedUrl = encode(streamUrl) @@ -131,8 +126,7 @@ sealed class Screen(val route: String) { val encodedInfoHash = infoHash ?: "" val encodedSources = sources?.let { encode(org.json.JSONArray(it).toString()) } ?: "" val encodedContentLanguage = contentLanguage?.let { encode(it) } ?: "" - return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription&infoHash=$encodedInfoHash&fileIdx=${fileIdx ?: ""}&sources=$encodedSources&contentLanguage=$encodedContentLanguage" - return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription&isRandom=$isRandom" + return "player/$encodedUrl/$encodedTitle?streamName=$encodedStreamName&year=$encodedYear&headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle&bingeGroup=$encodedBingeGroup&autoPlayNav=$autoPlayNav&returnToDetailOnBack=$returnToDetailOnBack&returnToHomeOnBack=$returnToHomeOnBack&filename=$encodedFilename&videoHash=$encodedVideoHash&videoSize=${videoSize ?: ""}&startFromBeginning=$startFromBeginning&addonName=$encodedAddonName&addonLogo=$encodedAddonLogo&streamDescription=$encodedStreamDescription&infoHash=$encodedInfoHash&fileIdx=${fileIdx ?: ""}&sources=$encodedSources&contentLanguage=$encodedContentLanguage&isRandom=$isRandom" } } data object Search : Screen("search") diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt index 7daf9795f9..6f216467c6 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt @@ -77,8 +77,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.painter.Painter import coil3.request.ImageRequest import coil3.request.crossfade -import coil.decode.SvgDecoder -import coil.request.ImageRequest import com.nuvio.tv.ui.screens.player.PlayerNextEpisodeRules import java.util.Locale diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index 0c4fa4146b..94844850b6 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -128,7 +128,7 @@ import kotlinx.coroutines.delay fun PlayerScreen( viewModel: PlayerViewModel = hiltViewModel(), onBackPress: (currentVideoId: String?, currentSeason: Int?, currentEpisode: Int?, autoPlayEnabled: Boolean) -> Unit, - onPlaybackErrorBack: (videoId: String, season: Int?, episode: Int?, episodeTitle: String?) -> Unit = { _, _, _, _ -> onBackPress(null, null, null, false) }, + onPlaybackErrorBack: () -> Unit = { onBackPress(null, null, null, false) }, onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?) -> Unit)? = null, isRandom: Boolean ) { @@ -154,8 +154,7 @@ fun PlayerScreen( } val exitPlayerFromError: () -> Unit = { viewModel.stopAndRelease() - val info = viewModel.getCurrentEpisodeInfo() - onPlaybackErrorBack(info.videoId, info.season, info.episode, info.episodeTitle) + onPlaybackErrorBack() } val handleBackPress = { @@ -678,10 +677,7 @@ fun PlayerScreen( if (uiState.error != null) { ErrorOverlay( message = uiState.error!!, - onBack = { - val info = viewModel.getCurrentEpisodeInfo() - onPlaybackErrorBack(info.videoId, info.season, info.episode, info.episodeTitle) - } + onBack = exitPlayerFromError ) } From cd04efab3f3cf0cb35595d6131c09f3c0f686f85 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sat, 25 Apr 2026 18:23:13 +0100 Subject: [PATCH 08/17] Handle random episode on no next episode --- .../com/nuvio/tv/ui/navigation/NuvioNavHost.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 52c2e664e0..41f1b7eae8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -883,7 +883,8 @@ fun NuvioNavHost( contentName = args?.getString("contentName"), runtime = null, returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack + returnToHomeOnBack = returnToHomeOnBack, + isRandom = isRandom ) navController.navigate(route) { popUpTo(Screen.Player.route) { inclusive = true } @@ -891,7 +892,17 @@ fun NuvioNavHost( } else { // No next episode — pop back to detail (or home if detail not on stack) val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) - if (!poppedToDetail) { + if (poppedToDetail) { + if (isRandom) { + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove("returnFocusSeason") + + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove("returnFocusEpisode") + } + } else { navController.popBackStack(Screen.Stream.route, inclusive = true) } } From 559ea2e8bf70de509a7ea54f5a7a643fac3c735c Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Wed, 13 May 2026 22:19:07 +0100 Subject: [PATCH 09/17] Update branch --- .../player/PlayerRuntimeControllerScrobble.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt index 61de7450ad..264106024c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerScrobble.kt @@ -26,12 +26,12 @@ internal fun PlayerRuntimeController.preparePlaybackBeforeStart( logSwitchTrace( stage = "track-pref-load", message = "contentId=$id loadedAudio=${loaded?.audio?.language}/${loaded?.audio?.name} " + - "loadedSubtitle=${loaded?.subtitle?.javaClass?.simpleName ?: "none"}" + "loadedSubtitle=${loaded?.subtitle?.javaClass?.simpleName ?: "none"}" ) Log.d( PlayerRuntimeController.TAG, "TRACK_PREF load: contentId=$id S${currentSeason}E${currentEpisode} " + - "result=${if (loaded == null) "null (no saved preference)" else "audio=${loaded.audio?.language}/${loaded.audio?.name} subtitle=${loaded.subtitle?.javaClass?.simpleName}"}" + "result=${if (loaded == null) "null (no saved preference)" else "audio=${loaded.audio?.language}/${loaded.audio?.name} subtitle=${loaded.subtitle?.javaClass?.simpleName}"}" ) persistedTrackPreference = loaded } ?: Log.d(PlayerRuntimeController.TAG, "TRACK_PREF load: skipped (contentId is null)") @@ -54,14 +54,14 @@ internal fun PlayerRuntimeController.preparePlaybackBeforeStart( Log.d( PlayerRuntimeController.TAG, "TRACK_PREF load: skipped (persistedTrackPreference already set: " + - "audio=${persistedTrackPreference?.audio?.language}/${persistedTrackPreference?.audio?.name} " + - "subtitle=${persistedTrackPreference?.subtitle?.javaClass?.simpleName})" + "audio=${persistedTrackPreference?.audio?.language}/${persistedTrackPreference?.audio?.name} " + + "subtitle=${persistedTrackPreference?.subtitle?.javaClass?.simpleName})" ) logSwitchTrace( stage = "track-pref-load", message = "skipped=true reason=persisted-already-set " + - "audio=${persistedTrackPreference?.audio?.language}/${persistedTrackPreference?.audio?.name} " + - "subtitle=${persistedTrackPreference?.subtitle?.javaClass?.simpleName ?: "none"}" + "audio=${persistedTrackPreference?.audio?.language}/${persistedTrackPreference?.audio?.name} " + + "subtitle=${persistedTrackPreference?.subtitle?.javaClass?.simpleName ?: "none"}" ) } // Load saved watch progress BEFORE player init. From 0611b5750144f953a56eeb6dc1f4e58236a48152 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Tue, 16 Jun 2026 14:24:00 +0100 Subject: [PATCH 10/17] Update to latest version --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 83 +++++++++++-------- .../tv/ui/screens/detail/MetaDetailsScreen.kt | 2 - 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index e4bc356d47..3b25a20fc8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -375,7 +375,7 @@ fun NuvioNavHost( ) ) }, - onRandomPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, isRandom -> + onRandomPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, _ -> navController.navigate( Screen.Stream.createRoute( videoId = videoId, @@ -397,7 +397,7 @@ fun NuvioNavHost( ) ) }, - onRandomManualPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, isRandom -> + onRandomManualPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, _ -> navController.navigate( Screen.Stream.createRoute( videoId = videoId, @@ -884,26 +884,25 @@ fun NuvioNavHost( } } else -> { - // normal back — try returning to Stream of this episode - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) - if (!returnedToStream) { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { - val detailOnStack = navController.previousBackStackEntry - ?.destination?.route?.startsWith("detail/") == true - if (detailOnStack) { - if (!isRandom) { - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusSeason", focusSeason) - navController.previousBackStackEntry?.savedStateHandle?.set("returnFocusEpisode", focusEpisode) - } - navController.popBackStack() - // normal back — skip Stream screen if episode/movie was completed - val skipStreamScreen = playbackCompleted && contentId.isNotBlank() + // normal back — skip Stream if playback completed + val skipStreamScreen = + playbackCompleted && contentId.isNotBlank() + if (skipStreamScreen) { returnToDetail() } else { - val returnedToStream = navController.popBackStack(Screen.Stream.route, inclusive = false) + val returnedToStream = + navController.popBackStack( + Screen.Stream.route, + inclusive = false + ) + if (!returnedToStream) { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + if ( + returnToDetailOnBack && + contentType.equals("series", ignoreCase = true) && + contentId.isNotBlank() + ) { returnToDetail() } else { navController.popBackStack() @@ -980,20 +979,19 @@ fun NuvioNavHost( } } } else { - val poppedToDetail = navController.popBackStack(Screen.Detail.route, inclusive = false) + val poppedToDetail = + navController.popBackStack(Screen.Detail.route, inclusive = false) if (poppedToDetail) { if (isRandom) { - navController.currentBackStackEntry - ?.savedStateHandle - ?.remove("returnFocusSeason") + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove("returnFocusSeason") - navController.currentBackStackEntry - ?.savedStateHandle - ?.remove("returnFocusEpisode") - val contentId = args?.getString("contentId").orEmpty() - val contentType = args?.getString("contentType").orEmpty() - val returnToHomeOnBack = args?.getString("returnToHomeOnBack") - ?.toBooleanStrictOrNull() == true + navController.currentBackStackEntry + ?.savedStateHandle + ?.remove("returnFocusEpisode") + } + } val focusSeason = args?.getString("season")?.toIntOrNull() val focusEpisode = args?.getString("episode")?.toIntOrNull() if (contentId.isNotBlank()) { @@ -1002,13 +1000,20 @@ fun NuvioNavHost( val itemId = it.arguments?.getString("itemId").orEmpty() val itemType = it.arguments?.getString("itemType").orEmpty() it.destination.route?.startsWith("detail/") == true && - itemId == contentId && - (itemType.isBlank() || contentType.isBlank() || itemType.equals(contentType, ignoreCase = true)) + itemId == contentId && + (itemType.isBlank() || contentType.isBlank() || itemType.equals( + contentType, + ignoreCase = true + )) } if (detailEntry != null) { detailEntry.savedStateHandle["returnFocusSeason"] = focusSeason - detailEntry.savedStateHandle["returnFocusEpisode"] = focusEpisode - navController.popBackStack(Screen.Detail.route, inclusive = false) + detailEntry.savedStateHandle["returnFocusEpisode"] = + focusEpisode + navController.popBackStack( + Screen.Detail.route, + inclusive = false + ) } else { navController.navigate( Screen.Detail.createRoute( @@ -1026,13 +1031,19 @@ fun NuvioNavHost( } } } else { - val poppedToStream = navController.popBackStack(Screen.Stream.route, inclusive = true) + val poppedToStream = navController.popBackStack( + Screen.Stream.route, + inclusive = true + ) if (!poppedToStream) { navController.popBackStack() + } else { + navController.popBackStack( + Screen.Stream.route, + inclusive = true + ) } } - } else { - navController.popBackStack(Screen.Stream.route, inclusive = true)} } } }, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 52f439c26c..fed83802fd 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -2153,12 +2153,10 @@ private fun MetaDetailsContent( onEpisodeManualPlayClick(episode) } } else { - // Movie case (non‑series) – only occurs when manualPlayIsRandom is false heroPlayManualClick() } manualPlayEpisode = null manualPlayIsRandom = false - heroPlayManualClick() }, showStartFromBeginning = nextToWatch?.isResume == true, onStartFromBeginning = { From 184d5c2bcd3820eb3d7dc806b0529a10383c18ee Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Tue, 16 Jun 2026 18:10:23 +0100 Subject: [PATCH 11/17] Tidy random episode code --- .../com/nuvio/tv/ui/navigation/NuvioNavHost.kt | 17 ++--------------- .../PlayerRuntimeControllerPlaybackEvents.kt | 2 -- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 3b25a20fc8..e9b722f3ab 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -979,21 +979,8 @@ fun NuvioNavHost( } } } else { - val poppedToDetail = - navController.popBackStack(Screen.Detail.route, inclusive = false) - if (poppedToDetail) { - if (isRandom) { - navController.currentBackStackEntry - ?.savedStateHandle - ?.remove("returnFocusSeason") - - navController.currentBackStackEntry - ?.savedStateHandle - ?.remove("returnFocusEpisode") - } - } - val focusSeason = args?.getString("season")?.toIntOrNull() - val focusEpisode = args?.getString("episode")?.toIntOrNull() + val focusSeason = if (isRandom) null else args?.getString("season")?.toIntOrNull() + val focusEpisode = if (isRandom) null else args?.getString("episode")?.toIntOrNull() if (contentId.isNotBlank()) { val detailEntry = navController.currentBackStack.value .lastOrNull { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index 5a59182d20..3e3dee4dec 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -457,8 +457,6 @@ internal fun PlayerRuntimeController.buildScrobbleItem(): TraktScrobbleItem? { internal fun PlayerRuntimeController.emitScrobbleStart() { if (isRandom) return - val item = currentScrobbleItem ?: buildScrobbleItem().also { currentScrobbleItem = it } - if (item == null) return if (isShortPlaceholderStream()) return if (hasRequestedScrobbleStartForCurrentItem) return From 49662763c923c01f0b99495c0b0b645999043d9d Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Fri, 7 Aug 2026 22:20:13 +0100 Subject: [PATCH 12/17] Update to latest version --- .../PlayerRuntimeControllerPlaybackEvents.kt | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index b3cd411028..46ebddab0a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -189,27 +189,27 @@ internal fun PlayerRuntimeController.startProgressUpdates() { val playingNow = view.isPlayingNow() val cacheBuffering = view.isPausedForCacheNow() || view.isCoreIdleNow() var firstFrameReady = hasRenderedFirstFrame - if (!firstFrameReady) { - firstFrameReady = pos > 0L || (playingNow && !cacheBuffering && playerDuration > 0L) - if (firstFrameReady) { - hasRenderedFirstFrame = true - val clickToFirstFrameMs = launchStartedAtElapsedMs - ?.let { (android.os.SystemClock.elapsedRealtime() - it).coerceAtLeast(0L) } - ?: -1L - val initToFirstFrameMs = (System.currentTimeMillis() - playerInitializationStartedAtMs) - .coerceAtLeast(0L) - playbackAnalyticsDiagnostics.recordRawEventLine( - "PLAYBACK_STARTUP: clickToFirstFrameMs=$clickToFirstFrameMs " + + if (!firstFrameReady) { + firstFrameReady = pos > 0L || (playingNow && !cacheBuffering && playerDuration > 0L) + if (firstFrameReady) { + hasRenderedFirstFrame = true + val clickToFirstFrameMs = launchStartedAtElapsedMs + ?.let { (android.os.SystemClock.elapsedRealtime() - it).coerceAtLeast(0L) } + ?: -1L + val initToFirstFrameMs = (System.currentTimeMillis() - playerInitializationStartedAtMs) + .coerceAtLeast(0L) + playbackAnalyticsDiagnostics.recordRawEventLine( + "PLAYBACK_STARTUP: clickToFirstFrameMs=$clickToFirstFrameMs " + "initToFirstFrameMs=$initToFirstFrameMs playbackSpeed=${_uiState.value.playbackSpeed} " + "currentPositionMs=$pos durationMs=$playerDuration engine=MPV " + "host=${currentStreamUrl.safePlaybackEventsHost()}" - ) - finishLoadingDiagnostics("mpv_first_frame_ready") - if (_uiState.value.postPlayDismissedForCurrentEpisode) { - _uiState.update { it.copy(postPlayDismissedForCurrentEpisode = false) } - } + ) + finishLoadingDiagnostics("mpv_first_frame_ready") + if (_uiState.value.postPlayDismissedForCurrentEpisode) { + _uiState.update { it.copy(postPlayDismissedForCurrentEpisode = false) } } } + } if (playerDuration > lastKnownDuration) { lastKnownDuration = playerDuration } @@ -316,7 +316,7 @@ internal fun PlayerRuntimeController.startProgressUpdates() { val usedMb = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) val maxMb = runtime.maxMemory() / (1024 * 1024) Log.d(PlayerRuntimeController.TAG, "BUFFER: ahead=${bufAhead}s, loading=$loading, heap=$usedMb/${maxMb}MB, pos=${pos / 1000}s") - + if (NuvioExoPlayerPerformanceHelper.shouldLogMemoryFootprint()) { val defaultAllocator = _loadControl?.allocator as? androidx.media3.exoplayer.upstream.DefaultAllocator val totalFootprintBytes = defaultAllocator?.let { allocator -> @@ -357,6 +357,7 @@ internal fun PlayerRuntimeController.startWatchProgressSaving() { } internal fun PlayerRuntimeController.stopWatchProgressSaving() { + if (isRandom) return watchProgressSaveJob?.cancel() watchProgressSaveJob = null } @@ -638,7 +639,7 @@ internal fun PlayerRuntimeController.handleNaturalPlaybackEnded() { Log.i( PlayerRuntimeController.TAG, "Ignoring non-natural ENDED: firstFrame=$hasRenderedFirstFrame " + - "error=$hasFatalError durationMs=$duration positionMs=$position" + "error=$hasFatalError durationMs=$duration positionMs=$position" ) // Prevent PlayerScreen from dispatching onPlaybackEnded / next-episode navigation. _uiState.update { it.copy(playbackEnded = false) } @@ -719,14 +720,7 @@ internal fun PlayerRuntimeController.currentPlaybackProgressPercent(): Float { } internal fun PlayerRuntimeController.refreshScrobbleItem() { - if (isRandom) { - currentScrobbleItem = null - hasSentScrobbleStartForCurrentItem = false - hasRequestedScrobbleStartForCurrentItem = false - scrobbleStartRequestGeneration++ - hasSentCompletionScrobbleForCurrentItem = false - return - } + if (isRandom) return currentScrobbleItem = buildScrobbleItem() hasSentScrobbleStartForCurrentItem = false hasRequestedScrobbleStartForCurrentItem = false @@ -736,7 +730,6 @@ internal fun PlayerRuntimeController.refreshScrobbleItem() { } internal fun PlayerRuntimeController.buildScrobbleItem(): TrackingMediaReference? { - if (isRandom) return null val rawContentId = contentId ?: return null val reference = buildTrackingMediaReference( contentType = contentType ?: "movie", @@ -750,7 +743,7 @@ internal fun PlayerRuntimeController.buildScrobbleItem(): TrackingMediaReference ) return reference.takeIf { media -> media.hasResolvableIdentity && - (media.kind == TrackingMediaKind.MOVIE || media.episode != null) + (media.kind == TrackingMediaKind.MOVIE || media.episode != null) } } @@ -847,7 +840,7 @@ internal fun PlayerRuntimeController.emitScrobbleStop(progressPercent: Float? = logScrobbleDiagnostic("stop_state_reset", "progress=$percent") } -internal fun PlayerRuntimeController.emitPauseScrobbleStop(progressPercent: Float? = null) { +internal fun PlayerRuntimeController.emitScrobblePause(progressPercent: Float? = null) { if (isRandom) return logScrobbleDiagnostic("pause_evaluated", "providedProgress=${progressPercent ?: "none"}") if (isShortPlaceholderStream()) { @@ -891,6 +884,7 @@ internal fun PlayerRuntimeController.emitCompletionScrobbleStop(progressPercent: } internal fun PlayerRuntimeController.emitStopScrobbleForCurrentProgress() { + if (isRandom) return val progressPercent = currentPlaybackProgressPercent() if (!shouldSendStopScrobble(hasRequestedScrobbleStartForCurrentItem, progressPercent)) { logScrobbleDiagnostic( @@ -907,10 +901,12 @@ internal fun PlayerRuntimeController.emitStopScrobbleForCurrentProgress() { } internal fun PlayerRuntimeController.emitPauseScrobbleForCurrentProgress() { + if (isRandom) return emitScrobblePause(progressPercent = currentPlaybackProgressPercent()) } internal fun PlayerRuntimeController.emitSeekScrobbleRestart(progressPercent: Float) { + if (isRandom) return if (progressPercent < 1f || progressPercent >= 80f) return if (isShortPlaceholderStream()) return val item = currentScrobbleItem ?: return @@ -930,7 +926,6 @@ internal fun PlayerRuntimeController.emitSeekScrobbleRestart(progressPercent: Fl } internal fun PlayerRuntimeController.flushPlaybackSnapshotForSwitchOrExit() { - if (isRandom) return logScrobbleDiagnostic("flush_switch_or_exit") emitStopScrobbleForCurrentProgress() saveWatchProgress() @@ -944,13 +939,12 @@ internal fun PlayerRuntimeController.logScrobbleDiagnostic( Log.d( TRACKING_SCROBBLE_DIAGNOSTIC_TAG, "player stage=$stage engine=$currentInternalPlayerEngine uiPlaying=${_uiState.value.isPlaying} " + - "requested=$hasRequestedScrobbleStartForCurrentItem sent=$hasSentScrobbleStartForCurrentItem " + - "generation=$scrobbleStartRequestGeneration $item $detail".trim() + "requested=$hasRequestedScrobbleStartForCurrentItem sent=$hasSentScrobbleStartForCurrentItem " + + "generation=$scrobbleStartRequestGeneration $item $detail".trim() ) } internal fun PlayerRuntimeController.scheduleProgressSyncAfterSeek() { - if (isRandom) return seekProgressSyncJob?.cancel() seekProgressSyncJob = scope.launch { delay(seekProgressSyncDebounceMs) @@ -1068,9 +1062,9 @@ internal fun PlayerRuntimeController.schedulePauseOverlay() { delay(pauseOverlayDelayMs) val s = _uiState.value val anyPanelOpen = s.showSubtitleOverlay || s.showSubtitleStylePanel || - s.showSpeedDialog || s.showMoreDialog || s.showEpisodesPanel || - s.showSourcesPanel || s.showAudioOverlay || s.showStreamInfoOverlay || - s.showSubtitleTimingDialog + s.showSpeedDialog || s.showMoreDialog || s.showEpisodesPanel || + s.showSourcesPanel || s.showAudioOverlay || s.showStreamInfoOverlay || + s.showSubtitleTimingDialog if (!s.isPlaying && s.pauseOverlayEnabled && s.error == null && !anyPanelOpen) { _uiState.update { it.copy(showPauseOverlay = true, showControls = false) } } @@ -1772,4 +1766,4 @@ private fun formatTorrentSpeed(context: android.content.Context, bytesPerSec: Lo bytesPerSec >= 1_024 -> context.getString(R.string.unit_speed_kb_s, String.format("%.0f", bytesPerSec / 1_024.0)) else -> context.getString(R.string.unit_speed_b_s, bytesPerSec) } -} +} \ No newline at end of file From 19f7813724a80b712470b32cb9cce857dca042a2 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sun, 9 Aug 2026 20:14:26 +0100 Subject: [PATCH 13/17] Random episode - code tidy --- .../tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt index 6f8b08788a..504578fbe2 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt @@ -202,8 +202,8 @@ internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boole val resolvedNext = if (isRandom) { val candidates = metaVideos.filter { video -> - video.season != null && video.season > 0 && // exclude season 0 - video.episode != null && video.episode > 0 && // valid episode + video.season != null && video.season > 0 && + video.episode != null && video.episode > 0 && (video.season != season || video.episode != episode) && PlayerNextEpisodeRules.hasEpisodeAired(video.released) } From 10b609be767ae5a46eb9edd01c393d6be58d3860 Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sat, 22 Aug 2026 15:11:32 +0100 Subject: [PATCH 14/17] Stop repeats in random episode session --- .../ui/screens/player/PlayerRuntimeController.kt | 5 ++++- .../player/PlayerRuntimeControllerMetadata.kt | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 8daa2eea7d..ee38d0dc90 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -218,7 +218,9 @@ class PlayerRuntimeController( fun setIsRandom(random: Boolean) { if (isRandom != random) { isRandom = random - // Re‑evaluate the next episode now that the mode has changed + if (!random) { + randomEpisodeSessionHistory.clear() + } recomputeNextEpisode(resetVisibility = true) } } @@ -438,6 +440,7 @@ class PlayerRuntimeController( internal var audioRouteChangeJob: Job? = null internal var isRandom: Boolean = false + internal val randomEpisodeSessionHistory: MutableSet> = mutableSetOf() internal var lastBufferLogTimeMs: Long = 0L internal var pendingSeekFlush: Boolean = false internal var suppressBufferingUiForSeek: Boolean = false diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt index d0a2d1cc51..1c2a946b78 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt @@ -222,13 +222,25 @@ internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boole } val resolvedNext = if (isRandom) { + randomEpisodeSessionHistory.add(season to episode) val candidates = metaVideos.filter { video -> video.season != null && video.season > 0 && video.episode != null && video.episode > 0 && - (video.season != season || video.episode != episode) && + (video.season to video.episode) !in randomEpisodeSessionHistory && PlayerNextEpisodeRules.hasEpisodeAired(video.released) } - if (candidates.isNotEmpty()) candidates.random() else null + if (candidates.isNotEmpty()) { + candidates.random() + } else { + randomEpisodeSessionHistory.clear() + randomEpisodeSessionHistory.add(season to episode) + metaVideos.filter { video -> + video.season != null && video.season > 0 && + video.episode != null && video.episode > 0 && + (video.season != season || video.episode != episode) && + PlayerNextEpisodeRules.hasEpisodeAired(video.released) + }.randomOrNull() + } } else { PlayerNextEpisodeRules.resolveNextEpisode( videos = metaVideos, From b3025f2b00fe44b1a3f18c2e56bf072ed8cde63f Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Sat, 22 Aug 2026 16:22:38 +0100 Subject: [PATCH 15/17] Add played random episode to session to stop repeats. Persist random playback across manual stream selection screen. --- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 14 ++++++- .../screens/player/PlayerRuntimeController.kt | 1 + .../player/PlayerRuntimeControllerMetadata.kt | 11 ++++-- .../tv/ui/screens/player/PlayerViewModel.kt | 2 + .../player/RandomEpisodeSessionTracker.kt | 38 +++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/player/RandomEpisodeSessionTracker.kt diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index fbf92e58d6..2babd552b4 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -55,6 +56,14 @@ fun NuvioNavHost( startDestination: String = Screen.Home.route, hideBuiltInHeaders: Boolean = false ) { + val context = androidx.compose.ui.platform.LocalContext.current + val randomEpisodeSessionTracker = remember { + dagger.hilt.android.EntryPointAccessors.fromApplication( + context, + com.nuvio.tv.core.player.RandomEpisodeSessionEntryPoint::class.java + ).randomEpisodeSessionTracker() + } + fun isStreamToPlayer(from: String, to: String): Boolean { return from.startsWith("stream/") && to.startsWith("player/") } @@ -379,6 +388,7 @@ fun NuvioNavHost( ) }, onRandomPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, _ -> + randomEpisodeSessionTracker.clear() navController.navigate( Screen.Stream.createRoute( videoId = videoId, @@ -401,6 +411,7 @@ fun NuvioNavHost( ) }, onRandomManualPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, _ -> + randomEpisodeSessionTracker.clear() navController.navigate( Screen.Stream.createRoute( videoId = videoId, @@ -879,7 +890,8 @@ fun NuvioNavHost( contentName = args?.getString("contentName"), manualSelection = true, returnToDetailOnBack = returnToDetailOnBack, - returnToHomeOnBack = returnToHomeOnBack + returnToHomeOnBack = returnToHomeOnBack, + isRandom = isRandom ) ) { popUpTo(Screen.Stream.route) { inclusive = true } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index ee38d0dc90..c7320dd07a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -96,6 +96,7 @@ class PlayerRuntimeController( internal val streamBadgePresentation: com.nuvio.tv.core.streams.StreamBadgePresentation, internal val playbackIssueReportRepository: PlaybackIssueReportRepository, internal val tvRecommendationManager: com.nuvio.tv.core.recommendations.TvRecommendationManager, + internal val randomEpisodeSessionTracker: com.nuvio.tv.core.player.RandomEpisodeSessionTracker, savedStateHandle: SavedStateHandle, internal val scope: CoroutineScope ) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt index 1c2a946b78..763175981a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerMetadata.kt @@ -222,18 +222,21 @@ internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boole } val resolvedNext = if (isRandom) { - randomEpisodeSessionHistory.add(season to episode) + val resolvedContentId = contentId + if (resolvedContentId != null) { + randomEpisodeSessionTracker.record(resolvedContentId, season, episode) + } + val seen = resolvedContentId?.let { randomEpisodeSessionTracker.watchedPairs(it) }.orEmpty() val candidates = metaVideos.filter { video -> video.season != null && video.season > 0 && video.episode != null && video.episode > 0 && - (video.season to video.episode) !in randomEpisodeSessionHistory && + (video.season to video.episode) !in seen && PlayerNextEpisodeRules.hasEpisodeAired(video.released) } if (candidates.isNotEmpty()) { candidates.random() } else { - randomEpisodeSessionHistory.clear() - randomEpisodeSessionHistory.add(season to episode) + resolvedContentId?.let { randomEpisodeSessionTracker.clear() } metaVideos.filter { video -> video.season != null && video.season > 0 && video.episode != null && video.episode > 0 && diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index a685c2765a..3e61613340 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -74,6 +74,7 @@ class PlayerViewModel @Inject constructor( private val externalPlaybackTracker: com.nuvio.tv.core.player.ExternalPlaybackTracker, private val subtitleFileCache: com.nuvio.tv.core.player.SubtitleFileCache, private val tvRecommendationManager: com.nuvio.tv.core.recommendations.TvRecommendationManager, + private val randomEpisodeSessionTracker: com.nuvio.tv.core.player.RandomEpisodeSessionTracker, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -117,6 +118,7 @@ class PlayerViewModel @Inject constructor( streamBadgePresentation = streamBadgePresentation, playbackIssueReportRepository = playbackIssueReportRepository, tvRecommendationManager = tvRecommendationManager, + randomEpisodeSessionTracker = randomEpisodeSessionTracker, savedStateHandle = savedStateHandle, scope = viewModelScope ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/RandomEpisodeSessionTracker.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/RandomEpisodeSessionTracker.kt new file mode 100644 index 0000000000..776858fcb4 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/RandomEpisodeSessionTracker.kt @@ -0,0 +1,38 @@ +package com.nuvio.tv.core.player + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RandomEpisodeSessionTracker @Inject constructor() { + + private var sessionContentId: String? = null + private val watchedEpisodes = mutableSetOf>() + + @Synchronized + fun record(contentId: String, season: Int, episode: Int) { + if (sessionContentId != contentId) { + sessionContentId = contentId + watchedEpisodes.clear() + } + watchedEpisodes.add(season to episode) + } + + @Synchronized + fun watchedPairs(contentId: String): Set> { + if (sessionContentId != contentId) return emptySet() + return watchedEpisodes.toSet() + } + + @Synchronized + fun clear() { + sessionContentId = null + watchedEpisodes.clear() + } +} + +@dagger.hilt.EntryPoint +@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) +interface RandomEpisodeSessionEntryPoint { + fun randomEpisodeSessionTracker(): RandomEpisodeSessionTracker +} \ No newline at end of file From c642de2239726bd4d571c310a5e1907474ebc03e Mon Sep 17 00:00:00 2001 From: Declan Shaen-Carter Date: Fri, 4 Sep 2026 19:14:20 +0100 Subject: [PATCH 16/17] Update to latest version --- .../main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt | 7 +------ .../java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index b7827436c2..cef98bb01f 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -645,7 +645,6 @@ fun NuvioNavHost( sources = playbackInfo.sources, contentLanguage = playbackInfo.contentLanguage, profileId = playbackInfo.profileId, - contentLanguage = playbackInfo.contentLanguage, isRandom = isRandom ) ) @@ -688,7 +687,6 @@ fun NuvioNavHost( sources = playbackInfo.sources, contentLanguage = playbackInfo.contentLanguage, profileId = playbackInfo.profileId, - contentLanguage = playbackInfo.contentLanguage, isRandom = isRandom ) ) { @@ -973,7 +971,6 @@ fun NuvioNavHost( returnToDetailOnBack = returnToDetailOnBack, returnToHomeOnBack = returnToHomeOnBack, profileId = args?.getString("profileId")?.toIntOrNull(), - returnToHomeOnBack = returnToHomeOnBack, isRandom = isRandom ) ) { @@ -1036,7 +1033,6 @@ fun NuvioNavHost( returnToDetailOnBack = returnToDetailOnBack, returnToHomeOnBack = returnToHomeOnBack, profileId = args?.getString("profileId")?.toIntOrNull(), - returnToHomeOnBack = returnToHomeOnBack, isRandom = isRandom ) navController.navigate(route) { @@ -1178,8 +1174,7 @@ fun NuvioNavHost( ?.toBooleanStrictOrNull() == true, returnToHomeOnBack = args?.getString("returnToHomeOnBack") ?.toBooleanStrictOrNull() == true, - profileId = args?.getString("profileId")?.toIntOrNull() - ?.toBooleanStrictOrNull() == true, + profileId = args?.getString("profileId")?.toIntOrNull(), isRandom = isRandom ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index 186ccba0be..e1951abefe 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -149,7 +149,6 @@ fun PlayerScreen( onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?, exitReason: PlayerExitReason?) -> Unit)? = null, onPlayRecommendation: (PostPlayRecommendation, manualSelection: Boolean) -> Unit = { _, _ -> }, onOpenRecommendationDetails: (PostPlayRecommendation) -> Unit = {}, - onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?, exitReason: PlayerExitReason?) -> Unit)? = null, isRandom: Boolean ) { LaunchedEffect(isRandom) { From f6442d825209528c0926a8753a485fac5d7ba048 Mon Sep 17 00:00:00 2001 From: DeclanSC <140628807+DeclanSC@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:35:48 +0100 Subject: [PATCH 17/17] Revert changed back stack navigation logic Removed redundant popBackStack call when poppedToStream is false. Added in previous commit. --- .../main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index cef98bb01f..13cbb1c662 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -1119,11 +1119,6 @@ fun NuvioNavHost( ) if (!poppedToStream) { navController.popBackStack() - } else { - navController.popBackStack( - Screen.Stream.route, - inclusive = true - ) } } } @@ -1512,4 +1507,4 @@ fun NuvioNavHost( ) } } -} \ No newline at end of file +}