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 7bbbfb2cce..05f0dd0ad2 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 @@ -113,6 +113,7 @@ class LayoutPreferenceDataStore @Inject constructor( private val blurContinueWatchingNextUpKey = booleanPreferencesKey("blur_continue_watching_next_up") private val continueWatchingSortModeKey = stringPreferencesKey("continue_watching_sort_mode") 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 modernHeroFullScreenBackdropKey = booleanPreferencesKey("modern_hero_full_screen_backdrop") private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content") @@ -377,6 +378,10 @@ class LayoutPreferenceDataStore @Inject constructor( prefs[detailPageTrailerButtonEnabledKey] ?: true } + val detailPageRandomButtonEnabled: Flow = profileFlow { prefs -> + prefs[detailPageRandomButtonEnabledKey] ?: false + } + val preferExternalMetaAddonDetail: StateFlow = profileFlow { prefs -> prefs[preferExternalMetaAddonDetailKey] ?: true }.stateIn(scope, SharingStarted.Eagerly, true) @@ -746,6 +751,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/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 5342bc989f..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 @@ -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 @@ -56,6 +57,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/") } @@ -392,6 +401,53 @@ fun NuvioNavHost( contentLanguage = contentLanguage ) ) + }, + onRandomPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year, runtime, _ -> + randomEpisodeSessionTracker.clear() + 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, _ -> + randomEpisodeSessionTracker.clear() + 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 + ) + ) } ) } @@ -486,6 +542,11 @@ fun NuvioNavHost( type = NavType.StringType nullable = true defaultValue = null + }, + navArgument("isRandom") { + type = NavType.BoolType + nullable = false + defaultValue = false } ) ) { backStackEntry -> @@ -503,12 +564,14 @@ fun NuvioNavHost( val startFromBeginning = streamArgs ?.getString("startFromBeginning") ?.toBooleanStrictOrNull() == true + val isRandom = streamArgs?.getBoolean("isRandom") ?: false StreamScreen( startFromBeginning = startFromBeginning, restoreSourceSelection = restoreSourceSelection, onSourceSelectionRestoreHandled = { streamSavedState[SOURCE_SELECTION_RESTORE_STATE_KEY] = false }, + isRandom = isRandom, onBackPress = { val streamContentType = streamArgs?.getString("contentType").orEmpty() val streamContentId = streamArgs?.getString("contentId").orEmpty() @@ -517,8 +580,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( @@ -576,7 +644,8 @@ fun NuvioNavHost( fileIdx = playbackInfo.fileIdx, sources = playbackInfo.sources, contentLanguage = playbackInfo.contentLanguage, - profileId = playbackInfo.profileId + profileId = playbackInfo.profileId, + isRandom = isRandom ) ) } @@ -617,7 +686,8 @@ fun NuvioNavHost( fileIdx = playbackInfo.fileIdx, sources = playbackInfo.sources, contentLanguage = playbackInfo.contentLanguage, - profileId = playbackInfo.profileId + profileId = playbackInfo.profileId, + isRandom = isRandom ) ) { popUpTo(Screen.Stream.route) { inclusive = true } @@ -762,6 +832,11 @@ fun NuvioNavHost( nullable = true defaultValue = null }, + navArgument("isRandom") { + type = NavType.BoolType + nullable = false + defaultValue = false + }, navArgument("launchStartedAtMs") { type = NavType.StringType nullable = true @@ -817,13 +892,16 @@ fun NuvioNavHost( } } + val args = backStackEntry.arguments + val isRandom = args?.getBoolean("isRandom") ?: false PlayerScreen( + isRandom = isRandom, onBackPress = { currentVideoId, currentSeason, currentEpisode, autoPlayEnabled, playbackCompleted -> 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) + (currentSeason != initialSeason || currentEpisode != initialEpisode) val returnToDetailOnBack = args?.getString("returnToDetailOnBack") ?.toBooleanStrictOrNull() == true val returnToHomeOnBack = args?.getString("returnToHomeOnBack") @@ -892,7 +970,8 @@ fun NuvioNavHost( manualSelection = true, returnToDetailOnBack = returnToDetailOnBack, returnToHomeOnBack = returnToHomeOnBack, - profileId = args?.getString("profileId")?.toIntOrNull() + profileId = args?.getString("profileId")?.toIntOrNull(), + isRandom = isRandom ) ) { popUpTo(Screen.Stream.route) { inclusive = true } @@ -903,14 +982,21 @@ fun NuvioNavHost( } } else -> { - // 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 = popBackToStream() + val returnedToStream = + popBackToStream() if (!returnedToStream) { - if (returnToDetailOnBack && contentType.equals("series", ignoreCase = true) && contentId.isNotBlank()) { + if ( + returnToDetailOnBack && + contentType.equals("series", ignoreCase = true) && + contentId.isNotBlank() + ) { returnToDetail() } else { navController.popBackStack() @@ -946,7 +1032,8 @@ fun NuvioNavHost( runtime = null, returnToDetailOnBack = returnToDetailOnBack, returnToHomeOnBack = returnToHomeOnBack, - profileId = args?.getString("profileId")?.toIntOrNull() + profileId = args?.getString("profileId")?.toIntOrNull(), + isRandom = isRandom ) navController.navigate(route) { popUpTo(Screen.Player.route) { inclusive = true } @@ -987,25 +1074,28 @@ fun NuvioNavHost( } } } else { - val contentId = args?.getString("contentId").orEmpty() - val contentType = args?.getString("contentType").orEmpty() - val returnToHomeOnBack = args?.getString("returnToHomeOnBack") - ?.toBooleanStrictOrNull() == true - 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 { 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( @@ -1023,7 +1113,10 @@ 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() } @@ -1045,6 +1138,7 @@ fun NuvioNavHost( ) }, onPlaybackErrorBack = { + val isRandom = args?.getBoolean("isRandom") ?: false val returnedToStream = popBackToStream() if (!returnedToStream) { val args = backStackEntry.arguments @@ -1075,7 +1169,8 @@ fun NuvioNavHost( ?.toBooleanStrictOrNull() == true, returnToHomeOnBack = args?.getString("returnToHomeOnBack") ?.toBooleanStrictOrNull() == true, - profileId = args?.getString("profileId")?.toIntOrNull() + profileId = args?.getString("profileId")?.toIntOrNull(), + isRandom = isRandom ) navController.navigate(route) { 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 61d8aaae9c..fafee10476 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 @@ -27,7 +27,7 @@ sealed class Screen(val route: String) { return "detail/$encodedItemId/$encodedItemType?addonBaseUrl=$encodedAddon&returnFocusSeason=${returnFocusSeason ?: ""}&returnFocusEpisode=${returnFocusEpisode ?: ""}&returnToHomeOnBack=$returnToHomeOnBack&heroBackdropUrl=$encodedHeroBackdrop&playOnLoad=$playOnLoad&manualSelection=$manualSelection" } } - 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}&profileId={profileId}") { + 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}&profileId={profileId}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -51,7 +51,8 @@ sealed class Screen(val route: String) { returnToHomeOnBack: Boolean = false, startFromBeginning: Boolean = false, contentLanguage: String? = null, - profileId: Int? = null + profileId: Int? = null, + isRandom: Boolean = false ): String { val encodedVideoId = encode(videoId) val encodedContentTypePath = encode(contentType) @@ -65,10 +66,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&profileId=${profileId ?: ""}" + 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&profileId=${profileId ?: ""}&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}&cloudSessionToken={cloudSessionToken}&launchStartedAtMs={launchStartedAtMs}&profileId={profileId}") { + 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}&cloudSessionToken={cloudSessionToken}&launchStartedAtMs={launchStartedAtMs}&profileId={profileId}&isRandom={isRandom}") { private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20") @@ -105,7 +106,8 @@ sealed class Screen(val route: String) { contentLanguage: String? = null, cloudSessionToken: String? = null, launchStartedAtMs: Long = SystemClock.elapsedRealtime(), - profileId: Int? = null + profileId: Int? = null, + isRandom: Boolean = false ): String { val encodedUrl = encode(streamUrl) val encodedTitle = encode(title) @@ -132,7 +134,7 @@ sealed class Screen(val route: String) { val encodedSources = sources?.let { encode(org.json.JSONArray(it).toString()) } ?: "" val encodedContentLanguage = contentLanguage?.let { encode(it) } ?: "" val encodedCloudSessionToken = cloudSessionToken?.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&cloudSessionToken=$encodedCloudSessionToken&launchStartedAtMs=$launchStartedAtMs&profileId=${profileId ?: ""}" + 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&cloudSessionToken=$encodedCloudSessionToken&launchStartedAtMs=$launchStartedAtMs&profileId=${profileId ?: ""}&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 e61f07fd85..e3da738e4a 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 @@ -71,12 +71,14 @@ import com.nuvio.tv.ui.components.SynopsisDescription 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 import androidx.compose.ui.graphics.painter.Painter import coil3.request.ImageRequest import coil3.request.crossfade +import com.nuvio.tv.ui.screens.player.PlayerNextEpisodeRules import com.nuvio.tv.ui.util.localizedGenreLabel import com.nuvio.tv.ui.util.rememberLongPressKeyTracker import java.util.Locale @@ -91,6 +93,8 @@ fun HeroContentSection( nextToWatch: NextToWatch?, onPlayClick: () -> Unit, onPlayLongPress: (() -> Unit)? = null, + onRandomEpisodeClick: ((Video) -> Unit)? = null, + onRandomEpisodeLongPress: ((Video) -> Unit)? = null, isInLibrary: Boolean, onToggleLibrary: () -> Unit, onLibraryLongPress: () -> Unit, @@ -98,6 +102,7 @@ fun HeroContentSection( isMovieWatchedPending: Boolean, onToggleMovieWatched: () -> Unit, trailerAvailable: Boolean = false, + randomAvailable: Boolean = false, onTrailerClick: () -> Unit = {}, hideLogoDuringTrailer: Boolean = false, mdbListRatings: MDBListRatings? = null, @@ -136,6 +141,15 @@ fun HeroContentSection( context = context, rawRes = com.nuvio.tv.R.raw.trailer_play_button ) + val randomEpisode = remember(meta.videos) { + meta.videos + .filter { video -> + video.season != null && video.season > 0 && + video.episode != null && video.episode > 0 && + PlayerNextEpisodeRules.hasEpisodeAired(video.released) + } + .randomOrNull() + } val strCreator = stringResource(R.string.hero_creator) val strDirector = stringResource(R.string.hero_director) val strWriter = stringResource(R.string.hero_writer) @@ -283,6 +297,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/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 9dd62d44e2..8ee7276ccf 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 @@ -293,6 +293,38 @@ fun MetaDetailsScreen( year: String?, runtime: Int?, contentLanguage: String? + ) -> 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() @@ -730,6 +762,42 @@ fun MetaDetailsScreen( meta.resolveContentLanguage() ) }, + 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) }, @@ -766,6 +834,7 @@ fun MetaDetailsScreen( sharedTrailerAudioUrl = uiState.sharedTrailerAudioUrl, sharedTrailerErrorMessage = uiState.sharedTrailerErrorMessage, selectedSharedTrailer = uiState.selectedSharedTrailer, + randomButtonEnabled = uiState.randomButtonEnabled, trailerSeekToken = trailerSeekToken, trailerSeekDeltaMs = trailerSeekDeltaMs, onTrailerControlKey = { keyCode, action, repeatCount -> @@ -1006,6 +1075,8 @@ private fun MetaDetailsContent( onEpisodeStartFromBeginningClick: (Video) -> Unit = {}, onPlayClick: (String) -> Unit, onPlayManuallyClick: (String) -> Unit, + onRandomPlayClick: (Video) -> Unit, + onRandomManualPlayClick: (Video) -> Unit, onPlayStartFromBeginningClick: (String) -> Unit = {}, showManualPlayOption: Boolean, onPlayButtonFocused: () -> Unit, @@ -1031,6 +1102,7 @@ private fun MetaDetailsContent( sharedTrailerAudioUrl: String?, sharedTrailerErrorMessage: String?, selectedSharedTrailer: MetaTrailer?, + randomButtonEnabled: Boolean, trailerSeekToken: Int, trailerSeekDeltaMs: Long, onTrailerControlKey: (keyCode: Int, action: Int, repeatCount: Int) -> Boolean, @@ -1069,6 +1141,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, nextToWatch, episodesForSeason) { resolveHeroPlaybackVideo( meta = meta, @@ -1113,6 +1186,7 @@ private fun MetaDetailsContent( var consumeReturnEpisodeFocusOnClear by rememberSaveable(meta.id) { mutableStateOf(false) } var initialHeroFocusRequested by rememberSaveable(meta.id) { mutableStateOf(false) } var showHeroPlayOptionsDialog by rememberSaveable(meta.id) { mutableStateOf(false) } + var manualPlayEpisode by remember { mutableStateOf(null) } var showSynopsisOverlay by rememberSaveable(meta.id) { mutableStateOf(false) } var initialDetailReturnFocusHandled by rememberSaveable( meta.id, @@ -1567,6 +1641,14 @@ private fun MetaDetailsContent( } } } + + val heroPlayEpisodeClick: (Video) -> Unit = remember(onEpisodeClick) { + { episode -> + markHeroRestore() + onEpisodeClick(episode) + } + } + val episodeClick = remember(onEpisodeClick) { { video: Video -> markEpisodeRestore(video.id) @@ -1785,9 +1867,18 @@ private fun MetaDetailsContent( nextToWatch = nextToWatch, onPlayClick = heroPlayClick, onPlayLongPress = if (showManualPlayOption || nextToWatch?.isResume == true) { - { showHeroPlayOptionsDialog = true } - } else { - null + { + manualPlayEpisode = heroVideo // normal behavior + showHeroPlayOptionsDialog = true + } + } else null, + onRandomEpisodeClick = { episode -> + onRandomPlayClick(episode) + }, + onRandomEpisodeLongPress = { episode -> + manualPlayEpisode = episode + manualPlayIsRandom = true + showHeroPlayOptionsDialog = true }, isInLibrary = isInLibrary, onToggleLibrary = onToggleLibrary, @@ -1800,6 +1891,7 @@ private fun MetaDetailsContent( tmdbRating = tmdbRating.takeIf { showStandardOverallRatings }, showFullReleaseDate = showFullReleaseDate, trailerAvailable = trailerButtonEnabled && !trailerUrl.isNullOrBlank(), + randomAvailable = randomButtonEnabled, onTrailerClick = onTrailerButtonClick, hideLogoDuringTrailer = hideLogoDuringTrailer, isTrailerPlaying = isTrailerPlaying, @@ -2054,7 +2146,7 @@ private fun MetaDetailsContent( } } } - + // Collection as separate section when there are too many tabs if (shouldSplitCollection && collection.isNotEmpty()) { item(key = "collection_section", contentType = "horizontal_row") { @@ -2224,12 +2316,33 @@ private fun MetaDetailsContent( if (showHeroPlayOptionsDialog) { PlayManualOverrideDialog( title = meta.name, - subtitle = nextToWatch?.displayText ?: stringResource(R.string.hero_play), - onDismiss = { showHeroPlayOptionsDialog = false }, + 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 + manualPlayIsRandom = false + manualPlayEpisode = null + }, showPlayManually = showManualPlayOption, onPlayManually = { showHeroPlayOptionsDialog = false - heroPlayManualClick() + val episode = manualPlayEpisode + if (episode != null) { + if (manualPlayIsRandom) { + onRandomManualPlayClick(episode) + } else { + onEpisodeManualPlayClick(episode) + } + } else { + heroPlayManualClick() + } + manualPlayEpisode = null + manualPlayIsRandom = false }, showStartFromBeginning = nextToWatch?.isResume == true, onStartFromBeginning = { @@ -2239,6 +2352,7 @@ private fun MetaDetailsContent( ) } + selectedComment?.let { review -> CommentOverlay( review = review, 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 1ad00d3a66..791352a81c 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 @@ -48,6 +48,7 @@ data class MetaDetailsUiState( val sharedTrailerErrorMessage: String? = null, val selectedSharedTrailer: MetaTrailer? = null, 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 604869ad83..e8dcfaa65a 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 @@ -194,6 +194,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/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 9f074bd773..a2001a2b57 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 @@ -99,6 +99,7 @@ class PlayerRuntimeController( internal val playbackIssueReportRepository: PlaybackIssueReportRepository, internal val tvRecommendationManager: com.nuvio.tv.core.recommendations.TvRecommendationManager, internal val profileId: Int, + internal val randomEpisodeSessionTracker: com.nuvio.tv.core.player.RandomEpisodeSessionTracker, savedStateHandle: SavedStateHandle, internal val scope: CoroutineScope ) { @@ -224,6 +225,16 @@ class PlayerRuntimeController( fun getCurrentStreamUrl(): String = currentStreamUrl fun getCurrentHeaders(): Map = currentHeaders + fun setIsRandom(random: Boolean) { + if (isRandom != random) { + isRandom = random + if (!random) { + randomEpisodeSessionHistory.clear() + } + recomputeNextEpisode(resetVisibility = true) + } + } + fun stopAndRelease() { releasePlayer() } @@ -479,6 +490,8 @@ class PlayerRuntimeController( internal var audioOutputRouteCallback: AudioDeviceCallback? = null 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 @@ -490,7 +503,7 @@ class PlayerRuntimeController( internal var lastVodTelemetryRefreshTimeMs: Long = 0L internal var cachedVodCacheLogState: String = "vod=warming" internal var bufferLogsEnabled: Boolean = false - internal var lastProgressUiUpdateUptimeMs: Long = 0L +internal var lastProgressUiUpdateUptimeMs: Long = 0L internal var lastSkipIntervalEvaluationUptimeMs: Long = 0L internal var lastNextEpisodeEvaluationUptimeMs: Long = 0L internal var bufferLogJob: Job? = null @@ -627,6 +640,10 @@ class PlayerRuntimeController( // was a fire-and-forget coroutine that raced against initializePlayer(), // causing the resume seek to be silently lost when ExoPlayer's STATE_READY // fired before the DB read completed. + if (!isRandom && !navigationArgs.startFromBeginning) { + loadSavedProgressFor(currentSeason, currentEpisode) + } + fetchParentalGuide(contentId, contentType, currentSeason, currentEpisode) observeSubtitleSettings() if (contentType.equals("cloud", ignoreCase = true)) { initializeCloudPlaybackSequence() 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 0ce8b2357b..1d413cffb2 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 @@ -221,11 +221,36 @@ internal fun PlayerRuntimeController.recomputeNextEpisode(resetVisibility: Boole return } - val resolvedNext = PlayerNextEpisodeRules.resolveNextEpisode( - videos = metaVideos, - currentSeason = season, - currentEpisode = episode - ) + val resolvedNext = if (isRandom) { + 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 seen && + PlayerNextEpisodeRules.hasEpisodeAired(video.released) + } + if (candidates.isNotEmpty()) { + candidates.random() + } else { + resolvedContentId?.let { randomEpisodeSessionTracker.clear() } + 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, + 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 224d44c840..e8525c1c22 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 @@ -192,27 +192,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 } @@ -331,7 +331,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 -> @@ -361,6 +361,7 @@ internal fun PlayerRuntimeController.stopProgressUpdates() { } internal fun PlayerRuntimeController.startWatchProgressSaving() { + if (isRandom) return watchProgressSaveJob?.cancel() watchProgressSaveJob = scope.launch { while (isActive) { @@ -371,6 +372,7 @@ internal fun PlayerRuntimeController.startWatchProgressSaving() { } internal fun PlayerRuntimeController.stopWatchProgressSaving() { + if (isRandom) return watchProgressSaveJob?.cancel() watchProgressSaveJob = null } @@ -589,6 +591,7 @@ private fun String.reportSafeHost(): String { } internal fun PlayerRuntimeController.saveWatchProgressIfNeeded() { + if (isRandom) return if (!hasRenderedFirstFrame) return val currentPosition = currentPlaybackPositionMs() ?: return val duration = getEffectiveDuration(currentPosition) @@ -604,6 +607,7 @@ internal fun PlayerRuntimeController.saveWatchProgressIfNeeded() { } internal fun PlayerRuntimeController.saveWatchProgress() { + if (isRandom) return if (!hasRenderedFirstFrame) return val currentPosition = currentPlaybackPositionMs() ?: return val duration = getEffectiveDuration(currentPosition) @@ -650,7 +654,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) } @@ -681,6 +685,7 @@ internal fun PlayerRuntimeController.cancelNextEpisodeAutoPlayOnFatalError() { } internal fun PlayerRuntimeController.saveWatchProgressInternal(position: Long, duration: Long, syncRemote: Boolean = true) { + if (isRandom) return if (contentType.equals("cloud", ignoreCase = true)) { saveCloudLibraryProgress(position, duration, completed = false) return @@ -763,6 +768,7 @@ internal fun PlayerRuntimeController.currentPlaybackProgressPercent(): Float { } internal fun PlayerRuntimeController.refreshScrobbleItem() { + if (isRandom) return currentScrobbleItem = buildScrobbleItem() hasSentScrobbleStartForCurrentItem = false hasRequestedScrobbleStartForCurrentItem = false @@ -785,13 +791,14 @@ internal fun PlayerRuntimeController.buildScrobbleItem(): TrackingMediaReference ) return reference.takeIf { media -> media.hasResolvableIdentity && - (media.kind == TrackingMediaKind.MOVIE || + (media.kind == TrackingMediaKind.MOVIE || media.kind == TrackingMediaKind.ANIME || media.episode != null) } } internal fun PlayerRuntimeController.emitScrobbleStart() { + if (isRandom) return logScrobbleDiagnostic("start_evaluated") if (isShortPlaceholderStream()) { logScrobbleDiagnostic("start_skipped", "reason=short_placeholder") @@ -849,6 +856,7 @@ internal fun PlayerRuntimeController.emitScrobbleStart() { } internal fun PlayerRuntimeController.emitScrobbleStop(progressPercent: Float? = null) { + if (isRandom) return logScrobbleDiagnostic("stop_evaluated", "providedProgress=${progressPercent ?: "none"}") if (isShortPlaceholderStream()) { logScrobbleDiagnostic("stop_skipped", "reason=short_placeholder") @@ -883,6 +891,7 @@ internal fun PlayerRuntimeController.emitScrobbleStop(progressPercent: Float? = } internal fun PlayerRuntimeController.emitScrobblePause(progressPercent: Float? = null) { + if (isRandom) return logScrobbleDiagnostic("pause_evaluated", "providedProgress=${progressPercent ?: "none"}") if (isShortPlaceholderStream()) { logScrobbleDiagnostic("pause_skipped", "reason=short_placeholder") @@ -918,12 +927,14 @@ internal fun PlayerRuntimeController.emitScrobblePause(progressPercent: Float? = } internal fun PlayerRuntimeController.emitCompletionScrobbleStop(progressPercent: Float) { + if (isRandom) return if (progressPercent < 80f || hasSentCompletionScrobbleForCurrentItem) return hasSentCompletionScrobbleForCurrentItem = true emitScrobbleStop(progressPercent = progressPercent) } internal fun PlayerRuntimeController.emitStopScrobbleForCurrentProgress() { + if (isRandom) return val progressPercent = currentPlaybackProgressPercent() if (!shouldSendStopScrobble(hasRequestedScrobbleStartForCurrentItem, progressPercent)) { logScrobbleDiagnostic( @@ -940,10 +951,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 @@ -976,8 +989,8 @@ 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() ) } @@ -1106,9 +1119,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.showSubtitleDelayOverlay + s.showSpeedDialog || s.showMoreDialog || s.showEpisodesPanel || + s.showSourcesPanel || s.showAudioOverlay || s.showStreamInfoOverlay || + s.showSubtitleTimingDialog || s.showSubtitleDelayOverlay if (!s.isPlaying && s.pauseOverlayEnabled && s.error == null && !anyPanelOpen) { _uiState.update { it.copy(showPauseOverlay = true, showControls = false) } } @@ -1837,4 +1850,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 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 2954da996e..6e80af39fa 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 @@ -55,12 +55,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)") @@ -83,14 +83,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"}" ) } contentId?.takeIf { it.isNotBlank() }?.let { id -> @@ -103,7 +103,7 @@ internal fun PlayerRuntimeController.preparePlaybackBeforeStart( // callback fired before the DB read completed, causing the resume // seek to be silently skipped — the player would start from 0:00 // or hang in buffering after a late seek. - if (loadSavedProgress) { + if (loadSavedProgress && !isRandom) { recordLoadingDiagnosticEvent( phase = "loading_saved_progress", message = context.getString(com.nuvio.tv.R.string.player_loading_preparing) @@ -125,6 +125,11 @@ private fun String.safeScrobbleHost(): String { } internal suspend fun PlayerRuntimeController.warmTraktEpisodeMappingForCurrentPlayback() { + if (isRandom) { + currentTraktEpisodeMapping = null + currentTraktEpisodeMappingKey = null + return + } if (!traktEpisodeMappingService.isTraktAuthenticated()) { currentTraktEpisodeMapping = null currentTraktEpisodeMappingKey = 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 5865f13d06..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 @@ -148,8 +148,12 @@ fun PlayerScreen( onPlaybackErrorBack: () -> Unit = { onBackPress(null, null, null, false, false) }, onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?, exitReason: PlayerExitReason?) -> Unit)? = null, onPlayRecommendation: (PostPlayRecommendation, manualSelection: Boolean) -> Unit = { _, _ -> }, - onOpenRecommendationDetails: (PostPlayRecommendation) -> Unit = {} + onOpenRecommendationDetails: (PostPlayRecommendation) -> Unit = {}, + isRandom: Boolean ) { + LaunchedEffect(isRandom) { + viewModel.setIsRandom(isRandom) + } val uiState by viewModel.uiState.collectAsState() val postPlayRecommendationState by viewModel.postPlayRecommendationUiState.collectAsState() val effectiveAutoplayEnabled by viewModel.effectiveAutoplayEnabled.collectAsState(initial = false) 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 dd641fecb5..8724b4ae5f 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 @@ -94,6 +94,7 @@ class PlayerViewModel @Inject constructor( private val subtitleFileCache: com.nuvio.tv.core.player.SubtitleFileCache, private val tvRecommendationManager: com.nuvio.tv.core.recommendations.TvRecommendationManager, profileManager: com.nuvio.tv.core.profile.ProfileManager, + private val randomEpisodeSessionTracker: com.nuvio.tv.core.player.RandomEpisodeSessionTracker, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -139,6 +140,7 @@ class PlayerViewModel @Inject constructor( tvRecommendationManager = tvRecommendationManager, profileId = savedStateHandle.get("profileId")?.toIntOrNull() ?: profileManager.activeProfileId.value, + randomEpisodeSessionTracker = randomEpisodeSessionTracker, savedStateHandle = savedStateHandle, scope = viewModelScope ) @@ -164,6 +166,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 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 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 9eb5eaedf0..dd26a1c5e2 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 @@ -592,6 +592,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 2e4f623df8..00bda33806 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 @@ -72,6 +72,7 @@ data class LayoutSettingsUiState( val blurContinueWatchingNextUp: Boolean = false, val useEpisodeThumbnailsInCw: Boolean = true, val detailPageTrailerButtonEnabled: Boolean = true, + val detailPageRandomButtonEnabled: Boolean = false, val detailPageTrailerAutoplayEnabled: Boolean = true, val detailPageTrailerAutoplayDelaySeconds: Int = 7, val preferExternalMetaAddonDetail: Boolean = false, @@ -128,6 +129,7 @@ sealed class LayoutSettingsEvent { data class SetBlurContinueWatchingNextUp(val enabled: Boolean) : LayoutSettingsEvent() data class SetUseEpisodeThumbnailsInCw(val enabled: Boolean) : LayoutSettingsEvent() data class SetDetailPageTrailerButtonEnabled(val enabled: Boolean) : LayoutSettingsEvent() + data class SetDetailPageRandomButtonEnabled(val enabled: Boolean) : LayoutSettingsEvent() data class SetDetailPageTrailerAutoplayEnabled(val enabled: Boolean) : LayoutSettingsEvent() data class SetDetailPageTrailerAutoplayDelaySeconds(val seconds: Int) : LayoutSettingsEvent() data class SetPreferExternalMetaAddonDetail(val enabled: Boolean) : LayoutSettingsEvent() @@ -332,6 +334,11 @@ class LayoutSettingsViewModel @Inject constructor( updateUiStateIfChanged { it.copy(detailPageTrailerButtonEnabled = enabled) } } } + viewModelScope.launch { + layoutPreferenceDataStore.detailPageRandomButtonEnabled.distinctUntilChanged().collectLatest { enabled -> + updateUiStateIfChanged { it.copy(detailPageRandomButtonEnabled = enabled) } + } + } viewModelScope.launch { trailerSettingsDataStore.settings.distinctUntilChanged().collectLatest { settings -> updateUiStateIfChanged { @@ -427,6 +434,7 @@ class LayoutSettingsViewModel @Inject constructor( is LayoutSettingsEvent.SetBlurContinueWatchingNextUp -> setBlurContinueWatchingNextUp(event.enabled) is LayoutSettingsEvent.SetUseEpisodeThumbnailsInCw -> setUseEpisodeThumbnailsInCw(event.enabled) is LayoutSettingsEvent.SetDetailPageTrailerButtonEnabled -> setDetailPageTrailerButtonEnabled(event.enabled) + is LayoutSettingsEvent.SetDetailPageRandomButtonEnabled -> setDetailPageRandomButtonEnabled(event.enabled) is LayoutSettingsEvent.SetDetailPageTrailerAutoplayEnabled -> setDetailPageTrailerAutoplayEnabled(event.enabled) is LayoutSettingsEvent.SetDetailPageTrailerAutoplayDelaySeconds -> setDetailPageTrailerAutoplayDelaySeconds(event.seconds) is LayoutSettingsEvent.SetPreferExternalMetaAddonDetail -> setPreferExternalMetaAddonDetail(event.enabled) @@ -698,6 +706,13 @@ class LayoutSettingsViewModel @Inject constructor( } } + private fun setDetailPageRandomButtonEnabled(enabled: Boolean) { + if (_uiState.value.detailPageRandomButtonEnabled == enabled) return + viewModelScope.launch { + layoutPreferenceDataStore.setDetailPageRandomButtonEnabled(enabled) + } + } + private fun setDetailPageTrailerAutoplayEnabled(enabled: Boolean) { if (_uiState.value.detailPageTrailerAutoplayEnabled == enabled) return viewModelScope.launch { 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 3c133eda39..daf5452ada 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 @@ -116,7 +116,8 @@ fun StreamScreen( onSourceSelectionRestoreHandled: () -> Unit = {}, 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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f730bce9c6..843567e99a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1023,6 +1023,8 @@ Upcoming 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