Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,37 @@ fun DetailsScreen(

val validStreams = uiState.streams.filter(::isAutoPlayableStream)
val minThreshold = minQualityThreshold(uiState.autoPlayMinQuality)
val singleStream = validStreams.singleOrNull()
val regexPattern = uiState.autoPlayRegex.trim()

// If a regex is configured, attempt to auto-select the first matching stream
if (regexPattern.isNotBlank()) {
val userRegex = runCatching { Regex(regexPattern, RegexOption.IGNORE_CASE) }.getOrNull()
if (userRegex != null) {
val regexMatch = validStreams.firstOrNull { stream ->
matchesAutoPlayRegex(stream, userRegex)
}
if (regexMatch != null) {
onNavigateToPlayer(
mediaType, mediaId,
request.season, request.episode,
uiState.imdbId,
regexMatch.url?.takeIf { it.isNotBlank() },
regexMatch.addonId.takeIf { it.isNotBlank() },
regexMatch.source.takeIf { it.isNotBlank() },
request.startPositionMs
)
pendingAutoPlayRequest = null
return@LaunchedEffect
}
}
// Regex set but no match — fall through to show the stream selector
showStreamSelector = true
pendingAutoPlayRequest = null
return@LaunchedEffect
}

// Standard quality-threshold auto-play (no regex)
val singleStream = validStreams.singleOrNull()
when {
singleStream != null && qualityScoreForAutoPlay(singleStream.quality) >= minThreshold -> {
onNavigateToPlayer(
Expand Down Expand Up @@ -855,6 +884,24 @@ private fun isAutoPlayableStream(stream: com.arflix.tv.data.model.StreamSource):
return !isPendingDebridStream(stream)
}

/**
* Build a searchable text blob for a stream, matching streams against addon name, title,
* description and URL — the same approach used by NuvioTV's StreamAutoPlaySelector.
*/
private fun matchesAutoPlayRegex(
stream: com.arflix.tv.data.model.StreamSource,
pattern: Regex
): Boolean {
val url = stream.url?.trim() ?: return false
val searchable = buildString {
append(stream.addonName.orEmpty()).append(' ')
append(stream.quality.orEmpty()).append(' ')
append(stream.source.orEmpty()).append(' ')
append(url)
}
return pattern.containsMatchIn(searchable)
}

private fun isPendingDebridStream(stream: com.arflix.tv.data.model.StreamSource): Boolean {
val text = listOfNotNull(stream.source, stream.addonName, stream.quality, stream.url)
.joinToString(" ")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.arflix.tv.ui.screens.details

import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arflix.tv.data.model.CastMember
Expand Down Expand Up @@ -83,7 +84,8 @@ data class DetailsUiState(
val playLabel: String? = null,
val playPositionMs: Long? = null,
val autoPlaySingleSource: Boolean = true,
val autoPlayMinQuality: String = "Any"
val autoPlayMinQuality: String = "Any",
val autoPlayRegex: String = ""
)

data class StreamingServiceUi(
Expand Down Expand Up @@ -178,6 +180,7 @@ class DetailsViewModel @Inject constructor(
@Volatile private var initialLoadComplete = false
private fun autoPlaySingleSourceKey() = profileManager.profileBooleanKey("auto_play_single_source")
private fun autoPlayMinQualityKey() = profileManager.profileStringKey("auto_play_min_quality")
private val autoPlayRegexKey = stringPreferencesKey("device_autoplay_regex")

private fun isBlankRating(value: String): Boolean {
return value.isBlank() || value == "0.0" || value == "0"
Expand Down Expand Up @@ -229,6 +232,7 @@ class DetailsViewModel @Inject constructor(
val prefs = context.settingsDataStore.data.first()
val autoPlaySingleSource = prefs[autoPlaySingleSourceKey()] ?: true
val autoPlayMinQuality = normalizeAutoPlayMinQuality(prefs[autoPlayMinQualityKey()])
val autoPlayRegex = prefs[autoPlayRegexKey] ?: ""

val previousState = _uiState.value
val previousMatches = previousState.item?.id == mediaId &&
Expand Down Expand Up @@ -259,7 +263,8 @@ class DetailsViewModel @Inject constructor(
null
},
autoPlaySingleSource = autoPlaySingleSource,
autoPlayMinQuality = autoPlayMinQuality
autoPlayMinQuality = autoPlayMinQuality,
autoPlayRegex = autoPlayRegex
)

val itemDeferred = async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Icon
import com.arflix.tv.ui.components.LoadingIndicator
import com.arflix.tv.ui.components.QrCodeImage
Expand Down Expand Up @@ -178,6 +179,8 @@ fun SettingsScreen(
var dnsProviderPickerIndex by remember { mutableIntStateOf(0) }
var showContentLanguagePicker by remember { mutableStateOf(false) }
var contentLanguagePickerIndex by remember { mutableIntStateOf(0) }
var showAutoPlayRegexPicker by remember { mutableStateOf(false) }
var autoPlayRegexPickerIndex by remember { mutableIntStateOf(0) }

val sections = remember { listOf("general", "iptv", "catalogs", "addons", "accounts") }

Expand Down Expand Up @@ -207,6 +210,10 @@ fun SettingsScreen(
contentLanguagePickerIndex = TMDB_LANGUAGES.indexOfFirst { it.first == uiState.contentLanguage }.coerceAtLeast(0)
showContentLanguagePicker = true
}
val openAutoPlayRegexPicker = {
autoPlayRegexPickerIndex = autoPlayRegexOptions.indexOfFirst { it.second == uiState.autoPlayRegex }.coerceAtLeast(0)
showAutoPlayRegexPicker = true
}

LaunchedEffect(Unit) {
focusRequester.requestFocus()
Expand Down Expand Up @@ -239,6 +246,14 @@ fun SettingsScreen(
dnsProviderPickerIndex = if (targetIndex >= 0) targetIndex else dnsProviderPickerIndex.coerceIn(0, maxIndex)
}
}

LaunchedEffect(showAutoPlayRegexPicker, uiState.autoPlayRegex) {
if (showAutoPlayRegexPicker) {
val maxIndex = (autoPlayRegexOptions.size - 1).coerceAtLeast(0)
val targetIndex = autoPlayRegexOptions.indexOfFirst { it.second == uiState.autoPlayRegex }
autoPlayRegexPickerIndex = if (targetIndex >= 0) targetIndex else autoPlayRegexPickerIndex.coerceIn(0, maxIndex)
}
}

// Reset content scroll when switching sections.
LaunchedEffect(sectionIndex) {
Expand Down Expand Up @@ -611,6 +626,8 @@ fun SettingsScreen(
autoPlayNext = uiState.autoPlayNext,
autoPlaySingleSource = uiState.autoPlaySingleSource,
autoPlayMinQuality = uiState.autoPlayMinQuality,
autoPlayRegex = uiState.autoPlayRegex,
autoPlayRegexOptions = autoPlayRegexOptions,
subtitleSize = uiState.subtitleSize,
subtitleColor = uiState.subtitleColor,
deviceModeOverride = uiState.deviceModeOverride,
Expand All @@ -624,6 +641,7 @@ fun SettingsScreen(
onAutoPlayToggle = { viewModel.setAutoPlayNext(it) },
onAutoPlaySingleSourceToggle = { viewModel.setAutoPlaySingleSource(it) },
onAutoPlayMinQualityClick = { viewModel.cycleAutoPlayMinQuality() },
onAutoPlayRegexClick = openAutoPlayRegexPicker,
trailerAutoPlay = uiState.trailerAutoPlay,
onTrailerAutoPlayToggle = { viewModel.setTrailerAutoPlay(it) },
onDeviceModeClick = {
Expand Down Expand Up @@ -782,6 +800,8 @@ fun SettingsScreen(
autoPlayNext = uiState.autoPlayNext,
autoPlaySingleSource = uiState.autoPlaySingleSource,
autoPlayMinQuality = uiState.autoPlayMinQuality,
autoPlayRegex = uiState.autoPlayRegex,
autoPlayRegexOptions = autoPlayRegexOptions,
contentLanguage = uiState.contentLanguage,
subtitleSize = uiState.subtitleSize,
subtitleColor = uiState.subtitleColor,
Expand All @@ -796,6 +816,7 @@ fun SettingsScreen(
onAutoPlayToggle = { viewModel.setAutoPlayNext(it) },
onAutoPlaySingleSourceToggle = { viewModel.setAutoPlaySingleSource(it) },
onAutoPlayMinQualityClick = { viewModel.cycleAutoPlayMinQuality() },
onAutoPlayRegexClick = openAutoPlayRegexPicker,
trailerAutoPlay = uiState.trailerAutoPlay,
onTrailerAutoPlayToggle = { viewModel.setTrailerAutoPlay(it) },
onDeviceModeClick = {
Expand Down Expand Up @@ -1071,6 +1092,22 @@ fun SettingsScreen(
)
}

if (showAutoPlayRegexPicker) {
SubtitlePickerModal(
title = "Auto-Play Match Mode",
options = autoPlayRegexOptions.map { it.first },
selected = autoPlayRegexOptions.firstOrNull { it.second == uiState.autoPlayRegex }?.first ?: "Disabled",
focusedIndex = autoPlayRegexPickerIndex,
onFocusChange = { autoPlayRegexPickerIndex = it },
onSelect = { displayName ->
val pattern = autoPlayRegexOptions.firstOrNull { it.first == displayName }?.second ?: ""
viewModel.setAutoPlayRegex(pattern)
showAutoPlayRegexPicker = false
},
onDismiss = { showAutoPlayRegexPicker = false }
)
}

if (uiState.showCloudEmailPasswordDialog) {
CloudEmailPasswordModal(
email = cloudDialogEmail,
Expand Down Expand Up @@ -2110,6 +2147,8 @@ private fun GeneralSettings(
autoPlayNext: Boolean,
autoPlaySingleSource: Boolean,
autoPlayMinQuality: String,
autoPlayRegex: String,
autoPlayRegexOptions: List<Pair<String, String>>,
subtitleSize: String = "Medium",
subtitleColor: String = "White",
deviceModeOverride: String = "auto",
Expand All @@ -2123,6 +2162,7 @@ private fun GeneralSettings(
onAutoPlayToggle: (Boolean) -> Unit,
onAutoPlaySingleSourceToggle: (Boolean) -> Unit,
onAutoPlayMinQualityClick: () -> Unit,
onAutoPlayRegexClick: () -> Unit,
onDeviceModeClick: () -> Unit = {},
onContentLanguageClick: () -> Unit = {},
onSkipProfileSelectionToggle: (Boolean) -> Unit = {},
Expand Down Expand Up @@ -2219,6 +2259,15 @@ private fun GeneralSettings(
onClick = onAutoPlayMinQualityClick
)
Spacer(modifier = Modifier.height(10.dp))
SettingsRow(
icon = Icons.Default.FilterList,
title = "Auto-Play Match Mode",
subtitle = if (autoPlayRegex.isBlank()) "None" else autoPlayRegexOptions.firstOrNull { it.second == autoPlayRegex }?.first ?: "Custom",
value = if (autoPlayRegex.isBlank()) "Settings" else "Enabled",
isFocused = focusedIndex == 8,
onClick = onAutoPlayRegexClick
)
Spacer(modifier = Modifier.height(10.dp))
SettingsToggleRow(
title = "Trailer Auto-Play",
subtitle = "Play trailers in hero banner",
Expand Down Expand Up @@ -4175,6 +4224,23 @@ private fun SubtitlePickerModal(
}
}

private val autoPlayRegexOptions = listOf(
"Disabled" to "",
"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).+$"
)

/** All TMDB-supported languages as (code, displayName) pairs. */
val TMDB_LANGUAGES = listOf(
"en-US" to "English",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import coil.Coil
import com.arflix.tv.BuildConfig
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -71,6 +72,7 @@ data class SettingsUiState(
val autoPlayNext: Boolean = true,
val autoPlaySingleSource: Boolean = true,
val autoPlayMinQuality: String = "Any",
val autoPlayRegex: String = "",
val dnsProvider: String = "System DNS",
val dnsProviderOptions: List<String> = listOf("System DNS", "Cloudflare", "Google", "AdGuard"),
val subtitleSize: String = "Medium",
Expand Down Expand Up @@ -176,6 +178,7 @@ class SettingsViewModel @Inject constructor(
private fun autoPlaySingleSourceKeyFor(profileId: String) = profileManager.profileBooleanKeyFor(profileId, "auto_play_single_source")
private fun autoPlayMinQualityKey() = profileManager.profileStringKey("auto_play_min_quality")
private fun autoPlayMinQualityKeyFor(profileId: String) = profileManager.profileStringKeyFor(profileId, "auto_play_min_quality")
private val autoPlayRegexKey = stringPreferencesKey("device_autoplay_regex")
private fun trailerAutoPlayKey() = profileManager.profileBooleanKey("trailer_auto_play")

private fun subtitleSizeKey() = profileManager.profileStringKey("subtitle_size")
Expand Down Expand Up @@ -263,6 +266,7 @@ class SettingsViewModel @Inject constructor(
context.settingsDataStore.edit { it[autoPlayNextKey()] = true }
}
val autoPlayMinQuality = normalizeAutoPlayMinQuality(prefs[autoPlayMinQualityKey()])
val autoPlayRegex = prefs[autoPlayRegexKey] ?: ""
val trailerAutoPlay = prefs[trailerAutoPlayKey()] ?: false

val subtitleSize = prefs[subtitleSizeKey()] ?: "Medium"
Expand Down Expand Up @@ -302,6 +306,7 @@ class SettingsViewModel @Inject constructor(
autoPlayNext = autoPlay,
autoPlaySingleSource = autoPlaySingleSource,
autoPlayMinQuality = autoPlayMinQuality,
autoPlayRegex = autoPlayRegex,
trailerAutoPlay = trailerAutoPlay,

subtitleSize = subtitleSize,
Expand Down Expand Up @@ -783,6 +788,13 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { context.settingsDataStore.edit { it[trailerAutoPlayKey()] = enabled }; _uiState.value = _uiState.value.copy(trailerAutoPlay = enabled); syncLocalStateToCloud(silent = true) }
}

fun setAutoPlayRegex(regex: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(autoPlayRegex = regex)
context.settingsDataStore.edit { it[autoPlayRegexKey] = regex }
}
}

fun cycleSubtitleSize() {
val next = when (_uiState.value.subtitleSize) { "Small" -> "Medium"; "Medium" -> "Large"; "Large" -> "Extra Large"; else -> "Small" }
viewModelScope.launch { context.settingsDataStore.edit { it[subtitleSizeKey()] = next }; _uiState.value = _uiState.value.copy(subtitleSize = next); syncLocalStateToCloud(silent = true) }
Expand Down
Binary file added clean_out.txt
Binary file not shown.
Binary file added tmp_gradle_out.txt
Binary file not shown.
Loading