From 259b8090161f16256d3015604e72be9cf7ddf364 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 2 Apr 2026 23:21:55 +0200 Subject: [PATCH] feat: add regex-based stream autoplay filtering (mirrored from NuvioTV) --- .vscode/settings.json | 3 + .../tv/ui/screens/details/DetailsScreen.kt | 49 ++++++++++++- .../tv/ui/screens/details/DetailsViewModel.kt | 9 ++- .../tv/ui/screens/settings/SettingsScreen.kt | 66 ++++++++++++++++++ .../ui/screens/settings/SettingsViewModel.kt | 12 ++++ clean_out.txt | Bin 0 -> 254 bytes tmp_gradle_out.txt | Bin 0 -> 2062 bytes 7 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 clean_out.txt create mode 100644 tmp_gradle_out.txt diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..c5f3f6b9c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index bed3da458..1a348134c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -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( @@ -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(" ") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 0d601f1b9..26a89b474 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -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 @@ -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( @@ -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" @@ -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 && @@ -259,7 +263,8 @@ class DetailsViewModel @Inject constructor( null }, autoPlaySingleSource = autoPlaySingleSource, - autoPlayMinQuality = autoPlayMinQuality + autoPlayMinQuality = autoPlayMinQuality, + autoPlayRegex = autoPlayRegex ) val itemDeferred = async { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 86b1b166c..6bc5ad581 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -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 @@ -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") } @@ -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() @@ -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) { @@ -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, @@ -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 = { @@ -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, @@ -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 = { @@ -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, @@ -2110,6 +2147,8 @@ private fun GeneralSettings( autoPlayNext: Boolean, autoPlaySingleSource: Boolean, autoPlayMinQuality: String, + autoPlayRegex: String, + autoPlayRegexOptions: List>, subtitleSize: String = "Medium", subtitleColor: String = "White", deviceModeOverride: String = "auto", @@ -2123,6 +2162,7 @@ private fun GeneralSettings( onAutoPlayToggle: (Boolean) -> Unit, onAutoPlaySingleSourceToggle: (Boolean) -> Unit, onAutoPlayMinQualityClick: () -> Unit, + onAutoPlayRegexClick: () -> Unit, onDeviceModeClick: () -> Unit = {}, onContentLanguageClick: () -> Unit = {}, onSkipProfileSelectionToggle: (Boolean) -> Unit = {}, @@ -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", @@ -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", diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 820cc2c46..0c53c04e5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -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 @@ -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 = listOf("System DNS", "Cloudflare", "Google", "AdGuard"), val subtitleSize: String = "Medium", @@ -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") @@ -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" @@ -302,6 +306,7 @@ class SettingsViewModel @Inject constructor( autoPlayNext = autoPlay, autoPlaySingleSource = autoPlaySingleSource, autoPlayMinQuality = autoPlayMinQuality, + autoPlayRegex = autoPlayRegex, trailerAutoPlay = trailerAutoPlay, subtitleSize = subtitleSize, @@ -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) } diff --git a/clean_out.txt b/clean_out.txt new file mode 100644 index 0000000000000000000000000000000000000000..1c2f362063aea451826b6c2947e98bc1de155880 GIT binary patch literal 254 zcma)%O$x$b3`E~r@DASvy3l18f{ibny=;xk(l*rfSgWc9UJC6^cP$!N6 literal 0 HcmV?d00001 diff --git a/tmp_gradle_out.txt b/tmp_gradle_out.txt new file mode 100644 index 0000000000000000000000000000000000000000..649383d214788930ac46d190b353de9028358bc0 GIT binary patch literal 2062 zcmcJQT~8BH5QgX4#Q(5Y;umY=vL+-zi-I306}eDDmUdezZEdy(YH#@I>hsRo<5DFg zT$oL}b9QIm`Fdye*U#L(*w88~tZ!p0ZDEHoCilSGnVqcoKe6v<1u%pCaR2hMVVm{<|1Yq52~x0i z+d*@RRQmraTJ({Fl6xkK^6HM4{NB~(%DrWH(7 z=v)80ux`)Z*b$L;JjZRkkMJ(+0VtAHg_y&FbAXeX<7d24j@R>w3=sBXw2qy>;`IV8 z@kf$!nbM<{S3wagclnqiOQ(;v`dE=koCh)Pj3+S;y;q-NhH5F6E#qOj7$iR%#5)JLF;Ms@|(IWMEPMqh|yYI*uJyYJ| zo^r~tSGH*;X72qPcs;b%oC_(hag7{x#GA@}M#iW{;<#$0sMqLhi#>hh(J6-tDV-~% zHl7#6Abf3dz1^ZRpQy}1Ez2&Qn>cc^5c^kSNUV~Lrsg9$%X&K0{X_4nCMQkbIvaUT zv<2CobJrI|a~LZq{DvU`e;L(PrJE^gtW(t!M#J!%{i^zLr#2f&@n{m$H|Mqpl%;lL zU{hT&B#<=BkA0hxn@8dSj|mV0}!-FBTME~YzSup)oM`VH!Zssg*MBWswEBx rNmhYpb2{;iE9vAQHec}EGa1(CvfG9^+No{O(thpuDe`s$MCttotFKAB literal 0 HcmV?d00001