mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-14 19:40:23 +00:00
feat: adding autoplay feature
This commit is contained in:
parent
a7ca87394f
commit
5ecb5b8131
12 changed files with 1291 additions and 0 deletions
|
|
@ -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<String>? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getStringSet(key, emptySet()) ?: emptySet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamAutoPlaySelectedAddons(addons: Set<String>) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putStringSet(ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey), addons)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamAutoPlaySelectedPlugins(): Set<String>? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getStringSet(key, emptySet()) ?: emptySet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set<String>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StreamRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasResolvedVideoId) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -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<String> = emptySet(),
|
||||
val streamAutoPlaySelectedPlugins: Set<String> = 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<String> = emptySet()
|
||||
private var streamAutoPlaySelectedPlugins: Set<String> = 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<String>) {
|
||||
ensureLoaded()
|
||||
if (streamAutoPlaySelectedAddons == addons) return
|
||||
streamAutoPlaySelectedAddons = addons
|
||||
publish()
|
||||
PlayerSettingsStorage.saveStreamAutoPlaySelectedAddons(addons)
|
||||
}
|
||||
|
||||
fun setStreamAutoPlaySelectedPlugins(plugins: Set<String>) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>?
|
||||
fun saveStreamAutoPlaySelectedAddons(addons: Set<String>)
|
||||
fun loadStreamAutoPlaySelectedPlugins(): Set<String>?
|
||||
fun saveStreamAutoPlaySelectedPlugins(plugins: Set<String>)
|
||||
fun loadStreamAutoPlayRegex(): String?
|
||||
fun saveStreamAutoPlayRegex(regex: String)
|
||||
fun loadStreamAutoPlayTimeoutSeconds(): Int?
|
||||
fun saveStreamAutoPlayTimeoutSeconds(seconds: Int)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
selectedItems: Set<String>,
|
||||
onSelectionSaved: (Set<String>) -> 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<String?>(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.nuvio.app.features.streams
|
||||
|
||||
object StreamAutoPlaySelector {
|
||||
|
||||
fun selectAutoPlayStream(
|
||||
streams: List<StreamItem>,
|
||||
mode: StreamAutoPlayMode,
|
||||
regexPattern: String,
|
||||
source: StreamAutoPlaySource,
|
||||
installedAddonNames: Set<String>,
|
||||
selectedAddons: Set<String>,
|
||||
selectedPlugins: Set<String>,
|
||||
): 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AddonStreamGroup>
|
||||
get() = if (selectedFilter == null) groups
|
||||
|
|
|
|||
|
|
@ -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<String, String>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey)
|
||||
val array = defaults.arrayForKey(key) as? List<String> ?: return null
|
||||
return array.toSet()
|
||||
}
|
||||
|
||||
actual fun saveStreamAutoPlaySelectedAddons(addons: Set<String>) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(addons.toList(), forKey = ProfileScopedKey.of(streamAutoPlaySelectedAddonsKey))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
actual fun loadStreamAutoPlaySelectedPlugins(): Set<String>? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamAutoPlaySelectedPluginsKey)
|
||||
val array = defaults.arrayForKey(key) as? List<String> ?: return null
|
||||
return array.toSet()
|
||||
}
|
||||
|
||||
actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set<String>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue