feat: adding autoplay feature

This commit is contained in:
tapframe 2026-04-04 19:19:14 +05:30
parent a7ca87394f
commit 5ecb5b8131
12 changed files with 1291 additions and 0 deletions

View file

@ -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()
}
}

View file

@ -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(),

View file

@ -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,
)
}
}

View file

@ -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)
}

View file

@ -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")
}
}
}
}
}
}

View file

@ -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,
}

View file

@ -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
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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),
)
}
}
}
}
}

View file

@ -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))
}
}