diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1801aa64..f9f3f92c 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") } } @@ -151,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*", @@ -439,3 +442,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-android: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") +} + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 653bc1e7..9a9209d7 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/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index c1556d15..0489f76c 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 1dd8478a..e4cef95d 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 109bb3b7..0b0e7dfd 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 fb6aa806..4c45fb6d 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 5c7c284e..7b7be221 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/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index f307cffc..99bffbc8 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)...") 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 34ae00bf..f193ec3d 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 @@ -540,7 +541,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 } 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 4dac7d32..12348e19 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 @@ -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 7aca3cfa..95cb824f 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 @@ -293,6 +293,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 } @@ -1723,24 +1725,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) + ) + } } } } @@ -2174,6 +2180,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) } @@ -2295,6 +2302,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 -> { @@ -2322,44 +2365,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 @@ -2696,7 +2713,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 ) @@ -3404,7 +3426,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 3e4d21bc..1fee2215 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 @@ -2033,6 +2033,10 @@ fun PlayerScreen( } aspectIndicatorTrigger++ } + + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr + ) { Box( modifier = Modifier .fillMaxSize() @@ -3524,6 +3528,7 @@ fun PlayerScreen( } } } + } } @OptIn(ExperimentalTvMaterial3Api::class) 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 aca4ac16..8bc5d701 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 @@ -295,6 +295,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 val sections = remember { buildList { @@ -687,7 +688,18 @@ fun SettingsScreen( } else { 2 } - 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() diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 00000000..3860195d --- /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 00000000..3d396fbf --- /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.*'