diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 5638063b8..f523d8d20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -964,6 +964,7 @@ private fun MainAppContent( episodeThumbnail = route.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, + bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, @@ -1039,6 +1040,7 @@ private fun MainAppContent( episodeThumbnail = route.episodeThumbnail, streamTitle = stream.streamLabel, streamSubtitle = stream.streamSubtitle, + bingeGroup = stream.behaviorHints.bingeGroup, pauseDescription = pauseDescription, providerName = stream.addonName, providerAddonId = stream.addonId, @@ -1101,6 +1103,7 @@ private fun MainAppContent( episodeThumbnail = launch.episodeThumbnail, streamTitle = launch.streamTitle, streamSubtitle = launch.streamSubtitle, + initialBingeGroup = launch.bingeGroup, pauseDescription = launch.pauseDescription, providerName = launch.providerName, providerAddonId = launch.providerAddonId, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 9c737b72d..b958ae4ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -21,6 +21,7 @@ data class PlayerLaunch( val episodeThumbnail: String? = null, val streamTitle: String, val streamSubtitle: String? = null, + val bingeGroup: String? = null, val pauseDescription: String? = null, val providerName: String, val providerAddonId: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 65d07e2c9..276ebd708 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -88,6 +88,7 @@ fun PlayerScreen( providerName: String, streamTitle: String, streamSubtitle: String?, + initialBingeGroup: String? = null, pauseDescription: String? = null, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -135,6 +136,7 @@ fun PlayerScreen( var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) } var activeProviderName by rememberSaveable { mutableStateOf(providerName) } var activeProviderAddonId by rememberSaveable { mutableStateOf(providerAddonId) } + var currentStreamBingeGroup by rememberSaveable { mutableStateOf(initialBingeGroup) } var activeSeasonNumber by rememberSaveable { mutableStateOf(seasonNumber) } var activeEpisodeNumber by rememberSaveable { mutableStateOf(episodeNumber) } var activeEpisodeTitle by rememberSaveable { mutableStateOf(episodeTitle) } @@ -531,6 +533,7 @@ fun PlayerScreen( activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup activeInitialPositionMs = currentPositionMs activeInitialProgressFraction = null showSourcesPanel = false @@ -584,6 +587,7 @@ fun PlayerScreen( activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup activeSeasonNumber = episode.season activeEpisodeNumber = episode.episode activeEpisodeTitle = episode.title @@ -606,19 +610,39 @@ fun PlayerScreen( val type = contentType ?: parentMetaType val settings = playerSettingsUiState + val shouldAutoSelectInManualMode = + settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && + ( + settings.streamAutoPlayNextEpisodeEnabled || + settings.streamAutoPlayPreferBingeGroup + ) // Determine auto-play mode for next episode - val effectiveMode = if (settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL) { - if (settings.streamAutoPlayNextEpisodeEnabled) StreamAutoPlayMode.FIRST_STREAM - else StreamAutoPlayMode.MANUAL + val effectiveMode = if (shouldAutoSelectInManualMode) { + StreamAutoPlayMode.FIRST_STREAM } else { settings.streamAutoPlayMode } - - val currentBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - // Try to find binge group of current stream (not directly available, pass empty) + val effectiveSource = if (shouldAutoSelectInManualMode) { + com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES + } else { + settings.streamAutoPlaySource + } + val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedAddons + } + val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedPlugins + } + val effectiveRegex = if (shouldAutoSelectInManualMode) { "" - } else "" + } else { + settings.streamAutoPlayRegex + } nextEpisodeAutoPlayJob = scope.launch { PlayerStreamsRepository.loadEpisodeStreams( @@ -646,11 +670,17 @@ fun PlayerScreen( StreamAutoPlaySelector.selectAutoPlayStream( streams = allStreams, mode = effectiveMode, - regexPattern = settings.streamAutoPlayRegex, - source = settings.streamAutoPlaySource, + regexPattern = effectiveRegex, + source = effectiveSource, installedAddonNames = installedAddonNames, - selectedAddons = settings.streamAutoPlaySelectedAddons, - selectedPlugins = settings.streamAutoPlaySelectedPlugins, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + }, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, ) } else null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 1c6652b59..cd32101a1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -10,6 +10,8 @@ object StreamAutoPlaySelector { installedAddonNames: Set, selectedAddons: Set, selectedPlugins: Set, + preferredBingeGroup: String? = null, + preferBingeGroupInSelection: Boolean = false, ): StreamItem? { if (streams.isEmpty()) return null @@ -29,6 +31,14 @@ object StreamAutoPlaySelector { if (candidateStreams.isEmpty()) return null if (mode == StreamAutoPlayMode.MANUAL) return null + val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() + if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + val bingeGroupMatch = candidateStreams.firstOrNull { stream -> + stream.behaviorHints.bingeGroup == targetBingeGroup && stream.directPlaybackUrl != null + } + if (bingeGroupMatch != null) return bingeGroupMatch + } + return when (mode) { StreamAutoPlayMode.MANUAL -> null StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt new file mode 100644 index 000000000..45fa47405 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -0,0 +1,162 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StreamAutoPlaySelectorTest { + + @Test + fun `bingeGroup-first selects matching stream before first stream mode`() { + val first = stream( + addonName = "AddonA", + url = "https://example.com/first.m3u8", + name = "1080p", + bingeGroup = "other-group", + ) + val preferred = stream( + addonName = "AddonB", + url = "https://example.com/preferred.m3u8", + name = "720p", + bingeGroup = "same-group", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(first, preferred), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("AddonA", "AddonB"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredBingeGroup = "same-group", + preferBingeGroupInSelection = true, + ) + + assertEquals(preferred, selected) + } + + @Test + fun `falls back to normal mode when no bingeGroup match exists`() { + val first = stream( + addonName = "AddonA", + url = "https://example.com/first.m3u8", + name = "First", + bingeGroup = "group-a", + ) + val second = stream( + addonName = "AddonB", + url = "https://example.com/second.m3u8", + name = "Second", + bingeGroup = "group-b", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(first, second), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("AddonA", "AddonB"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredBingeGroup = "missing-group", + preferBingeGroupInSelection = true, + ) + + assertEquals(first, selected) + } + + @Test + fun `bingeGroup-first respects source and addon plugin filters`() { + val filteredOutAddonMatch = stream( + addonName = "AddonFilteredOut", + url = "https://example.com/addon-match.m3u8", + bingeGroup = "same-group", + ) + val allowedPluginMatch = stream( + addonName = "PluginAllowed", + url = "https://example.com/plugin-match.m3u8", + bingeGroup = "same-group", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(filteredOutAddonMatch, allowedPluginMatch), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ENABLED_PLUGINS_ONLY, + installedAddonNames = setOf("AddonFilteredOut"), + selectedAddons = emptySet(), + selectedPlugins = setOf("PluginAllowed"), + preferredBingeGroup = "same-group", + preferBingeGroupInSelection = true, + ) + + assertEquals(allowedPluginMatch, selected) + } + + @Test + fun `blank preferredBingeGroup behaves as disabled`() { + val first = stream( + addonName = "AddonA", + url = "https://example.com/first.m3u8", + bingeGroup = "group-a", + ) + val second = stream( + addonName = "AddonB", + url = "https://example.com/second.m3u8", + bingeGroup = "group-b", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(first, second), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("AddonA", "AddonB"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredBingeGroup = " ", + preferBingeGroupInSelection = true, + ) + + assertEquals(first, selected) + } + + @Test + fun `manual mode remains manual even with matching bingeGroup`() { + val matched = stream( + addonName = "AddonA", + url = "https://example.com/match.m3u8", + bingeGroup = "same-group", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(matched), + mode = StreamAutoPlayMode.MANUAL, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("AddonA"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredBingeGroup = "same-group", + preferBingeGroupInSelection = true, + ) + + assertNull(selected) + } + + private fun stream( + addonName: String, + url: String? = null, + name: String? = null, + bingeGroup: String? = null, + ): StreamItem = StreamItem( + name = name, + url = url, + addonName = addonName, + addonId = addonName, + behaviorHints = StreamBehaviorHints( + bingeGroup = bingeGroup, + ), + ) +}