From 5ecb5b8131dee16b71c191a162d8ab7be5782158 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:19:14 +0530 Subject: [PATCH] feat: adding autoplay feature --- .../player/PlayerSettingsStorage.android.kt | 87 +++ .../commonMain/kotlin/com/nuvio/app/App.kt | 52 ++ .../player/PlayerSettingsRepository.kt | 84 ++ .../features/player/PlayerSettingsStorage.kt | 12 + .../features/settings/PlaybackSettingsPage.kt | 728 ++++++++++++++++++ .../features/streams/StreamAutoPlayModels.kt | 13 + .../features/streams/StreamAutoPlayPolicy.kt | 21 + .../streams/StreamAutoPlaySelector.kt | 78 ++ .../app/features/streams/StreamModels.kt | 3 + .../app/features/streams/StreamsRepository.kt | 100 +++ .../app/features/streams/StreamsScreen.kt | 39 + .../player/PlayerSettingsStorage.ios.kt | 74 ++ 12 files changed, 1291 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 306f071b1..44b071850 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -20,6 +20,12 @@ actual object PlayerSettingsStorage { private const val decoderPriorityKey = "decoder_priority" private const val mapDV7ToHevcKey = "map_dv7_to_hevc" private const val tunnelingEnabledKey = "tunneling_enabled" + private const val streamAutoPlayModeKey = "stream_auto_play_mode" + private const val streamAutoPlaySourceKey = "stream_auto_play_source" + private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" + private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" + private const val streamAutoPlayRegexKey = "stream_auto_play_regex" + private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" private var preferences: SharedPreferences? = null @@ -243,4 +249,85 @@ actual object PlayerSettingsStorage { ?.putBoolean(ProfileScopedKey.of(tunnelingEnabledKey), enabled) ?.apply() } + + actual fun loadStreamAutoPlayMode(): String? = + preferences?.getString(ProfileScopedKey.of(streamAutoPlayModeKey), null) + + actual fun saveStreamAutoPlayMode(mode: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(streamAutoPlayModeKey), mode) + ?.apply() + } + + actual fun loadStreamAutoPlaySource(): String? = + preferences?.getString(ProfileScopedKey.of(streamAutoPlaySourceKey), null) + + actual fun saveStreamAutoPlaySource(source: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(streamAutoPlaySourceKey), source) + ?.apply() + } + + actual fun loadStreamAutoPlaySelectedAddons(): Set? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getStringSet(key, emptySet()) ?: emptySet() + } else { + null + } + } + + actual fun saveStreamAutoPlaySelectedAddons(addons: Set) { + preferences + ?.edit() + ?.putStringSet(ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey), addons) + ?.apply() + } + + actual fun loadStreamAutoPlaySelectedPlugins(): Set? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getStringSet(key, emptySet()) ?: emptySet() + } else { + null + } + } + + actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) { + preferences + ?.edit() + ?.putStringSet(ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey), plugins) + ?.apply() + } + + actual fun loadStreamAutoPlayRegex(): String? = + preferences?.getString(ProfileScopedKey.of(streamAutoPlayRegexKey), null) + + actual fun saveStreamAutoPlayRegex(regex: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(streamAutoPlayRegexKey), regex) + ?.apply() + } + + actual fun loadStreamAutoPlayTimeoutSeconds(): Int? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getInt(key, 3) + } else { + null + } + } + + actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) { + preferences + ?.edit() + ?.putInt(ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey), seconds) + ?.apply() + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index a81000ff3..e10c81ee0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -859,6 +859,58 @@ private fun MainAppContent( } } + val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() + var autoPlayHandled by rememberSaveable(route.videoId, effectiveVideoId) { mutableStateOf(false) } + LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled) { + if (!reuseHandled) return@LaunchedEffect + if (autoPlayHandled) return@LaunchedEffect + val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect + autoPlayHandled = true + if (playerSettings.streamReuseLastLinkEnabled) { + val cacheKey = StreamLinkCacheRepository.contentKey(route.type, effectiveVideoId) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = sourceUrl, + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + filename = stream.behaviorHints.filename, + videoSize = stream.behaviorHints.videoSize, + ) + } + val launchId = PlayerLaunchStore.put( + PlayerLaunch( + title = route.title, + sourceUrl = sourceUrl, + sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + logo = route.logo, + poster = route.poster, + background = route.background, + seasonNumber = route.seasonNumber, + episodeNumber = route.episodeNumber, + episodeTitle = route.episodeTitle, + episodeThumbnail = route.episodeThumbnail, + streamTitle = stream.streamLabel, + streamSubtitle = stream.streamSubtitle, + pauseDescription = pauseDescription, + providerName = stream.addonName, + providerAddonId = stream.addonId, + contentType = route.type, + videoId = effectiveVideoId, + parentMetaId = route.parentMetaId ?: effectiveVideoId, + parentMetaType = route.parentMetaType ?: route.type, + initialPositionMs = route.resumePositionMs ?: 0L, + initialProgressFraction = route.resumeProgressFraction, + ) + ) + StreamsRepository.consumeAutoPlay() + route.streamContextId?.let(StreamContextStore::remove) + navController.navigate(PlayerRoute(launchId = launchId)) { + popUpTo { inclusive = true } + } + } + if (!hasResolvedVideoId) { Box( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 407e2ea51..f5682e33d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.player +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,6 +18,12 @@ data class PlayerSettingsUiState( val decoderPriority: Int = 1, val mapDV7ToHevc: Boolean = false, val tunnelingEnabled: Boolean = false, + val streamAutoPlayMode: StreamAutoPlayMode = StreamAutoPlayMode.MANUAL, + val streamAutoPlaySource: StreamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES, + val streamAutoPlaySelectedAddons: Set = emptySet(), + val streamAutoPlaySelectedPlugins: Set = emptySet(), + val streamAutoPlayRegex: String = "", + val streamAutoPlayTimeoutSeconds: Int = 3, ) object PlayerSettingsRepository { @@ -34,6 +42,12 @@ object PlayerSettingsRepository { private var decoderPriority = 1 private var mapDV7ToHevc = false private var tunnelingEnabled = false + private var streamAutoPlayMode = StreamAutoPlayMode.MANUAL + private var streamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES + private var streamAutoPlaySelectedAddons: Set = emptySet() + private var streamAutoPlaySelectedPlugins: Set = emptySet() + private var streamAutoPlayRegex = "" + private var streamAutoPlayTimeoutSeconds = 3 fun ensureLoaded() { if (hasLoaded) return @@ -57,6 +71,12 @@ object PlayerSettingsRepository { decoderPriority = 1 mapDV7ToHevc = false tunnelingEnabled = false + streamAutoPlayMode = StreamAutoPlayMode.MANUAL + streamAutoPlaySource = StreamAutoPlaySource.ALL_SOURCES + streamAutoPlaySelectedAddons = emptySet() + streamAutoPlaySelectedPlugins = emptySet() + streamAutoPlayRegex = "" + streamAutoPlayTimeoutSeconds = 3 publish() } @@ -88,6 +108,16 @@ object PlayerSettingsRepository { decoderPriority = PlayerSettingsStorage.loadDecoderPriority() ?: 1 mapDV7ToHevc = PlayerSettingsStorage.loadMapDV7ToHevc() ?: false tunnelingEnabled = PlayerSettingsStorage.loadTunnelingEnabled() ?: false + streamAutoPlayMode = PlayerSettingsStorage.loadStreamAutoPlayMode() + ?.let { runCatching { StreamAutoPlayMode.valueOf(it) }.getOrNull() } + ?: StreamAutoPlayMode.MANUAL + streamAutoPlaySource = PlayerSettingsStorage.loadStreamAutoPlaySource() + ?.let { runCatching { StreamAutoPlaySource.valueOf(it) }.getOrNull() } + ?: StreamAutoPlaySource.ALL_SOURCES + streamAutoPlaySelectedAddons = PlayerSettingsStorage.loadStreamAutoPlaySelectedAddons() ?: emptySet() + streamAutoPlaySelectedPlugins = PlayerSettingsStorage.loadStreamAutoPlaySelectedPlugins() ?: emptySet() + streamAutoPlayRegex = PlayerSettingsStorage.loadStreamAutoPlayRegex() ?: "" + streamAutoPlayTimeoutSeconds = PlayerSettingsStorage.loadStreamAutoPlayTimeoutSeconds() ?: 3 publish() } @@ -186,6 +216,54 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveTunnelingEnabled(enabled) } + fun setStreamAutoPlayMode(mode: StreamAutoPlayMode) { + ensureLoaded() + if (streamAutoPlayMode == mode) return + streamAutoPlayMode = mode + publish() + PlayerSettingsStorage.saveStreamAutoPlayMode(mode.name) + } + + fun setStreamAutoPlaySource(source: StreamAutoPlaySource) { + ensureLoaded() + if (streamAutoPlaySource == source) return + streamAutoPlaySource = source + publish() + PlayerSettingsStorage.saveStreamAutoPlaySource(source.name) + } + + fun setStreamAutoPlaySelectedAddons(addons: Set) { + ensureLoaded() + if (streamAutoPlaySelectedAddons == addons) return + streamAutoPlaySelectedAddons = addons + publish() + PlayerSettingsStorage.saveStreamAutoPlaySelectedAddons(addons) + } + + fun setStreamAutoPlaySelectedPlugins(plugins: Set) { + ensureLoaded() + if (streamAutoPlaySelectedPlugins == plugins) return + streamAutoPlaySelectedPlugins = plugins + publish() + PlayerSettingsStorage.saveStreamAutoPlaySelectedPlugins(plugins) + } + + fun setStreamAutoPlayRegex(regex: String) { + ensureLoaded() + if (streamAutoPlayRegex == regex) return + streamAutoPlayRegex = regex + publish() + PlayerSettingsStorage.saveStreamAutoPlayRegex(regex) + } + + fun setStreamAutoPlayTimeoutSeconds(seconds: Int) { + ensureLoaded() + if (streamAutoPlayTimeoutSeconds == seconds) return + streamAutoPlayTimeoutSeconds = seconds + publish() + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(seconds) + } + private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, @@ -199,6 +277,12 @@ object PlayerSettingsRepository { decoderPriority = decoderPriority, mapDV7ToHevc = mapDV7ToHevc, tunnelingEnabled = tunnelingEnabled, + streamAutoPlayMode = streamAutoPlayMode, + streamAutoPlaySource = streamAutoPlaySource, + streamAutoPlaySelectedAddons = streamAutoPlaySelectedAddons, + streamAutoPlaySelectedPlugins = streamAutoPlaySelectedPlugins, + streamAutoPlayRegex = streamAutoPlayRegex, + streamAutoPlayTimeoutSeconds = streamAutoPlayTimeoutSeconds, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 02a483b1a..72a73334d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -29,4 +29,16 @@ internal expect object PlayerSettingsStorage { fun saveMapDV7ToHevc(enabled: Boolean) fun loadTunnelingEnabled(): Boolean? fun saveTunnelingEnabled(enabled: Boolean) + fun loadStreamAutoPlayMode(): String? + fun saveStreamAutoPlayMode(mode: String) + fun loadStreamAutoPlaySource(): String? + fun saveStreamAutoPlaySource(source: String) + fun loadStreamAutoPlaySelectedAddons(): Set? + fun saveStreamAutoPlaySelectedAddons(addons: Set) + fun loadStreamAutoPlaySelectedPlugins(): Set? + fun saveStreamAutoPlaySelectedPlugins(plugins: Set) + fun loadStreamAutoPlayRegex(): String? + fun saveStreamAutoPlayRegex(regex: String) + fun loadStreamAutoPlayTimeoutSeconds(): Int? + fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 9328b5783..0e8e941b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,30 +14,46 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.languageLabelForCode +import com.nuvio.app.features.plugins.PluginRepository +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.isIos internal fun LazyListScope.playbackSettingsContent( @@ -89,6 +106,15 @@ private fun PlaybackSettingsSection( var showSecondarySubtitleDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } + var showAutoPlayModeDialog by remember { mutableStateOf(false) } + var showAutoPlaySourceDialog by remember { mutableStateOf(false) } + var showAutoPlayAddonSelectionDialog by remember { mutableStateOf(false) } + var showAutoPlayPluginSelectionDialog by remember { mutableStateOf(false) } + var showAutoPlayRegexDialog by remember { mutableStateOf(false) } + val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val pluginUiState by PluginRepository.uiState.collectAsStateWithLifecycle() + val hapticFeedback = LocalHapticFeedback.current val sectionSpacing = if (isTablet) 18.dp else 12.dp Column( @@ -177,6 +203,134 @@ private fun PlaybackSettingsSection( } } + SettingsSection( + title = "STREAM AUTO-PLAY", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = "Stream Selection Mode", + description = when (autoPlayPlayerSettings.streamAutoPlayMode) { + StreamAutoPlayMode.MANUAL -> "Manual" + StreamAutoPlayMode.FIRST_STREAM -> "First Available Stream" + StreamAutoPlayMode.REGEX_MATCH -> "Regex Match" + }, + isTablet = isTablet, + onClick = { showAutoPlayModeDialog = true }, + ) + if (autoPlayPlayerSettings.streamAutoPlayMode != StreamAutoPlayMode.MANUAL) { + if (autoPlayPlayerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Regex Pattern", + description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { "Not set" }, + isTablet = isTablet, + onClick = { showAutoPlayRegexDialog = true }, + ) + } + SettingsGroupDivider(isTablet = isTablet) + val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds + val timeoutLabel = when (timeoutSec) { + 0 -> "Instant" + 11 -> "Unlimited" + else -> "${timeoutSec}s" + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 18.dp else 16.dp, vertical = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Stream Timeout", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "How long to wait for streams before auto-selecting.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = timeoutLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + var sliderValue by remember(timeoutSec) { mutableFloatStateOf(timeoutSec.toFloat()) } + var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec) } + Slider( + value = sliderValue, + onValueChange = { + sliderValue = it + val steppedValue = it.toInt() + if (steppedValue != lastHapticStep) { + lastHapticStep = steppedValue + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + }, + onValueChangeFinished = { + PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(sliderValue.toInt()) + }, + valueRange = 0f..11f, + steps = 10, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Source Scope", + description = when (autoPlayPlayerSettings.streamAutoPlaySource) { + StreamAutoPlaySource.ALL_SOURCES -> "All Sources" + StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> "Installed Addons Only" + StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> "Enabled Plugins Only" + }, + isTablet = isTablet, + onClick = { showAutoPlaySourceDialog = true }, + ) + if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.ENABLED_PLUGINS_ONLY) { + SettingsGroupDivider(isTablet = isTablet) + val addonSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedAddons.isEmpty()) { + "All Addons" + } else { + "${autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size} selected" + } + SettingsNavigationRow( + title = "Allowed Addons", + description = addonSubtitle, + isTablet = isTablet, + onClick = { showAutoPlayAddonSelectionDialog = true }, + ) + } + if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.INSTALLED_ADDONS_ONLY) { + SettingsGroupDivider(isTablet = isTablet) + val pluginSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.isEmpty()) { + "All Plugins" + } else { + "${autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size} selected" + } + SettingsNavigationRow( + title = "Allowed Plugins", + description = pluginSubtitle, + isTablet = isTablet, + onClick = { showAutoPlayPluginSelectionDialog = true }, + ) + } + } + } + } + if (!isIos) { SettingsSection( title = "DECODER", @@ -308,6 +462,78 @@ private fun PlaybackSettingsSection( onDismiss = { showDecoderPriorityDialog = false }, ) } + + if (showAutoPlayModeDialog) { + StreamAutoPlayModeDialog( + selectedMode = autoPlayPlayerSettings.streamAutoPlayMode, + onModeSelected = { + PlayerSettingsRepository.setStreamAutoPlayMode(it) + showAutoPlayModeDialog = false + }, + onDismiss = { showAutoPlayModeDialog = false }, + ) + } + + if (showAutoPlaySourceDialog) { + StreamAutoPlaySourceDialog( + selectedSource = autoPlayPlayerSettings.streamAutoPlaySource, + onSourceSelected = { + PlayerSettingsRepository.setStreamAutoPlaySource(it) + showAutoPlaySourceDialog = false + }, + onDismiss = { showAutoPlaySourceDialog = false }, + ) + } + + if (showAutoPlayAddonSelectionDialog) { + val addonNames = addonUiState.addons + .mapNotNull { it.manifest } + .filter { manifest -> manifest.resources.any { resource -> resource.name == "stream" } } + .map { it.name } + .distinct() + .sorted() + StreamAutoPlayProviderSelectionDialog( + title = "Allowed Addons", + allLabel = "All Addons", + items = addonNames, + selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedAddons, + onSelectionSaved = { + PlayerSettingsRepository.setStreamAutoPlaySelectedAddons(it) + showAutoPlayAddonSelectionDialog = false + }, + onDismiss = { showAutoPlayAddonSelectionDialog = false }, + ) + } + + if (showAutoPlayPluginSelectionDialog) { + val pluginNames = pluginUiState.scrapers + .filter { it.enabled } + .map { it.name } + .distinct() + .sorted() + StreamAutoPlayProviderSelectionDialog( + title = "Allowed Plugins", + allLabel = "All Plugins", + items = pluginNames, + selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedPlugins, + onSelectionSaved = { + PlayerSettingsRepository.setStreamAutoPlaySelectedPlugins(it) + showAutoPlayPluginSelectionDialog = false + }, + onDismiss = { showAutoPlayPluginSelectionDialog = false }, + ) + } + + if (showAutoPlayRegexDialog) { + StreamAutoPlayRegexDialog( + initialRegex = autoPlayPlayerSettings.streamAutoPlayRegex, + onSave = { + PlayerSettingsRepository.setStreamAutoPlayRegex(it) + showAutoPlayRegexDialog = false + }, + onDismiss = { showAutoPlayRegexDialog = false }, + ) + } } private fun formatReuseCacheDuration(hours: Int): String = when { @@ -591,3 +817,505 @@ private fun DecoderPriorityDialog( } } } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun StreamAutoPlayModeDialog( + selectedMode: StreamAutoPlayMode, + onModeSelected: (StreamAutoPlayMode) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf( + Triple(StreamAutoPlayMode.MANUAL, "Manual", "Select streams manually each time."), + Triple(StreamAutoPlayMode.FIRST_STREAM, "First Available Stream", "Automatically play the first stream found."), + Triple(StreamAutoPlayMode.REGEX_MATCH, "Regex Match", "Auto-select a stream matching a regex pattern."), + ) + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Stream Selection Mode", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { (mode, title, description) -> + val isSelected = mode == selectedMode + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onModeSelected(mode) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Tap outside to close", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun StreamAutoPlaySourceDialog( + selectedSource: StreamAutoPlaySource, + onSourceSelected: (StreamAutoPlaySource) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf( + Triple(StreamAutoPlaySource.ALL_SOURCES, "All Sources", "Consider streams from both addons and plugins."), + Triple(StreamAutoPlaySource.INSTALLED_ADDONS_ONLY, "Installed Addons Only", "Only consider streams from installed addons."), + Triple(StreamAutoPlaySource.ENABLED_PLUGINS_ONLY, "Enabled Plugins Only", "Only consider streams from enabled plugins."), + ) + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Source Scope", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { (source, title, description) -> + val isSelected = source == selectedSource + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSourceSelected(source) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Tap outside to close", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun StreamAutoPlayProviderSelectionDialog( + title: String, + allLabel: String, + items: List, + selectedItems: Set, + onSelectionSaved: (Set) -> Unit, + onDismiss: () -> Unit, +) { + var selected by remember(selectedItems, items) { + mutableStateOf(selectedItems.intersect(items.toSet())) + } + + BasicAlertDialog( + onDismissRequest = { + onSelectionSaved(selected) + onDismiss() + }, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + val allContainerColor = if (selected.isEmpty()) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { selected = emptySet() }, + shape = RoundedCornerShape(12.dp), + color = allContainerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = allLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (selected.isEmpty()) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + + if (items.isEmpty()) { + Text( + text = "No items available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 340.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + count = items.size, + key = { items[it] }, + ) { index -> + val item = items[index] + val isSelected = item in selected + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + selected = if (isSelected) selected - item else selected + item + }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Tap outside to save & close", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun StreamAutoPlayRegexDialog( + initialRegex: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var regex by remember(initialRegex) { mutableStateOf(initialRegex) } + var regexError by remember { mutableStateOf(null) } + + val presets = remember { + listOf( + "Any 1080p+" to "(2160p|4k|1080p)", + "4K / Remux" to "(2160p|4k|remux)", + "1080p Standard" to "(1080p|full\\s*hd)", + "720p / Smaller" to "(720p|webrip|web-dl)", + "WEB Sources" to "(web[-\\s]?dl|webrip)", + "BluRay Quality" to "(bluray|b[dr]rip|remux)", + "HEVC / x265" to "(hevc|x265|h\\.265)", + "AVC / x264" to "(x264|h\\.264|avc)", + "HDR / Dolby Vision" to "(hdr|hdr10\\+?|dv|dolby\\s*vision)", + "Dolby Atmos / DTS" to "(atmos|truehd|dts[-\\s]?hd|dtsx?)", + "English" to "(\\beng\\b|english)", + "No CAM/TS" to "^(?!.*\\b(cam|hdcam|ts|telesync)\\b).*$", + "No REMUX/HDR" to "(?is)^(?!.*\\b(hdr|hdr10|dv|dolby|vision|hevc|remux|2160p)\\b).+$", + ) + } + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .padding(20.dp) + .heightIn(max = 520.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Regex Pattern", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Text( + text = "Matches against stream name, label, description, addon, and URL.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = "Presets", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items( + count = presets.size, + key = { presets[it].first }, + ) { index -> + val (label, pattern) = presets[index] + Surface( + modifier = Modifier.clickable { + regex = pattern + regexError = null + }, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + border = BorderStroke( + 1.dp, + if (regexError != null) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ), + ) { + BasicTextField( + value = regex, + onValueChange = { + regex = it + regexError = null + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + if (regex.isBlank()) { + Text( + text = "4K|2160p|Remux", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + }, + ) + } + + if (regexError != null) { + Text( + text = regexError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = { + regex = "" + regexError = null + }) { + Text("Clear") + } + TextButton(onClick = { + val value = regex.trim() + if (value.isNotEmpty()) { + val valid = runCatching { Regex(value, RegexOption.IGNORE_CASE) }.isSuccess + if (!valid) { + regexError = "Invalid regex pattern" + return@TextButton + } + } + onSave(value) + }) { + Text("Save") + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayModels.kt new file mode 100644 index 000000000..f27ad8b13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayModels.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.streams + +enum class StreamAutoPlayMode { + MANUAL, + FIRST_STREAM, + REGEX_MATCH, +} + +enum class StreamAutoPlaySource { + ALL_SOURCES, + INSTALLED_ADDONS_ONLY, + ENABLED_PLUGINS_ONLY, +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt new file mode 100644 index 000000000..445af267a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.features.player.PlayerSettingsUiState + +object StreamAutoPlayPolicy { + fun isEffectivelyEnabled(settings: PlayerSettingsUiState): Boolean { + if (settings.streamReuseLastLinkEnabled) return true + + return when (settings.streamAutoPlayMode) { + StreamAutoPlayMode.MANUAL -> false + StreamAutoPlayMode.FIRST_STREAM -> true + StreamAutoPlayMode.REGEX_MATCH -> isRegexSelectionConfigured(settings.streamAutoPlayRegex) + } + } + + fun isRegexSelectionConfigured(regexPattern: String): Boolean { + val pattern = regexPattern.trim() + if (pattern.isEmpty() || !pattern.any { it.isLetterOrDigit() }) return false + return runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.isSuccess + } +} 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 new file mode 100644 index 000000000..1c6652b59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -0,0 +1,78 @@ +package com.nuvio.app.features.streams + +object StreamAutoPlaySelector { + + fun selectAutoPlayStream( + streams: List, + mode: StreamAutoPlayMode, + regexPattern: String, + source: StreamAutoPlaySource, + installedAddonNames: Set, + selectedAddons: Set, + selectedPlugins: Set, + ): StreamItem? { + if (streams.isEmpty()) return null + + val sourceScopedStreams = when (source) { + StreamAutoPlaySource.ALL_SOURCES -> streams + StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> streams.filter { it.addonName in installedAddonNames } + StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> streams.filter { it.addonName !in installedAddonNames } + } + val candidateStreams = sourceScopedStreams.filter { stream -> + val isAddonStream = stream.addonName in installedAddonNames + if (isAddonStream) { + selectedAddons.isEmpty() || stream.addonName in selectedAddons + } else { + selectedPlugins.isEmpty() || stream.addonName in selectedPlugins + } + } + if (candidateStreams.isEmpty()) return null + if (mode == StreamAutoPlayMode.MANUAL) return null + + return when (mode) { + StreamAutoPlayMode.MANUAL -> null + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null } + StreamAutoPlayMode.REGEX_MATCH -> { + val pattern = regexPattern.trim() + + val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() + ?: return null + + val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern) + + val exclusionWords = exclusionMatches + .flatMap { match -> match.groupValues[1].split("|") } + .map { it.trim() } + .filter { it.isNotBlank() } + .toList() + + val excludeRegex = if (exclusionWords.isNotEmpty()) { + Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE) + } else null + + val matchingStreams = candidateStreams.filter { stream -> + val url = stream.directPlaybackUrl ?: return@filter false + + val searchableText = buildString { + append(stream.addonName).append(' ') + append(stream.name.orEmpty()).append(' ') + append(stream.streamLabel).append(' ') + append(stream.description.orEmpty()).append(' ') + append(url) + } + + if (!userRegex.containsMatchIn(searchableText)) return@filter false + + if (excludeRegex != null && excludeRegex.containsMatchIn(searchableText)) { + return@filter false + } + + true + } + + if (matchingStreams.isEmpty()) return null + matchingStreams.firstOrNull { it.directPlaybackUrl != null } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 43fd6601f..8933ae874 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -59,6 +59,9 @@ data class StreamsUiState( val selectedFilter: String? = null, val isAnyLoading: Boolean = false, val emptyStateReason: StreamsEmptyStateReason? = null, + val autoPlayStream: StreamItem? = null, + val isDirectAutoPlayFlow: Boolean = false, + val showDirectAutoPlayOverlay: Boolean = false, ) { val filteredGroups: List get() = if (selectedFilter == null) groups diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index d278f673f..d40d13ac3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.PluginRepositoryItem import com.nuvio.app.features.plugins.PluginRuntimeResult @@ -13,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -54,6 +56,21 @@ object StreamsRepository { activeJob?.cancel() _uiState.value = StreamsUiState() + PlayerSettingsRepository.ensureLoaded() + val playerSettings = PlayerSettingsRepository.uiState.value + val autoPlayMode = playerSettings.streamAutoPlayMode + val isAutoPlayEnabled = autoPlayMode != StreamAutoPlayMode.MANUAL && + !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && + !StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)) + val isDirectAutoPlayFlow = isAutoPlayEnabled + + if (isDirectAutoPlayFlow) { + _uiState.value = StreamsUiState( + isDirectAutoPlayFlow = true, + showDirectAutoPlayOverlay = true, + ) + } + val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId) if (embeddedStreams.isNotEmpty()) { log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" } @@ -128,6 +145,8 @@ object StreamsRepository { activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = true, emptyStateReason = null, + isDirectAutoPlayFlow = isDirectAutoPlayFlow, + showDirectAutoPlayOverlay = isDirectAutoPlayFlow, ) activeJob = scope.launch { @@ -138,6 +157,53 @@ object StreamsRepository { val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + pluginRemainingByAddonId.values.sum() + val installedAddonNames = installedAddons + .mapNotNull { it.manifest?.name } + .toSet() + var autoSelectTriggered = false + var timeoutElapsed = false + + val timeoutJob = if (isAutoPlayEnabled) { + val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L + if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) { + launch { + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + autoSelectTriggered = true + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + ) + _uiState.update { it.copy(autoPlayStream = selected) } + if (selected == null) { + _uiState.update { + it.copy( + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + } + } + } + } else if (timeoutMs <= 0L) { + timeoutElapsed = true + null + } else { + null + } + } else { + null + } + streamAddons.forEach { manifest -> launch { val encodedId = videoId.encodeForPath() @@ -276,6 +342,30 @@ object StreamsRepository { } completions.close() + + if (isAutoPlayEnabled && !autoSelectTriggered) { + autoSelectTriggered = true + val allStreams = _uiState.value.groups.flatMap { it.streams } + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + ) + _uiState.update { it.copy(autoPlayStream = selected) } + } + if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) { + _uiState.update { + it.copy( + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + timeoutJob?.cancel() } } @@ -283,6 +373,16 @@ object StreamsRepository { _uiState.update { it.copy(selectedFilter = addonId) } } + fun consumeAutoPlay() { + _uiState.update { + it.copy( + autoPlayStream = null, + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + fun clear() { activeJob?.cancel() activeRequestKey = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 8370fb7fd..4896fc5b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -236,6 +236,45 @@ fun StreamsScreen( ) } } + + AnimatedVisibility( + visible = uiState.showDirectAutoPlayOverlay, + enter = fadeIn(animationSpec = tween(250)), + exit = fadeOut(animationSpec = tween(200)), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.85f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!logo.isNullOrBlank()) { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .height(48.dp), + contentScale = ContentScale.Fit, + ) + } + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White, + strokeWidth = 2.5.dp, + ) + Text( + text = "Finding source...", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f), + ) + } + } + } } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index b04b9c0d6..0ee55c3d8 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -18,6 +18,12 @@ actual object PlayerSettingsStorage { private const val decoderPriorityKey = "decoder_priority" private const val mapDV7ToHevcKey = "map_dv7_to_hevc" private const val tunnelingEnabledKey = "tunneling_enabled" + private const val streamAutoPlayModeKey = "stream_auto_play_mode" + private const val streamAutoPlaySourceKey = "stream_auto_play_source" + private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" + private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" + private const val streamAutoPlayRegexKey = "stream_auto_play_regex" + private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" actual fun loadShowLoadingOverlay(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults @@ -206,4 +212,72 @@ actual object PlayerSettingsStorage { actual fun saveTunnelingEnabled(enabled: Boolean) { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(tunnelingEnabledKey)) } + + actual fun loadStreamAutoPlayMode(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayModeKey) + return defaults.stringForKey(key) + } + + actual fun saveStreamAutoPlayMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(streamAutoPlayModeKey)) + } + + actual fun loadStreamAutoPlaySource(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlaySourceKey) + return defaults.stringForKey(key) + } + + actual fun saveStreamAutoPlaySource(source: String) { + NSUserDefaults.standardUserDefaults.setObject(source, forKey = ProfileScopedKey.of(streamAutoPlaySourceKey)) + } + + @Suppress("UNCHECKED_CAST") + actual fun loadStreamAutoPlaySelectedAddons(): Set? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey) + val array = defaults.arrayForKey(key) as? List ?: return null + return array.toSet() + } + + actual fun saveStreamAutoPlaySelectedAddons(addons: Set) { + NSUserDefaults.standardUserDefaults.setObject(addons.toList(), forKey = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey)) + } + + @Suppress("UNCHECKED_CAST") + actual fun loadStreamAutoPlaySelectedPlugins(): Set? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey) + val array = defaults.arrayForKey(key) as? List ?: return null + return array.toSet() + } + + actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) { + NSUserDefaults.standardUserDefaults.setObject(plugins.toList(), forKey = ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey)) + } + + actual fun loadStreamAutoPlayRegex(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayRegexKey) + return defaults.stringForKey(key) + } + + actual fun saveStreamAutoPlayRegex(regex: String) { + NSUserDefaults.standardUserDefaults.setObject(regex, forKey = ProfileScopedKey.of(streamAutoPlayRegexKey)) + } + + actual fun loadStreamAutoPlayTimeoutSeconds(): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey) + return if (defaults.objectForKey(key) != null) { + defaults.integerForKey(key).toInt() + } else { + null + } + } + + actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) { + NSUserDefaults.standardUserDefaults.setInteger(seconds.toLong(), forKey = ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey)) + } }