Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
29654c5
Add random episode button to details page
DeclanSC Mar 22, 2026
5bc51d1
Add random episode button to settings
DeclanSC Mar 22, 2026
ea752c4
Merge tag 'refs/tags/0.5.1-beta' into dev
DeclanSC Mar 24, 2026
f9d0296
Pass isRandom to Stream and Player screens
DeclanSC Mar 25, 2026
106f56c
Add Random episode logic for: Next Episode, No Tracking/Scrobbling, B…
DeclanSC Mar 25, 2026
f4408a5
Merge branch 'NuvioMedia:dev' into dev
DeclanSC Mar 25, 2026
a86ae14
Update random episode playback ended route
DeclanSC Mar 26, 2026
6efd96b
Merge remote-tracking branch 'refs/remotes/upstream/dev' into dev
DeclanSC Mar 26, 2026
747e0ee
Merge branch 'NuvioMedia:dev' into dev
DeclanSC Mar 29, 2026
86d3cfa
Update StreamScreen.kt
DeclanSC Mar 29, 2026
d526a7e
Merge branch 'NuvioMedia:dev' into dev
DeclanSC Mar 29, 2026
09a4a0c
Merge remote-tracking branch 'origin/random-episode' into feat-random…
DeclanSC Apr 25, 2026
b56d779
Update random episode branch
DeclanSC Apr 25, 2026
cd04efa
Handle random episode on no next episode
DeclanSC Apr 25, 2026
87e76ac
Merge branch 'NuvioMedia:dev' into dev
DeclanSC Apr 26, 2026
06a8eeb
Merge branch 'NuvioMedia:dev' into feat-random-episode
DeclanSC Apr 27, 2026
0448d7d
Merge remote-tracking branch 'origin/dev' into feat-random-episode
DeclanSC Apr 30, 2026
ab7abcc
Merge branch 'NuvioMedia:dev' into feat-random-episode
DeclanSC May 5, 2026
1470b9b
Merge branch 'refs/heads/dev' into feat-random-episode
DeclanSC May 13, 2026
559ea2e
Update branch
DeclanSC May 13, 2026
c428077
Merge branch 'refs/heads/dev' into feat-random-episode
DeclanSC Jun 15, 2026
0611b57
Update to latest version
DeclanSC Jun 16, 2026
1be7e48
Merge branch 'NuvioMedia:dev' into feat-random-episode
DeclanSC Jun 16, 2026
184d5c2
Tidy random episode code
DeclanSC Jun 16, 2026
029df65
Merge remote-tracking branch 'origin/feat-random-episode' into feat-r…
DeclanSC Jun 16, 2026
96b644f
Merge branch 'NuvioMedia:dev' into feat-random-episode
DeclanSC Jun 22, 2026
9f5c86a
Merge branch 'dev' into feat-random-episode
DeclanSC Aug 7, 2026
4966276
Update to latest version
DeclanSC Aug 7, 2026
bcfaa3c
Merge branch 'dev' into feat-random-episode
DeclanSC Aug 9, 2026
19f7813
Random episode - code tidy
DeclanSC Aug 9, 2026
b4e137d
Merge branch 'refs/heads/dev' into feat-random-episode
DeclanSC Aug 22, 2026
10b609b
Stop repeats in random episode session
DeclanSC Aug 22, 2026
b3025f2
Add played random episode to session to stop repeats.
DeclanSC Aug 22, 2026
6025685
Merge branch 'refs/heads/dev' into feat-random-episode
DeclanSC Sep 4, 2026
c642de2
Update to latest version
DeclanSC Sep 4, 2026
f6442d8
Revert changed back stack navigation logic
DeclanSC Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -377,6 +378,10 @@ class LayoutPreferenceDataStore @Inject constructor(
prefs[detailPageTrailerButtonEnabledKey] ?: true
}

val detailPageRandomButtonEnabled: Flow<Boolean> = profileFlow { prefs ->
prefs[detailPageRandomButtonEnabledKey] ?: false
}

val preferExternalMetaAddonDetail: StateFlow<Boolean> = profileFlow { prefs ->
prefs[preferExternalMetaAddonDetailKey] ?: true
}.stateIn(scope, SharingStarted.Eagerly, true)
Expand Down Expand Up @@ -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
Expand Down
141 changes: 118 additions & 23 deletions app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
28 changes: 28 additions & 0 deletions app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -91,13 +93,16 @@ fun HeroContentSection(
nextToWatch: NextToWatch?,
onPlayClick: () -> Unit,
onPlayLongPress: (() -> Unit)? = null,
onRandomEpisodeClick: ((Video) -> Unit)? = null,
onRandomEpisodeLongPress: ((Video) -> Unit)? = null,
isInLibrary: Boolean,
onToggleLibrary: () -> Unit,
onLibraryLongPress: () -> Unit,
isMovieWatched: Boolean,
isMovieWatchedPending: Boolean,
onToggleMovieWatched: () -> Unit,
trailerAvailable: Boolean = false,
randomAvailable: Boolean = false,
onTrailerClick: () -> Unit = {},
hideLogoDuringTrailer: Boolean = false,
mdbListRatings: MDBListRatings? = null,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading