From f752e358fc226f28b2a9e2832590eb878089d309 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sat, 6 Jun 2026 14:54:31 +0300 Subject: [PATCH 01/11] Fix TV stream selector UI, add left menu scroll and plugin prefixes --- app/proguard-rules.pro | 43 ++-- .../com/arflix/tv/core/plugin/PluginSafety.kt | 2 +- .../arflix/tv/ui/components/StreamSelector.kt | 203 ++++++++++++++---- .../tv/ui/screens/details/DetailsScreen.kt | 3 + .../tv/ui/screens/details/DetailsViewModel.kt | 90 +++++++- app/src/main/res/values-iw/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../arflix/tv/core/plugin/PluginManager.kt | 2 +- .../arflix/tv/core/plugin/PluginManager.kt | 5 +- .../plugin/cloudstream/ExternalRepoParser.kt | 8 +- 10 files changed, 289 insertions(+), 71 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 653bc1e73..9a9209d7f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,23 +18,23 @@ # Log stripping for release builds # Remove ALL logs for maximum performance # ============================================ --assumenosideeffects class android.util.Log { - public static int v(...); - public static int d(...); - public static int i(...); - public static int w(...); - public static int e(...); - public static int wtf(...); -} +# -assumenosideeffects class android.util.Log { +# public static int v(...); +# public static int d(...); +# public static int i(...); +# public static int w(...); +# public static int e(...); +# public static int wtf(...); +# } # Also strip ALL our custom AppLogger methods --assumenosideeffects class com.arflix.tv.util.AppLogger { - public static void v(...); - public static void d(...); - public static void i(...); - public static void w(...); - public static void e(...); -} +# -assumenosideeffects class com.arflix.tv.util.AppLogger { +# public static void v(...); +# public static void d(...); +# public static void i(...); +# public static void w(...); +# public static void e(...); +# } # Strip Kotlin debug assertions in release -assumenosideeffects class kotlin.jvm.internal.Intrinsics { @@ -120,6 +120,19 @@ -dontwarn javax.script.** -dontwarn org.mozilla.javascript.** +# CloudStream API & Dependencies (External plugins are compiled against these) +-keep class com.lagradost.** { *; } +-keep interface com.lagradost.** { *; } +-dontwarn com.lagradost.** + +-keep class org.jsoup.** { *; } +-keep interface org.jsoup.** { *; } +-dontwarn org.jsoup.** + +-keep class com.fasterxml.jackson.** { *; } +-keep interface com.fasterxml.jackson.** { *; } +-dontwarn com.fasterxml.jackson.** + # ============================================ # Hilt / Dagger - KEEP EVERYTHING # ============================================ diff --git a/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt b/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt index dde300d68..920a27dfc 100644 --- a/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt +++ b/app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt @@ -42,7 +42,7 @@ internal object PluginSafety { } // No path traversal allowed - if (filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + if (filename.contains("..")) { return false } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 7c248c718..c1835e149 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -40,6 +42,7 @@ import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Movie +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -108,6 +111,9 @@ fun StreamSelector( addonOrderedIds: List = emptyList(), completedAddons: Int = 0, totalAddons: Int = 0, + streamSearchStartTime: Long = 0L, + pluginScrapersLoading: Boolean = false, + loadingPluginNames: Set = emptySet(), onFocusedStream: (StreamSource) -> Unit = {}, onSelect: (StreamSource) -> Unit = {}, onClose: () -> Unit = {} @@ -116,10 +122,24 @@ fun StreamSelector( var focusedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) } var focusZone by remember { mutableStateOf("streams") } // "tabs" or "streams" + val listState = rememberTvLazyListState() + val mobileListState = rememberLazyListState() + val pluginPrefix = stringResource(R.string.plugin_prefix) val focusRequester = remember { FocusRequester() } val isMobile = LocalDeviceType.current.isTouchDevice() + var elapsedSeconds by remember { mutableIntStateOf(0) } + LaunchedEffect(streamSearchStartTime) { + if (streamSearchStartTime > 0L) { + elapsedSeconds = 0 + while (true) { + kotlinx.coroutines.delay(1000L) + elapsedSeconds = ((System.currentTimeMillis() - streamSearchStartTime) / 1000).toInt() + } + } + } + // Request focus when visible LaunchedEffect(isVisible) { if (isVisible) { @@ -143,11 +163,13 @@ fun StreamSelector( } val nameCounts = baseNameById.values.groupingBy { it }.eachCount() baseNameById.map { (id, baseName) -> + val isPlugin = id.startsWith("plugin_") + val baseNameWithPrefix = if (isPlugin) "$pluginPrefix$baseName" else baseName val label = if ((nameCounts[baseName] ?: 0) > 1) { val shortId = id.takeLast(4).uppercase() - "$baseName #$shortId" + "$baseNameWithPrefix #$shortId" } else { - baseName + baseNameWithPrefix } AddonTab(id, label) }.let { tabs -> @@ -164,7 +186,7 @@ fun StreamSelector( listOf("All sources") + addonTabs.map { it.label } } - val presentations = remember(streams) { streams.map(::presentSource) } + val presentations = remember(streams, pluginPrefix) { streams.map { presentSource(it, pluginPrefix) } } // Sort streams with richer heuristics: // cached/direct first, then resolution, then release type, then addon order, then size. @@ -218,7 +240,12 @@ fun StreamSelector( // Scroll to focused item LaunchedEffect(focusedIndex) { if (flatStreams.isNotEmpty() && focusedIndex < flatStreams.size) { - listState.animateScrollToItem(focusedIndex) + val target = kotlin.math.max(0, focusedIndex - 2) + if (isMobile) { + mobileListState.animateScrollToItem(target) + } else { + listState.animateScrollToItem(target) + } } } @@ -322,11 +349,13 @@ fun StreamSelector( .fillMaxHeight() .padding(24.dp) ) { + val leftScrollState = rememberScrollState() // Flat sheet section — no boxed card border, feels more modern/premium. Column( modifier = Modifier .fillMaxWidth() - .padding(12.dp) + .padding(top = 12.dp, start = 12.dp, end = 12.dp, bottom = 48.dp) + .verticalScroll(leftScrollState) ) { // Header without icon Column(modifier = Modifier.padding(bottom = 20.dp)) { @@ -350,11 +379,22 @@ fun StreamSelector( ) } - if (subtitle.isNotEmpty()) { + val statusText = buildString { + if (isLoading || pluginScrapersLoading) { + append("${elapsedSeconds}s • ") + if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) + else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) + else if (totalAddons > 0) append("Searching ($completedAddons/$totalAddons)...") + else append("Searching...") + } else { + append(subtitle) + } + } + if (statusText.isNotEmpty()) { Text( - text = subtitle, + text = statusText, style = ArflixTypography.caption.copy(fontSize = 13.sp), - color = TextSecondary.copy(alpha = 0.7f), + color = if (isLoading || pluginScrapersLoading) Pink else TextSecondary.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 20.dp) @@ -417,6 +457,19 @@ fun StreamSelector( } } } + if (leftScrollState.canScrollForward) { + androidx.compose.material3.Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "More sources", + tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + .size(24.dp) + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f), CircleShape) + .padding(4.dp) + ) + } } // Right Panel - Stream List @@ -438,7 +491,7 @@ fun StreamSelector( ) if (streams.isEmpty()) { - val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0) + val stillSearching = isLoading || pluginScrapersLoading || (completedAddons < totalAddons && totalAddons > 0) Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -453,8 +506,14 @@ fun StreamSelector( if (stillSearching) { LoadingIndicator(color = Pink, size = 48.dp) Spacer(modifier = Modifier.height(16.dp)) + val loadingText = buildString { + if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) + else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) + else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") + else append(stringResource(R.string.finding_sources)) + } Text( - text = if (totalAddons > 0) "Searching addons ($completedAddons/$totalAddons)..." else stringResource(R.string.finding_sources), + text = loadingText, style = ArflixTypography.body.copy( fontSize = 16.sp, fontWeight = FontWeight.Medium @@ -499,31 +558,45 @@ fun StreamSelector( } } } else { - TvLazyColumn( - state = listState, - contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.fillMaxSize().arvioDpadFocusGroup() - ) { - // Show flat list - no addon headers, sorted by Cached → Size → Quality - flatStreams.forEachIndexed { index, stream -> - item { - GlassyStreamCard( - presentation = presentSource(stream), - isFocused = index == focusedIndex, - isSelected = stream == selectedStream, - onClick = { onSelect(stream) } - ) + Box(modifier = Modifier.fillMaxSize()) { + TvLazyColumn( + state = listState, + contentPadding = PaddingValues(top = 4.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxSize().arvioDpadFocusGroup() + ) { + // Show flat list - no addon headers, sorted by Cached → Size → Quality + flatStreams.forEachIndexed { index, stream -> + item { + GlassyStreamCard( + presentation = presentSource(stream, pluginPrefix).let { it.copy(title = "${index + 1}. ${it.title}") }, + isFocused = index == focusedIndex, + isSelected = stream == selectedStream, + onClick = { onSelect(stream) } + ) + } } } + + if (listState.canScrollForward) { + androidx.compose.material3.Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "More sources", + tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + .size(24.dp) + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f), CircleShape) + .padding(4.dp) + ) + } } } } } } else { // Mobile single-column layout - val mobileListState = rememberLazyListState() - Column( modifier = Modifier .fillMaxSize() @@ -547,10 +620,21 @@ fun StreamSelector( maxLines = 1, overflow = TextOverflow.Ellipsis ) + val mobileStatusText = buildString { + if (isLoading || pluginScrapersLoading) { + append("${elapsedSeconds}s • ") + if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) + else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) + else if (totalAddons > 0) append("Searching ($completedAddons/$totalAddons)...") + else append("Searching...") + } else { + append("${streams.size} ${stringResource(R.string.sources_available)}") + } + } Text( - text = "${streams.size} ${stringResource(R.string.sources_available)}", + text = mobileStatusText, style = ArflixTypography.caption.copy(fontSize = 12.sp), - color = TextSecondary + color = if (isLoading || pluginScrapersLoading) Pink else TextSecondary ) } Box( @@ -614,7 +698,7 @@ fun StreamSelector( // Stream list or loading/empty states if (streams.isEmpty()) { - val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0) + val stillSearching = isLoading || pluginScrapersLoading || (completedAddons < totalAddons && totalAddons > 0) Box( modifier = Modifier .fillMaxSize() @@ -631,8 +715,14 @@ fun StreamSelector( if (stillSearching) { LoadingIndicator(color = Pink, size = 40.dp) Spacer(modifier = Modifier.height(12.dp)) + val loadingText = buildString { + if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) + else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) + else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") + else append(stringResource(R.string.finding_sources)) + } Text( - text = if (totalAddons > 0) "Searching addons ($completedAddons/$totalAddons)..." else stringResource(R.string.finding_sources), + text = loadingText, style = ArflixTypography.body.copy( fontSize = 14.sp, fontWeight = FontWeight.Medium @@ -677,20 +767,35 @@ fun StreamSelector( } } } else { - LazyColumn( - state = mobileListState, - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .fillMaxSize() - .weight(1f) - .arvioDpadFocusGroup() - ) { - items(flatStreams) { stream -> - MobileStreamCard( - presentation = presentSource(stream), - isSelected = stream == selectedStream, - onClick = { onSelect(stream) } + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + LazyColumn( + state = mobileListState, + contentPadding = PaddingValues(top = 8.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxSize() + .arvioDpadFocusGroup() + ) { + itemsIndexed(flatStreams) { index, stream -> + MobileStreamCard( + presentation = presentSource(stream, pluginPrefix).let { it.copy(title = "${index + 1}. ${it.title}") }, + isSelected = stream == selectedStream, + onClick = { onSelect(stream) } + ) + } + } + + if (mobileListState.canScrollForward) { + androidx.compose.material3.Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "More sources", + tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + .size(24.dp) + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f), CircleShape) + .padding(4.dp) ) } } @@ -760,9 +865,13 @@ private fun sourceTabId(stream: StreamSource): String { } } -private fun presentSource(stream: StreamSource): SourcePresentation { +private fun presentSource(stream: StreamSource, pluginPrefix: String): SourcePresentation { val title = stream.behaviorHints?.filename?.takeIf { it.isNotBlank() } ?: stream.source - val addonLabel = stream.addonName.split(" - ").firstOrNull()?.trim() ?: stream.addonName + + val isPlugin = stream.addonId.startsWith("plugin_") + val addonLabelRaw = stream.addonName.split(" - ").firstOrNull()?.trim() ?: stream.addonName + val addonLabel = if (isPlugin) "$pluginPrefix$addonLabelRaw" else addonLabelRaw + val searchBlob = buildString { append(stream.quality) append(' ') 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 33b02e9b8..96a074deb 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 @@ -950,6 +950,9 @@ fun DetailsScreen( addonOrderedIds = uiState.addonOrderedIds, completedAddons = uiState.completedAddons, totalAddons = uiState.totalAddons, + streamSearchStartTime = uiState.streamSearchStartTime, + pluginScrapersLoading = uiState.pluginScrapersLoading, + loadingPluginNames = uiState.loadingPluginNames, onFocusedStream = { stream -> viewModel.prewarmStreamsAround(stream, uiState.streams) }, 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 3cbe3cbb7..df9076805 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 @@ -37,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Locale +import com.arflix.tv.core.plugin.PluginManager +import com.arflix.tv.domain.model.LocalScraperResult import javax.inject.Inject data class DetailsUiState( @@ -62,6 +64,9 @@ data class DetailsUiState( val streams: List = emptyList(), val subtitles: List = emptyList(), val isLoadingStreams: Boolean = false, + val streamSearchStartTime: Long = 0L, + val pluginScrapersLoading: Boolean = false, + val loadingPluginNames: Set = emptySet(), val completedAddons: Int = 0, val totalAddons: Int = 0, val hasStreamingAddons: Boolean = true, @@ -171,6 +176,7 @@ private fun isSupplementalStream(stream: StreamSource): Boolean = class DetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val mediaRepository: MediaRepository, + private val pluginManager: PluginManager, private val profileManager: ProfileManager, private val traktRepository: TraktRepository, private val streamRepository: StreamRepository, @@ -1332,7 +1338,9 @@ class DetailsViewModel @Inject constructor( completedAddons = 0, totalAddons = 0, streams = emptyList(), - subtitles = emptyList() + subtitles = emptyList(), + streamSearchStartTime = System.currentTimeMillis(), + pluginScrapersLoading = false ) val requestId = ++loadStreamsRequestId val requestMediaType = currentMediaType @@ -1377,7 +1385,9 @@ class DetailsViewModel @Inject constructor( totalAddons = 0, streams = emptyList(), subtitles = emptyList(), - addonOrderedIds = orderedAddonIds + addonOrderedIds = orderedAddonIds, + streamSearchStartTime = System.currentTimeMillis(), + pluginScrapersLoading = false ) if (requestMediaType == MediaType.MOVIE) { @@ -1422,6 +1432,56 @@ class DetailsViewModel @Inject constructor( ) } + var pluginScraperJob: kotlinx.coroutines.Job? = null + pluginScraperJob = viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(pluginScrapersLoading = true) + val tmdbIdStr = requestMediaId.toString() + val pluginMediaType = if (requestMediaType == MediaType.MOVIE) "movie" else "tv" + pluginManager.executeScrapersStreaming( + tmdbId = tmdbIdStr, + mediaType = pluginMediaType, + season = if (requestMediaType != MediaType.MOVIE) (season ?: 1) else null, + episode = if (requestMediaType != MediaType.MOVIE) (episode ?: 1) else null + ).collect { pair -> + val scraperInfo = pair.first + val results: List? = pair.second + if (!isCurrentRequest()) return@collect + + val current = _uiState.value + if (results == null) { + // Plugin started loading + _uiState.value = current.copy( + loadingPluginNames = current.loadingPluginNames + scraperInfo.name + ) + } else { + // Plugin finished loading (with or without results) + val updatedNames = current.loadingPluginNames - scraperInfo.name + if (results.isNotEmpty()) { + val pluginStreams = results.map { it.toStreamSource() } + val merged = sortPlayableStreamsFirst( + (current.streams + pluginStreams) + .distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" } + ) + _uiState.value = current.copy( + streams = merged, + isLoadingStreams = false, + loadingPluginNames = updatedNames + ) + } else { + _uiState.value = current.copy( + loadingPluginNames = updatedNames + ) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "[PluginScrapers] streaming execution failed: ${e.message}") + } finally { + _uiState.value = _uiState.value.copy(pluginScrapersLoading = false, loadingPluginNames = emptySet()) + } + } + val result = if (currentMediaType == MediaType.MOVIE) { val enabledAddons = streamRepository.installedAddons.first() .filter { it.isEnabled && it.type != com.arflix.tv.data.model.AddonType.SUBTITLE } @@ -1480,7 +1540,7 @@ class DetailsViewModel @Inject constructor( homeServerAppendJob?.isActive == true || vodAppendJob?.isActive == true _uiState.value = _uiState.value.copy( isLoadingStreams = mergedStreams.isEmpty() && - (!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading), + (!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading || pluginScraperJob?.isActive == true), completedAddons = progressive.completedAddons, totalAddons = progressive.totalAddons, streams = mergedStreams, @@ -1536,7 +1596,7 @@ class DetailsViewModel @Inject constructor( homeServerAppendJob?.isActive == true || vodAppendJob?.isActive == true _uiState.value = _uiState.value.copy( isLoadingStreams = mergedStreams.isEmpty() && - (!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading), + (!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading || pluginScraperJob?.isActive == true), completedAddons = progressive.completedAddons, totalAddons = progressive.totalAddons, streams = mergedStreams, @@ -2510,4 +2570,26 @@ private object DetailsVMRegexes { pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b", option = RegexOption.IGNORE_CASE ) + } + +private fun LocalScraperResult.toStreamSource(): StreamSource = StreamSource( + source = title, + addonName = provider ?: name ?: "Plugin", + addonId = "plugin_${provider?.lowercase()?.replace(" ", "_") ?: "unknown"}", + quality = quality ?: "Unknown", + size = size ?: "", + sizeBytes = null, + url = url, + infoHash = infoHash, + fileIdx = null, + behaviorHints = headers?.let { hdrs -> + com.arflix.tv.data.model.StreamBehaviorHints( + notWebReady = false, + proxyHeaders = com.arflix.tv.data.model.ProxyHeaders(request = hdrs) + ) + }, + subtitles = emptyList(), + sources = emptyList(), + description = null +) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 8b3ca6309..467f6fece 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -223,4 +223,6 @@ מפתח API נשמר תרגום AI מוכן לשימוש סרוק עם הטלפון — בחר Groq או Gemini + Plugin: + פלאגינים: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64e1429fa..13ed9f615 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,4 +280,6 @@ This plugin may be risky. Cancel Enable + Plugin: + Plugins: %1$s diff --git a/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt b/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt index 98b99f9ed..b3fb0168e 100644 --- a/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt +++ b/app/src/play/kotlin/com/arflix/tv/core/plugin/PluginManager.kt @@ -64,7 +64,7 @@ class PluginManager @Inject constructor() { mediaType: String, season: Int? = null, episode: Int? = null - ): Flow>> = emptyFlow() + ): Flow?>> = emptyFlow() suspend fun executeScraper( scraper: ScraperInfo, diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt index 77a526fa6..43c402232 100644 --- a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginManager.kt @@ -682,7 +682,7 @@ class PluginManager @Inject constructor( mediaType: String, season: Int? = null, episode: Int? = null - ): Flow>> = channelFlow { + ): Flow?>> = channelFlow { val enabledList = enabledScrapers.first() .filter { it.supportsType(mediaType) } @@ -705,10 +705,11 @@ class PluginManager @Inject constructor( enabledList.forEach { scraper -> launch { try { + send(scraper to null) val results = executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode) send(scraper to results) } catch (e: Exception) { - Log.e(TAG, "Scraper ${scraper.name} failed in streaming: ${e.message}") + Log.w(TAG, "Scraper ${scraper.id} streaming failed: ${e.message}") send(scraper to emptyList()) } } diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt index c383cc75f..df84dd887 100644 --- a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/cloudstream/ExternalRepoParser.kt @@ -104,7 +104,13 @@ class ExternalRepoParser @Inject constructor( private suspend fun fetchPluginList(url: String): List? = withContext(Dispatchers.IO) { val body = fetchBody(url) ?: return@withContext null try { - pluginListAdapter.fromJson(body.trim()) + val list = pluginListAdapter.fromJson(body.trim()) ?: return@withContext null + list.map { entry -> + entry.copy( + url = resolveUrl(url, entry.url), + iconUrl = entry.iconUrl?.let { resolveUrl(url, it) } + ) + } } catch (e: Exception) { Log.e(TAG, "Failed to parse plugin list from $url: ${e.message}") null From adc400a06818fd6be754392cc3f7f44160c0bc79 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sat, 6 Jun 2026 17:32:21 +0300 Subject: [PATCH 02/11] Fix Hebrew RTL support, TMDB localization, and UI readability Details: - Force RTL layout direction globally when Hebrew is selected. - Keep PlayerScreen in LTR so video controls remain standard. - Add drop shadow to category titles and card texts to improve contrast on bright backgrounds. - Add OkHttp Interceptor to force TMDB API to use 'he' language when Hebrew is selected. - Map 'iw' to 'he' in MediaRepository for TMDB compatibility. --- .../main/kotlin/com/arflix/tv/MainActivity.kt | 2 +- .../tv/data/repository/MediaRepository.kt | 3 + .../main/kotlin/com/arflix/tv/di/AppModule.kt | 24 ++++- .../tv/ui/components/ContinueWatchingCard.kt | 32 ++++++- .../com/arflix/tv/ui/components/MediaCard.kt | 40 +++++++-- .../arflix/tv/ui/screens/home/HomeScreen.kt | 88 ++++++++++++------- .../tv/ui/screens/player/PlayerScreen.kt | 5 ++ 7 files changed, 151 insertions(+), 43 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index c1556d158..0489f76c7 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -312,7 +312,7 @@ class MainActivity : ComponentActivity() { } val isRtl = remember(appLanguage) { val lang = java.util.Locale.forLanguageTag(appLanguage.replace('_', '-')).language - lang in listOf("ar", "he", "fa", "ur") + lang in listOf("ar", "he", "iw", "fa", "ur") } CompositionLocalProvider( androidx.compose.ui.platform.LocalContext provides localizedContext, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index fe95431e1..cfdb35d90 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -100,6 +100,9 @@ class MediaRepository @Inject constructor( /** TMDB content language (e.g. "en-US", "fr-FR", "nl-NL"). Null = TMDB default (English). */ @Volatile var contentLanguage: String? = null + set(value) { + field = value?.replace("iw", "he")?.replace('_', '-') + } // === IN-MEMORY CACHE FOR PERFORMANCE === private data class CacheEntry(val data: T, val timestamp: Long) diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 109bb3b72..7bc4af8e1 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -33,10 +33,30 @@ object AppModule { @Provides @Singleton - fun provideTmdbApi(okHttpClient: OkHttpClient): TmdbApi { + fun provideTmdbApi(okHttpClient: OkHttpClient, @dagger.hilt.android.qualifiers.ApplicationContext context: android.content.Context): TmdbApi { + val tmdbClient = okHttpClient.newBuilder() + .addInterceptor { chain -> + val original = chain.request() + val originalHttpUrl = original.url + + val langPrefs = context.getSharedPreferences("app_locale", android.content.Context.MODE_PRIVATE) + val lang = langPrefs.getString("locale_tag", "en-US") ?: "en-US" + + // Only inject if it's not the default English. Map "iw" to "he". + val urlBuilder = originalHttpUrl.newBuilder() + if (lang != "en-US") { + val tmdbLang = lang.replace("iw", "he").replace('_', '-') + urlBuilder.setQueryParameter("language", tmdbLang) + } + + val requestBuilder = original.newBuilder().url(urlBuilder.build()) + chain.proceed(requestBuilder.build()) + } + .build() + return Retrofit.Builder() .baseUrl(Constants.TMDB_BASE_URL) - .client(okHttpClient) + .client(tmdbClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(TmdbApi::class.java) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt index fb6aa8065..4c45fb6d2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContinueWatchingCard.kt @@ -186,7 +186,13 @@ fun ContinueWatchingCard( Text( text = item.title, - style = ArvioSkin.typography.cardTitle, + style = ArvioSkin.typography.cardTitle.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 6f + ) + ), color = if (isFocused) ArvioSkin.colors.textPrimary else ArvioSkin.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -196,7 +202,13 @@ fun ContinueWatchingCard( if (meta != null) { Text( text = meta, - style = ArvioSkin.typography.caption, + style = ArvioSkin.typography.caption.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 4f + ) + ), color = ArvioSkin.colors.textMuted.copy(alpha = 0.85f), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -274,7 +286,13 @@ fun ContinueWatchingCardCompact( Column(modifier = Modifier.weight(1f)) { Text( text = item.title, - style = ArvioSkin.typography.cardTitle, + style = ArvioSkin.typography.cardTitle.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 6f + ) + ), color = ArvioSkin.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -282,7 +300,13 @@ fun ContinueWatchingCardCompact( if (episodeInfo != null) { Text( text = episodeInfo, - style = ArvioSkin.typography.caption, + style = ArvioSkin.typography.caption.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 4f + ) + ), color = ArvioSkin.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt index dc7190723..912eeac9a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt @@ -448,8 +448,20 @@ fun MediaCard( Text( text = item.title, - style = if (isMobile) ArvioSkin.typography.cardTitle.copy(fontSize = 14.sp) - else ArvioSkin.typography.cardTitle, + style = if (isMobile) ArvioSkin.typography.cardTitle.copy( + fontSize = 14.sp, + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 6f + ) + ) else ArvioSkin.typography.cardTitle.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 6f + ) + ), color = if (visualFocused) { ArvioSkin.colors.textPrimary } else { @@ -475,7 +487,13 @@ fun MediaCard( } Text( text = subtitle, - style = ArvioSkin.typography.caption, + style = ArvioSkin.typography.caption.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 4f + ) + ), color = ArvioSkin.colors.textMuted.copy(alpha = 0.85f), maxLines = subtitleMaxLines, overflow = TextOverflow.Ellipsis, @@ -621,7 +639,13 @@ fun PosterCard( Text( text = item.title, - style = ArvioSkin.typography.caption, + style = ArvioSkin.typography.caption.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 4f + ) + ), color = ArvioSkin.colors.textPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -630,7 +654,13 @@ fun PosterCard( if (item.year.isNotBlank()) { Text( text = item.year, - style = ArvioSkin.typography.caption, + style = ArvioSkin.typography.caption.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = androidx.compose.ui.graphics.Color.Black, + offset = androidx.compose.ui.geometry.Offset(2f, 2f), + blurRadius = 4f + ) + ), color = ArvioSkin.colors.textMuted.copy(alpha = 0.65f), ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index e95b9ca7a..df5b3873d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -285,6 +285,8 @@ private fun localizedCategoryTitle(category: Category): String = when (category. "collection_row_franchise" -> stringResource(R.string.franchises) "collection_row_network" -> stringResource(R.string.networks) "collection_row_featured" -> stringResource(R.string.featured) + "top10_movies_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסרטים הנצפים היום" else "Top 10 Movies Today" + "top10_shows_today" -> if (androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl) "10 הסדרות הנצפות היום" else "Top 10 Shows Today" else -> category.title } @@ -2160,6 +2162,7 @@ private fun HomeInputLayer( onOpenContextMenu: (MediaItem, Boolean) -> Unit, ) { val focusRequester = remember { FocusRequester() } + val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl var selectPressedInHome by remember { mutableStateOf(false) } var selectDownAtMs by remember { mutableLongStateOf(0L) } var rootHasFocus by remember { mutableStateOf(false) } @@ -2281,6 +2284,42 @@ private fun HomeInputLayer( ) { return@onPreviewKeyEvent true } + + val moveNext = { + if (focusState.isSidebarFocused) { + if (focusState.sidebarFocusIndex < maxSidebarIndex) { + focusState.sidebarFocusIndex++ + focusState.lastNavEventTime = SystemClock.elapsedRealtime() + } + true + } else { + val maxItems = categories.getOrNull(focusState.currentRowIndex)?.items?.size ?: 0 + if (focusState.currentItemIndex < maxItems - 1) { + focusState.currentItemIndex++ + focusState.lastNavEventTime = SystemClock.elapsedRealtime() + } + true + } + } + + val movePrev = { + if (!focusState.isSidebarFocused) { + if (focusState.currentItemIndex == 0) { + true + } else { + focusState.currentItemIndex-- + focusState.lastNavEventTime = SystemClock.elapsedRealtime() + true + } + } else { + if (focusState.sidebarFocusIndex > 0) { + focusState.sidebarFocusIndex-- + focusState.lastNavEventTime = SystemClock.elapsedRealtime() + } + true + } + } + when (event.type) { KeyEventType.KeyDown -> when (event.key) { Key.Enter, Key.DirectionCenter -> { @@ -2308,44 +2347,18 @@ private fun HomeInputLayer( } true } + Key.DirectionLeft -> { selectPressedInHome = false selectDownAtMs = 0L focusState.userHasNavigated = true - if (!focusState.isSidebarFocused) { - if (focusState.currentItemIndex == 0) { - true - } else { - focusState.currentItemIndex-- - focusState.lastNavEventTime = SystemClock.elapsedRealtime() - true - } - } else { - if (focusState.sidebarFocusIndex > 0) { - focusState.sidebarFocusIndex-- - focusState.lastNavEventTime = SystemClock.elapsedRealtime() - } - true - } + if (isRtl) moveNext() else movePrev() } Key.DirectionRight -> { selectPressedInHome = false selectDownAtMs = 0L focusState.userHasNavigated = true - if (focusState.isSidebarFocused) { - if (focusState.sidebarFocusIndex < maxSidebarIndex) { - focusState.sidebarFocusIndex++ - focusState.lastNavEventTime = SystemClock.elapsedRealtime() - } - true - } else { - val maxItems = categories.getOrNull(focusState.currentRowIndex)?.items?.size ?: 0 - if (focusState.currentItemIndex < maxItems - 1) { - focusState.currentItemIndex++ - focusState.lastNavEventTime = SystemClock.elapsedRealtime() - } - true - } + if (isRtl) movePrev() else moveNext() } Key.DirectionUp -> { selectPressedInHome = false @@ -2672,7 +2685,12 @@ private fun MobileHomeRowsLayer( text = localizedCategoryTitle(category), style = ArflixTypography.sectionTitle.copy( fontSize = 16.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = androidx.compose.ui.geometry.Offset(1f, 1f), + blurRadius = 4f + ) ), color = Color.White ) @@ -3349,7 +3367,15 @@ private fun ContentRow( ) { Text( text = localizedCategoryTitle(category), - style = ArflixTypography.sectionTitle.copy(fontSize = 18.sp, fontWeight = FontWeight.Bold), + style = ArflixTypography.sectionTitle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = androidx.compose.ui.geometry.Offset(1f, 1f), + blurRadius = 4f + ) + ), color = Color.White ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index e4747fe11..fa202b7d0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -2005,6 +2005,10 @@ fun PlayerScreen( } aspectIndicatorTrigger++ } + + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr + ) { Box( modifier = Modifier .fillMaxSize() @@ -3493,6 +3497,7 @@ fun PlayerScreen( } } } + } } @OptIn(ExperimentalTvMaterial3Api::class) From 1fea91495bfcb62f9ddfed341adde8870d8f42a5 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sat, 6 Jun 2026 17:41:24 +0300 Subject: [PATCH 03/11] Fix SettingsScreen D-Pad navigation for RTL Map physical Left/Right keys to logical keys based on LayoutDirection so navigation correctly moves left/right when RTL layout is active. --- .../tv/ui/screens/settings/SettingsScreen.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 52dc1faa4..a90ba7254 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 @@ -294,6 +294,7 @@ fun SettingsScreen( val isTouchDevice = LocalDeviceType.current.isTouchDevice() val context = LocalContext.current val scope = rememberCoroutineScope() + val isRtlLayoutDirection = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl // Auto-start cloud auth if requested (e.g. from profile selection page) LaunchedEffect(autoStartCloudAuth) { @@ -670,7 +671,18 @@ fun SettingsScreen( val focusedStremioAddonCanDelete = focusedStremioAddon?.let { addon -> !(addon.id == "opensubtitles" && addon.type == com.arflix.tv.data.model.AddonType.SUBTITLE) } ?: false - when (event.key) { + + val isRtl = isRtlLayoutDirection + val actualKey = event.key + val logicalKey = if (isRtl) { + when (actualKey) { + Key.DirectionLeft -> Key.DirectionRight + Key.DirectionRight -> Key.DirectionLeft + else -> actualKey + } + } else actualKey + + when (logicalKey) { Key.Back, Key.Escape -> { when (activeZone) { Zone.SIDEBAR -> onBack() From fbc4deb0d7ca6afe47cc2744d189268c75b3eddc Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sat, 6 Jun 2026 17:44:22 +0300 Subject: [PATCH 04/11] Fix DetailsScreen D-Pad navigation for RTL Map physical Left/Right keys to logical keys based on LayoutDirection so navigation between sidebar and content columns correctly moves left/right when RTL layout is active. --- .../arflix/tv/ui/screens/details/DetailsScreen.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 96a074deb..7ea3511e8 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 @@ -211,6 +211,7 @@ fun DetailsScreen( onSwitchProfile: () -> Unit = {}, onBack: () -> Unit ) { + val isRtlLayoutDirection = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl val uiState by viewModel.uiState.collectAsStateWithLifecycle() val usePosterCards = rememberCatalogueRowLayoutMode("details:similar") == CardLayoutMode.POSTER val context = LocalContext.current @@ -516,7 +517,17 @@ fun DetailsScreen( return@onPreviewKeyEvent false // Let the modal handle it } - when (event.key) { + val isRtl = isRtlLayoutDirection + val actualKey = event.key + val logicalKey = if (isRtl) { + when (actualKey) { + Key.DirectionLeft -> Key.DirectionRight + Key.DirectionRight -> Key.DirectionLeft + else -> actualKey + } + } else actualKey + + when (logicalKey) { Key.Back, Key.Escape -> { if (showTrailerPlayer) { showTrailerPlayer = false; true } else { onBack(); true } From 53cb1e695311c851b14b38886244b7f5ac291512 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sat, 6 Jun 2026 21:17:09 +0300 Subject: [PATCH 05/11] Fix StreamSelector D-Pad navigation for RTL Map physical Left/Right keys to logical keys based on LayoutDirection so navigation between tabs and streams correctly moves left/right when RTL layout is active. --- .../com/arflix/tv/ui/components/StreamSelector.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index c1835e149..e568383f4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -118,6 +118,7 @@ fun StreamSelector( onSelect: (StreamSource) -> Unit = {}, onClose: () -> Unit = {} ) { + val isRtlLayoutDirection = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl var focusedIndex by remember { mutableIntStateOf(0) } var focusedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) } @@ -279,7 +280,17 @@ fun StreamSelector( .background(Color.Black.copy(alpha = 0.95f)) .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { - when (event.key) { + val isRtl = isRtlLayoutDirection + val actualKey = event.key + val logicalKey = if (isRtl) { + when (actualKey) { + Key.DirectionLeft -> Key.DirectionRight + Key.DirectionRight -> Key.DirectionLeft + else -> actualKey + } + } else actualKey + + when (logicalKey) { Key.Back, Key.Escape -> { onClose() true From 9a5f562b8e3644158c19f50ea209979bc7099017 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sun, 7 Jun 2026 18:06:38 +0300 Subject: [PATCH 06/11] Fix compile error caused by missing StreamSelector parameters Added streamSearchStartTime, pluginScrapersLoading, and loadingPluginNames to StreamSelector signature to match DetailsScreen usage after merge. --- .../com/arflix/tv/ui/components/StreamSelector.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 963ebbcd3..51d55de27 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -117,6 +117,9 @@ fun StreamSelector( addonOrderedIds: List = emptyList(), completedAddons: Int = 0, totalAddons: Int = 0, + streamSearchStartTime: Long = 0L, + pluginScrapersLoading: Boolean = false, + loadingPluginNames: Set = emptySet(), onFocusedStream: (StreamSource) -> Unit = {}, onSelect: (StreamSource) -> Unit = {}, onClose: () -> Unit = {} @@ -131,6 +134,18 @@ fun StreamSelector( val listState = rememberTvLazyListState() val focusRequester = remember { FocusRequester() } val isMobile = LocalDeviceType.current.isTouchDevice() + val pluginPrefix = stringResource(R.string.plugin_prefix) + + var elapsedSeconds by remember { mutableIntStateOf(0) } + LaunchedEffect(streamSearchStartTime) { + if (streamSearchStartTime > 0L) { + elapsedSeconds = 0 + while (true) { + kotlinx.coroutines.delay(1000L) + elapsedSeconds = ((System.currentTimeMillis() - streamSearchStartTime) / 1000).toInt() + } + } + } // Request focus when visible LaunchedEffect(isVisible) { From 53b6efdf97ac28d16fe2b154f8ec16fda04d9b46 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Sun, 7 Jun 2026 21:03:17 +0300 Subject: [PATCH 07/11] Fix DetailsViewModel plugin job loading state, remove trailing whitespaces, and force LTR for HomeHeroLayer --- .../main/kotlin/com/arflix/tv/di/AppModule.kt | 6 +-- .../tv/ui/screens/details/DetailsViewModel.kt | 16 ++++++-- .../arflix/tv/ui/screens/home/HomeScreen.kt | 38 ++++++++++--------- .../tv/ui/screens/player/PlayerScreen.kt | 2 +- .../tv/ui/screens/settings/SettingsScreen.kt | 2 +- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 7bc4af8e1..0b0e7dfde 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -38,17 +38,17 @@ object AppModule { .addInterceptor { chain -> val original = chain.request() val originalHttpUrl = original.url - + val langPrefs = context.getSharedPreferences("app_locale", android.content.Context.MODE_PRIVATE) val lang = langPrefs.getString("locale_tag", "en-US") ?: "en-US" - + // Only inject if it's not the default English. Map "iw" to "he". val urlBuilder = originalHttpUrl.newBuilder() if (lang != "en-US") { val tmdbLang = lang.replace("iw", "he").replace('_', '-') urlBuilder.setQueryParameter("language", tmdbLang) } - + val requestBuilder = original.newBuilder().url(urlBuilder.build()) chain.proceed(requestBuilder.build()) } 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 df9076805..79b46c444 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 @@ -1447,7 +1447,7 @@ class DetailsViewModel @Inject constructor( val scraperInfo = pair.first val results: List? = pair.second if (!isCurrentRequest()) return@collect - + val current = _uiState.value if (results == null) { // Plugin started loading @@ -1457,7 +1457,7 @@ class DetailsViewModel @Inject constructor( } else { // Plugin finished loading (with or without results) val updatedNames = current.loadingPluginNames - scraperInfo.name - if (results.isNotEmpty()) { + if (!results.isNullOrEmpty()) { val pluginStreams = results.map { it.toStreamSource() } val merged = sortPlayableStreamsFirst( (current.streams + pluginStreams) @@ -1478,7 +1478,17 @@ class DetailsViewModel @Inject constructor( } catch (e: Exception) { Log.w(TAG, "[PluginScrapers] streaming execution failed: ${e.message}") } finally { - _uiState.value = _uiState.value.copy(pluginScrapersLoading = false, loadingPluginNames = emptySet()) + val current = _uiState.value + val stillLoading = loadStreamsJob?.isActive == true || + vodAppendJob?.isActive == true || + homeServerAppendJob?.isActive == true + val newLoading = current.isLoadingStreams && current.streams.isEmpty() && stillLoading + + _uiState.value = current.copy( + pluginScrapersLoading = false, + loadingPluginNames = emptySet(), + isLoadingStreams = newLoading + ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 97894c1de..839526fec 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -1714,24 +1714,28 @@ private fun HomeHeroLayer( val buttonsBottomPadding = contentRowTopPadding - 10.dp val heroBottomPadding = buttonsBottomPadding + if (configuration.screenHeightDp < 720) 34.dp else 34.dp - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = AppTopBarContentTopInset) - .zIndex(3f) + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr ) { - heroItem?.let { item -> - if (!item.status.orEmpty().startsWith("collection:")) { - HeroSection( - item = item, - logoUrl = heroLogoUrl, - overviewOverride = heroOverviewOverride, - showBudget = showBudget, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = contentStartPadding, end = 400.dp) - .offset(y = -heroBottomPadding) - ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = AppTopBarContentTopInset) + .zIndex(3f) + ) { + heroItem?.let { item -> + if (!item.status.orEmpty().startsWith("collection:")) { + HeroSection( + item = item, + logoUrl = heroLogoUrl, + overviewOverride = heroOverviewOverride, + showBudget = showBudget, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = contentStartPadding, end = 400.dp) + .offset(y = -heroBottomPadding) + ) + } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 0beb2c41e..f3202165c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -2006,7 +2006,7 @@ fun PlayerScreen( } aspectIndicatorTrigger++ } - + androidx.compose.runtime.CompositionLocalProvider( androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr ) { 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 034d49912..a58f29b77 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 @@ -681,7 +681,7 @@ fun SettingsScreen( val focusedStremioAddonCanDelete = focusedStremioAddon?.let { addon -> !(addon.id == "opensubtitles" && addon.type == com.arflix.tv.data.model.AddonType.SUBTITLE) } ?: false - + val isRtl = isRtlLayoutDirection val actualKey = event.key val logicalKey = if (isRtl) { From 7b9307c39691117ec288ea8528e3e6a35c70fc8c Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Thu, 11 Jun 2026 18:25:54 +0300 Subject: [PATCH 08/11] feat: Add elapsed time indicator for plugin scraper loading --- .../arflix/tv/ui/components/StreamSelector.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index f307cffc4..99bffbc80 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -402,6 +402,7 @@ fun StreamSelector( hasStreamingAddons = hasStreamingAddons, completedAddons = completedAddons, totalAddons = totalAddons, + elapsedSeconds = elapsedSeconds, pluginScrapersLoading = pluginScrapersLoading, loadingPluginNames = loadingPluginNames, onFilterSelected = { index -> @@ -529,6 +530,7 @@ fun StreamSelector( Spacer(modifier = Modifier.height(12.dp)) Text( text = buildString { + if (elapsedSeconds > 0) append("${elapsedSeconds}s \u2022 ") if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") @@ -627,6 +629,7 @@ private fun OledSourceSelectorTv( hasStreamingAddons: Boolean, completedAddons: Int, totalAddons: Int, + elapsedSeconds: Int = 0, pluginScrapersLoading: Boolean, loadingPluginNames: Set, onFilterSelected: (Int) -> Unit, @@ -671,7 +674,8 @@ private fun OledSourceSelectorTv( sourceCount = streams.size, completedAddons = completedAddons, totalAddons = totalAddons, - isLoading = isLoading + isLoading = isLoading, + elapsedSeconds = elapsedSeconds ), style = ArflixTypography.caption.copy(fontSize = 13.sp), color = OledMutedText, @@ -737,6 +741,7 @@ private fun OledSourceSelectorTv( completedAddons = completedAddons, totalAddons = totalAddons, hasStreamingAddons = hasStreamingAddons, + elapsedSeconds = elapsedSeconds, pluginScrapersLoading = pluginScrapersLoading, loadingPluginNames = loadingPluginNames ) @@ -957,13 +962,15 @@ private fun sourceStatusText( sourceCount: Int, completedAddons: Int, totalAddons: Int, - isLoading: Boolean + isLoading: Boolean, + elapsedSeconds: Int = 0 ): String { val remaining = (totalAddons - completedAddons).coerceAtLeast(0) + val elapsed = if (elapsedSeconds > 0 && isLoading) "${elapsedSeconds}s \u2022 " else "" return when { isLoading && totalAddons > 0 && remaining > 0 -> - "$sourceCount found - still checking $remaining ${if (remaining == 1) "addon" else "addons"}" - isLoading -> "$sourceCount found - searching sources" + "${elapsed}$sourceCount found - still checking $remaining ${if (remaining == 1) "addon" else "addons"}" + isLoading -> "${elapsed}$sourceCount found - searching sources" totalAddons > 0 -> "$sourceCount found - $completedAddons/$totalAddons addons checked" else -> "$sourceCount found" } @@ -1539,6 +1546,7 @@ private fun SourceEmptyState( completedAddons: Int, totalAddons: Int, hasStreamingAddons: Boolean, + elapsedSeconds: Int = 0, pluginScrapersLoading: Boolean = false, loadingPluginNames: Set = emptySet(), message: String? = null @@ -1560,6 +1568,7 @@ private fun SourceEmptyState( Spacer(modifier = Modifier.height(14.dp)) Text( text = buildString { + if (elapsedSeconds > 0) append("${elapsedSeconds}s \u2022 ") if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", "))) else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "...")) else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...") From deb5efc6515da2bf100cb920a928ba4935df7ffa Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Thu, 11 Jun 2026 17:50:02 +0300 Subject: [PATCH 09/11] fix: Restore FEATURE_PLUGINS_ENABLED, compileSdk=36, and sideload plugin dependencies --- app/build.gradle.kts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1801aa642..2c4eaf447 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ plugins { android { namespace = "com.arflix.tv" - compileSdk = 35 + compileSdk = 36 flavorDimensions += "distribution" @@ -54,10 +54,12 @@ android { create("play") { dimension = "distribution" buildConfigField("Boolean", "SELF_UPDATE_ENABLED", "false") + buildConfigField("Boolean", "FEATURE_PLUGINS_ENABLED", "false") } create("sideload") { dimension = "distribution" buildConfigField("Boolean", "SELF_UPDATE_ENABLED", "true") + buildConfigField("Boolean", "FEATURE_PLUGINS_ENABLED", "true") } } @@ -439,3 +441,31 @@ detekt { } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} + +dependencies { + ksp("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + annotationProcessor("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + + // Plugin system dependencies (Sideload flavor only) + add("sideloadImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) + add("sideloadImplementation", "com.fasterxml.jackson.core:jackson-databind:2.17.0") + add("sideloadImplementation", "com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") + add("sideloadImplementation", "com.github.Blatzar:NiceHttp:0.4.11") + add("sideloadImplementation", "org.conscrypt:conscrypt-android:2.5.3") + add("sideloadImplementation", "com.github.recloudstream.cloudstream:library:v4.7.0") { + exclude(group = "org.mozilla", module = "rhino") + } + add("sideloadImplementation", "org.webjars.npm:crypto-js:4.2.0") + + // Runtime helpers used by the sideload plugin extractor stack. + add("sideloadImplementation", "org.mozilla:rhino:1.8.1") + add("sideloadImplementation", "com.google.re2j:re2j:1.8") +} + + From c7eb661aa88a1159b8b264d159f97eb53a4f1087 Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Thu, 11 Jun 2026 18:21:37 +0300 Subject: [PATCH 10/11] fix: Resolve CI build failures and sideload Kotlin compilation --- app/build.gradle.kts | 2 +- config/detekt/baseline.xml | 8 + config/detekt/detekt.yml | 479 +++++++++++++++++++++++++++++++++++++ 3 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 config/detekt/baseline.xml create mode 100644 config/detekt/detekt.yml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c4eaf447..c971c4299 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -458,7 +458,7 @@ dependencies { add("sideloadImplementation", "com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") add("sideloadImplementation", "com.github.Blatzar:NiceHttp:0.4.11") add("sideloadImplementation", "org.conscrypt:conscrypt-android:2.5.3") - add("sideloadImplementation", "com.github.recloudstream.cloudstream:library:v4.7.0") { + add("sideloadImplementation", "com.github.recloudstream.cloudstream:library-android:v4.7.0") { exclude(group = "org.mozilla", module = "rhino") } add("sideloadImplementation", "org.webjars.npm:crypto-js:4.2.0") diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 000000000..3860195d5 --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 000000000..3d396fbf7 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,479 @@ +# Detekt Configuration for Arflix TV +# https://detekt.dev/docs/rules/ + +build: + maxIssues: 0 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false + +processors: + active: true + exclude: + - 'DetektProgressListener' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + +output-reports: + active: true + exclude: [] + +comments: + active: true + CommentOverPrivateProperty: + active: false + UndocumentedPublicClass: + active: false # Too strict for TV app + UndocumentedPublicFunction: + active: false # Too strict for TV app + UndocumentedPublicProperty: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 6 + ComplexMethod: + active: true + threshold: 20 # Relaxed for complex UI methods + ignoreSingleWhenExpression: true + ignoreSimpleWhenEntries: true + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 80 # Relaxed for Compose functions + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 10 + ignoreDefaultParameters: true + ignoreAnnotated: + - 'Composable' + NestedBlockDepth: + active: true + threshold: 5 + TooManyFunctions: + active: true + thresholdInFiles: 30 + thresholdInClasses: 25 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + thresholdInEnums: 10 + ignoreDeprecated: true + ignorePrivate: true + ignoreOverridden: true + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: true + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + InstanceOfCheckForException: + active: true + NotImplementedDeclaration: + active: true + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: true + allowedPattern: '^(is|has|are|can|should|was|will|do)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + FunctionMaxLength: + active: false + FunctionMinLength: + active: false + FunctionNaming: + active: true + functionPattern: '[a-z][a-zA-Z0-9]*' + ignoreAnnotated: + - 'Composable' # Compose functions often start with uppercase + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + InvalidPackageDeclaration: + active: true + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '[a-z][A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[a-z][A-Za-z0-9]*' + VariableMaxLength: + active: false + VariableMinLength: + active: false + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '_?[a-z][A-Za-z0-9]*' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: true + threshold: 3 + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + CastToNullableType: + active: true + Deprecation: + active: false # Too noisy with Android APIs + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false # Common in Android + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: true + NullableToStringCall: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + CanBeNonNullable: + active: true + CascadingCallWrapping: + active: false + ClassOrdering: + active: false + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: false + DataClassShouldBeImmutable: + active: true + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 5 + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + ForbiddenComment: + active: true + values: + - 'FIXME' + - 'STOPSHIP' + - 'TODO:' # Catch TODOs that should be tracked + allowedPatterns: 'TODO\\(.*\\)' # Allow TODO(name) format + ForbiddenImport: + active: false + ForbiddenMethodCall: + active: false + ForbiddenVoid: + active: true + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreAnnotated: + - 'Composable' + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 2 + MagicNumber: + active: false # Too strict for UI code + MandatoryBracesIfStatements: + active: false + MandatoryBracesLoops: + active: false + MaxLineLength: + active: true + maxLineLength: 140 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: true + NullableBooleanCheck: + active: true + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 4 + excludeLabeled: true + excludeReturnFromLambda: true + excludeGuardClauses: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: true + ThrowsCount: + active: true + max: 3 + TrailingWhitespace: + active: true + TrimMultilineRawString: + active: true + UnderscoresInNumericLiterals: + active: true + acceptableLength: 5 + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: true + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + UseEmptyCounterpart: + active: true + UseIfEmptyOrIfBlank: + active: true + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: true + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + - 'kotlinx.android.synthetic.*' From b2e412a7c77dcf96cf52f69bd534c21aff73789f Mon Sep 17 00:00:00 2001 From: NuvioTV Dev Date: Thu, 11 Jun 2026 18:26:53 +0300 Subject: [PATCH 11/11] fix: Exclude conflicting MANIFEST.MF --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c971c4299..f9f3f92ce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ android { packaging { resources { + excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" excludes += setOf( "/META-INF/{AL2.0,LGPL2.1}", "/META-INF/LICENSE*",