diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 2b93eddec..f69cdc68c 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -27,6 +27,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.Image import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,8 +46,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -186,7 +190,12 @@ class MainActivity : ComponentActivity() { setContent { // Observe device mode override changes live from DataStore val deviceModeOverride by remember { this@MainActivity.settingsDataStore.data.map { it[DEVICE_MODE_OVERRIDE_KEY] } }.collectAsState(initial = null) - val skipProfileSelection by remember { this@MainActivity.settingsDataStore.data.map { it[SKIP_PROFILE_SELECTION_KEY] ?: false } }.collectAsState(initial = false) + val skipProfileSelection by remember { + this@MainActivity.settingsDataStore.data.map { it[SKIP_PROFILE_SELECTION_KEY] ?: false } + }.collectAsState(initial = null as Boolean?) + val activeProfileLoaded by remember { + profileRepository.get().activeProfileId.map { true } + }.collectAsState(initial = false) val deviceType = when (deviceModeOverride) { "tv" -> DeviceType.TV "tablet" -> DeviceType.TABLET @@ -210,6 +219,7 @@ class MainActivity : ComponentActivity() { traktRepository = traktRepository.get(), launcherContinueWatchingRepository = launcherContinueWatchingRepository.get(), skipProfileSelection = skipProfileSelection, + activeProfileLoaded = activeProfileLoaded, pendingLauncherRequest = pendingLauncherRequest, onConsumeLauncherRequest = { pendingLauncherRequest = null }, preloadedCategories = startupState.categories, @@ -283,7 +293,7 @@ private fun ComponentActivity.runAfterFirstDraw(block: () -> Unit) { } /** - * Simple ARVIO loading screen - white glowing text + spinner + * Simple ARVIO loading screen - app logo + spinner */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -301,15 +311,14 @@ fun ArvioLoadingScreen() { label = "rotation" ) - // Pulsing glow for text - val glowAlpha by infiniteTransition.animateFloat( - initialValue = 0.5f, + val logoAlpha by infiniteTransition.animateFloat( + initialValue = 0.72f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(1500, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse ), - label = "glow" + label = "logoAlpha" ) Box( @@ -318,58 +327,45 @@ fun ArvioLoadingScreen() { .background(Color(0xFF0a0a0a)), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Image( + painter = painterResource(id = R.drawable.arvio_loading_logo), + contentDescription = "ARVIO", + modifier = Modifier.padding(horizontal = 24.dp), + contentScale = ContentScale.Fit, + alpha = logoAlpha + ) + + Canvas( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 132.dp) + .size(28.dp) ) { - // ARVIO text with white glow - Text( - text = "ARVIO", - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - letterSpacing = 8.sp, - style = androidx.compose.ui.text.TextStyle( - shadow = Shadow( - color = Color.White.copy(alpha = glowAlpha), - offset = Offset.Zero, - blurRadius = 30f - ) - ) + val strokeWidth = 2.dp.toPx() + val arcSize = androidx.compose.ui.geometry.Size( + size.width - strokeWidth, + size.height - strokeWidth ) - Spacer(modifier = Modifier.height(48.dp)) - - // Simple rotating spinner - Canvas(modifier = Modifier.size(48.dp)) { - val strokeWidth = 3.dp.toPx() - val arcSize = androidx.compose.ui.geometry.Size( - size.width - strokeWidth, - size.height - strokeWidth - ) - - // Background ring - drawArc( - color = Color.White.copy(alpha = 0.15f), - startAngle = 0f, - sweepAngle = 360f, - useCenter = false, - topLeft = Offset(strokeWidth / 2, strokeWidth / 2), - size = arcSize, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round) - ) + drawArc( + color = Color.White.copy(alpha = 0.12f), + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = Offset(strokeWidth / 2, strokeWidth / 2), + size = arcSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) - // Rotating arc - drawArc( - color = Color.White, - startAngle = rotation, - sweepAngle = 90f, - useCenter = false, - topLeft = Offset(strokeWidth / 2, strokeWidth / 2), - size = arcSize, - style = Stroke(width = strokeWidth, cap = StrokeCap.Round) - ) - } + drawArc( + color = Color.White.copy(alpha = 0.9f), + startAngle = rotation, + sweepAngle = 82f, + useCenter = false, + topLeft = Offset(strokeWidth / 2, strokeWidth / 2), + size = arcSize, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) } } } @@ -384,7 +380,8 @@ fun ArflixApp( profileRepository: ProfileRepository, traktRepository: TraktRepository, launcherContinueWatchingRepository: LauncherContinueWatchingRepository, - skipProfileSelection: Boolean = false, + skipProfileSelection: Boolean? = null, + activeProfileLoaded: Boolean = false, pendingLauncherRequest: LauncherContinueWatchingRequest? = null, onConsumeLauncherRequest: () -> Unit = {}, preloadedCategories: List = emptyList(), @@ -394,10 +391,19 @@ fun ArflixApp( onExitApp: () -> Unit = {} ) { val context = LocalContext.current - val navController = rememberNavController() - val appCoroutineScope = androidx.compose.runtime.rememberCoroutineScope() val authState by authRepository.authState.collectAsState() val activeProfile by profileRepository.activeProfile.collectAsState(initial = null) + val startupReady = skipProfileSelection != null && + activeProfileLoaded && + authState !is AuthState.Loading + + if (!startupReady) { + ArvioLoadingScreen() + return + } + + val navController = rememberNavController() + val appCoroutineScope = androidx.compose.runtime.rememberCoroutineScope() var lastAddonsSyncKey by remember { mutableStateOf(null) } LaunchedEffect(authState, activeProfile?.id) { @@ -412,7 +418,7 @@ fun ArflixApp( } // Always show profile selection on startup - user must manually choose a profile - val startDestination = if (skipProfileSelection && activeProfile != null) { + val startDestination = if (skipProfileSelection == true && activeProfile != null) { Screen.Home.route } else { Screen.ProfileSelection.route @@ -515,4 +521,3 @@ private fun enqueueFullTraktSync(context: android.content.Context) { request ) } - 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 12edd16f7..a0af581e9 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -19,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -293,6 +295,21 @@ fun SettingsScreen( } } + val hasBlockingModal = + showCustomAddonInput || + showIptvInput || + showStalkerInput || + showCatalogInput || + showCatalogRename || + showSubtitlePicker || + showAudioLanguagePicker || + showDnsProviderPicker || + showContentLanguagePicker || + uiState.showCloudPairDialog || + uiState.showCloudEmailPasswordDialog || + uiState.showAppUpdateDialog || + uiState.showUnknownSourcesDialog + Box( modifier = Modifier .fillMaxSize() @@ -301,8 +318,7 @@ fun SettingsScreen( .focusable() .onPreviewKeyEvent { event -> if (isTouchDevice) return@onPreviewKeyEvent false - // BLOCKER FIX: Ignore main screen navigation if modals are open - if (showCustomAddonInput || showSubtitlePicker || showAudioLanguagePicker || showDnsProviderPicker || showContentLanguagePicker || showIptvInput || showCatalogInput || uiState.showCloudPairDialog || uiState.showCloudEmailPasswordDialog) return@onPreviewKeyEvent false + if (hasBlockingModal) return@onPreviewKeyEvent false if (event.type == KeyEventType.KeyDown) { val currentSection = sections.getOrNull(sectionIndex).orEmpty() @@ -1117,6 +1133,36 @@ fun SettingsScreen( } } +@Composable +private fun ModalScrim( + onDismiss: () -> Unit, + content: @Composable BoxScope.() -> Unit +) { + val scrimInteraction = remember { MutableInteractionSource() } + val contentInteraction = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable( + interactionSource = scrimInteraction, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier.clickable( + interactionSource = contentInteraction, + indication = null, + onClick = {} + ), + content = content + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun CloudEmailPasswordModal( @@ -1145,228 +1191,230 @@ private fun CloudEmailPasswordModal( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( dismissOnBackPress = true, - dismissOnClickOutside = false, + dismissOnClickOutside = true, usePlatformDefaultWidth = false ) ) { - Column( - modifier = Modifier - .fillMaxWidth(if (LocalDeviceType.current.isTouchDevice()) 0.92f else 1f) - .widthIn(max = 600.dp) - .background(BackgroundElevated, RoundedCornerShape(16.dp)) - .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 32.dp) - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.Back, Key.Escape -> { - onDismiss() - true - } - Key.DirectionUp -> { - focusedIndex = when (focusedIndex) { - 1 -> 0 - 2, 3, 4 -> 1 - else -> focusedIndex + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth(if (LocalDeviceType.current.isTouchDevice()) 0.92f else 1f) + .widthIn(max = 600.dp) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 32.dp) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true } - true - } - Key.DirectionDown -> { - focusedIndex = when (focusedIndex) { - 0 -> 1 - 1 -> 3 // Move to primary action; avoid "Down moves right" feeling. - else -> focusedIndex + Key.DirectionUp -> { + focusedIndex = when (focusedIndex) { + 1 -> 0 + 2, 3, 4 -> 1 + else -> focusedIndex + } + true } - true - } - Key.DirectionLeft -> { - focusedIndex = when (focusedIndex) { - 4 -> 3 - 3 -> 2 - else -> focusedIndex + Key.DirectionDown -> { + focusedIndex = when (focusedIndex) { + 0 -> 1 + 1 -> 3 + else -> focusedIndex + } + true } - true - } - Key.DirectionRight -> { - focusedIndex = when (focusedIndex) { - 2 -> 3 - 3 -> 4 - else -> focusedIndex + Key.DirectionLeft -> { + focusedIndex = when (focusedIndex) { + 4 -> 3 + 3 -> 2 + else -> focusedIndex + } + true } - true - } - Key.Enter, Key.DirectionCenter -> { - when (focusedIndex) { - 2 -> { onDismiss(); true } - 3 -> { onSignIn(); true } - 4 -> { onCreateAccount(); true } - else -> false + Key.DirectionRight -> { + focusedIndex = when (focusedIndex) { + 2 -> 3 + 3 -> 4 + else -> focusedIndex + } + true } + Key.Enter, Key.DirectionCenter -> { + when (focusedIndex) { + 2 -> { onDismiss(); true } + 3 -> { onSignIn(); true } + 4 -> { onCreateAccount(); true } + else -> false + } + } + else -> false } - else -> false - } - } else false - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "ARVIO Cloud Sign-in", - style = ArflixTypography.sectionTitle, - color = TextPrimary, - modifier = Modifier.padding(bottom = 24.dp) - ) - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = "Email", - style = ArflixTypography.caption, - color = if (focusedIndex == 0) Pink else TextSecondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - androidx.compose.material3.TextField( - value = email, - onValueChange = onEmailChange, - singleLine = true, - textStyle = ArflixTypography.body.copy(color = TextPrimary), - colors = androidx.compose.material3.TextFieldDefaults.colors( - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - focusedContainerColor = Color.White.copy(alpha = 0.1f), - unfocusedContainerColor = Color.White.copy(alpha = 0.05f), - focusedIndicatorColor = Pink, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Pink - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(emailRequester) - .border( - width = if (focusedIndex == 0) 2.dp else 1.dp, - color = if (focusedIndex == 0) Pink else Color.White.copy(alpha = 0.2f), - shape = RoundedCornerShape(8.dp) - ) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Column(modifier = Modifier.fillMaxWidth()) { + } else false + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( - text = "Password", - style = ArflixTypography.caption, - color = if (focusedIndex == 1) Pink else TextSecondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - androidx.compose.material3.TextField( - value = password, - onValueChange = onPasswordChange, - singleLine = true, - visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), - textStyle = ArflixTypography.body.copy(color = TextPrimary), - colors = androidx.compose.material3.TextFieldDefaults.colors( - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - focusedContainerColor = Color.White.copy(alpha = 0.1f), - unfocusedContainerColor = Color.White.copy(alpha = 0.05f), - focusedIndicatorColor = Pink, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Pink - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(passwordRequester) - .border( - width = if (focusedIndex == 1) 2.dp else 1.dp, - color = if (focusedIndex == 1) Pink else Color.White.copy(alpha = 0.2f), - shape = RoundedCornerShape(8.dp) - ) + text = "ARVIO Cloud Sign-in", + style = ArflixTypography.sectionTitle, + color = TextPrimary, + modifier = Modifier.padding(bottom = 24.dp) ) - } - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - val isCancelFocused = focusedIndex == 2 - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .background( - color = if (isCancelFocused) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.1f) - ) - .clickable { onDismiss() } - .border( - width = if (isCancelFocused) 2.dp else 0.dp, - color = if (isCancelFocused) Pink else Color.Transparent, - shape = RoundedCornerShape(8.dp) - ) - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center - ) { + Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "Cancel", - style = ArflixTypography.button, - color = if (isCancelFocused) TextPrimary else TextSecondary + text = "Email", + style = ArflixTypography.caption, + color = if (focusedIndex == 0) Pink else TextSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + androidx.compose.material3.TextField( + value = email, + onValueChange = onEmailChange, + singleLine = true, + textStyle = ArflixTypography.body.copy(color = TextPrimary), + colors = androidx.compose.material3.TextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + focusedContainerColor = Color.White.copy(alpha = 0.1f), + unfocusedContainerColor = Color.White.copy(alpha = 0.05f), + focusedIndicatorColor = Pink, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Pink + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(emailRequester) + .border( + width = if (focusedIndex == 0) 2.dp else 1.dp, + color = if (focusedIndex == 0) Pink else Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) ) } - val isSignInFocused = focusedIndex == 3 - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .background( - color = if (isSignInFocused) SuccessGreen else Pink.copy(alpha = 0.6f) - ) - .clickable { onSignIn() } - .border( - width = if (isSignInFocused) 2.dp else 0.dp, - color = if (isSignInFocused) SuccessGreen.copy(alpha = 0.5f) else Color.Transparent, - shape = RoundedCornerShape(8.dp) - ) - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center - ) { + Spacer(modifier = Modifier.height(16.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { Text( - text = "Sign In", - style = ArflixTypography.button, - color = Color.White + text = "Password", + style = ArflixTypography.caption, + color = if (focusedIndex == 1) Pink else TextSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + androidx.compose.material3.TextField( + value = password, + onValueChange = onPasswordChange, + singleLine = true, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + textStyle = ArflixTypography.body.copy(color = TextPrimary), + colors = androidx.compose.material3.TextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + focusedContainerColor = Color.White.copy(alpha = 0.1f), + unfocusedContainerColor = Color.White.copy(alpha = 0.05f), + focusedIndicatorColor = Pink, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Pink + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(passwordRequester) + .border( + width = if (focusedIndex == 1) 2.dp else 1.dp, + color = if (focusedIndex == 1) Pink else Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) ) } - val isCreateFocused = focusedIndex == 4 - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .background( - color = if (isCreateFocused) SuccessGreen else Color.White.copy(alpha = 0.08f) + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + val isCancelFocused = focusedIndex == 2 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background( + color = if (isCancelFocused) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.1f) + ) + .clickable { onDismiss() } + .border( + width = if (isCancelFocused) 2.dp else 0.dp, + color = if (isCancelFocused) Pink else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + style = ArflixTypography.button, + color = if (isCancelFocused) TextPrimary else TextSecondary ) - .clickable { onCreateAccount() } - .border( - width = if (isCreateFocused) 2.dp else 0.dp, - color = if (isCreateFocused) SuccessGreen.copy(alpha = 0.5f) else Color.Transparent, - shape = RoundedCornerShape(8.dp) + } + + val isSignInFocused = focusedIndex == 3 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background( + color = if (isSignInFocused) SuccessGreen else Pink.copy(alpha = 0.6f) + ) + .clickable { onSignIn() } + .border( + width = if (isSignInFocused) 2.dp else 0.dp, + color = if (isSignInFocused) SuccessGreen.copy(alpha = 0.5f) else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Sign In", + style = ArflixTypography.button, + color = Color.White ) - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "Create", - style = ArflixTypography.button, - color = Color.White - ) + } + + val isCreateFocused = focusedIndex == 4 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background( + color = if (isCreateFocused) SuccessGreen else Color.White.copy(alpha = 0.08f) + ) + .clickable { onCreateAccount() } + .border( + width = if (isCreateFocused) 2.dp else 0.dp, + color = if (isCreateFocused) SuccessGreen.copy(alpha = 0.5f) else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Create", + style = ArflixTypography.button, + color = Color.White + ) + } } - } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = if (LocalDeviceType.current.isTouchDevice()) "Enter your email and password to sign in." else "Tip: Use TV keyboard. D-pad to navigate.", - style = ArflixTypography.caption, - color = TextSecondary.copy(alpha = 0.5f) - ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = if (LocalDeviceType.current.isTouchDevice()) "Enter your email and password to sign in." else "Tip: Use TV keyboard. D-pad to navigate.", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.5f) + ) + } } } } @@ -1394,12 +1442,13 @@ private fun CloudPairModal( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( dismissOnBackPress = true, - dismissOnClickOutside = false, + dismissOnClickOutside = true, usePlatformDefaultWidth = false ) ) { val isMobile = LocalDeviceType.current.isTouchDevice() - BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ModalScrim(onDismiss = onDismiss) { + BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val modalWidth = if (isMobile) maxWidth else (maxWidth * 0.62f).coerceIn(520.dp, 760.dp) val qrContainerSize = (modalWidth * 0.42f).coerceIn(190.dp, 260.dp) val qrBitmapSizePx = ((qrContainerSize.value * 3.2f).toInt()).coerceIn(512, 900) @@ -1471,17 +1520,27 @@ private fun CloudPairModal( Box( modifier = Modifier .size(qrContainerSize) - .background(Color.White, RoundedCornerShape(12.dp)) - .padding(10.dp), + .background(BackgroundDark.copy(alpha = 0.92f), RoundedCornerShape(16.dp)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(16.dp)) + .padding(12.dp), contentAlignment = Alignment.Center ) { - QrCodeImage( - data = effectiveVerificationUrl, - sizePx = qrBitmapSizePx, - modifier = Modifier.fillMaxSize(), - foreground = android.graphics.Color.BLACK, - background = android.graphics.Color.WHITE, - ) + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + QrCodeImage( + data = effectiveVerificationUrl, + sizePx = qrBitmapSizePx, + modifier = Modifier.fillMaxSize(), + foreground = android.graphics.Color.BLACK, + background = android.graphics.Color.WHITE, + ) + } } } @@ -1647,6 +1706,7 @@ private fun CloudPairModal( } } } + } } } @@ -1752,50 +1812,51 @@ private fun AppUpdateModal( focusRequester.requestFocus() } - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Column( - modifier = Modifier - .then( - if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) - else Modifier.width(760.dp) - ) - .background(BackgroundElevated, RoundedCornerShape(18.dp)) - .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Back, Key.Escape -> { onDismiss(); true } - Key.DirectionLeft -> { - focusedIndex = (focusedIndex - 1).coerceAtLeast(0) - true - } - Key.DirectionRight -> { - focusedIndex = (focusedIndex + 1).coerceAtMost(2) - true - } - Key.Enter, Key.DirectionCenter -> { - when (focusedIndex) { - 0 -> onDismiss() - 1 -> onIgnore() - 2 -> if (primaryEnabled) { - if (downloadedApkPath != null) onInstall() else onDownload() + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) + else Modifier.width(760.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(18.dp)) + .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Back, Key.Escape -> { onDismiss(); true } + Key.DirectionLeft -> { + focusedIndex = (focusedIndex - 1).coerceAtLeast(0) + true + } + Key.DirectionRight -> { + focusedIndex = (focusedIndex + 1).coerceAtMost(2) + true + } + Key.Enter, Key.DirectionCenter -> { + when (focusedIndex) { + 0 -> onDismiss() + 1 -> onIgnore() + 2 -> if (primaryEnabled) { + if (downloadedApkPath != null) onInstall() else onDownload() + } } + true } - true + else -> false } - else -> false } - } - ) { + ) { Text("App Update", style = ArflixTypography.sectionTitle, color = TextPrimary) Spacer(modifier = Modifier.height(10.dp)) @@ -1877,6 +1938,7 @@ private fun AppUpdateModal( ) } } + } } } @@ -1897,45 +1959,47 @@ private fun UnknownSourcesModal( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( dismissOnBackPress = true, - dismissOnClickOutside = false, + dismissOnClickOutside = true, usePlatformDefaultWidth = false ) ) { - Column( - modifier = Modifier - .then( - if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) - else Modifier.width(620.dp) - ) - .background(BackgroundElevated, RoundedCornerShape(18.dp)) - .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Back, Key.Escape -> { onDismiss(); true } - Key.DirectionLeft -> { focusedIndex = 0; true } - Key.DirectionRight -> { focusedIndex = 1; true } - Key.Enter, Key.DirectionCenter -> { - if (focusedIndex == 0) onDismiss() else onOpenSettings() - true + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) + else Modifier.width(620.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(18.dp)) + .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Back, Key.Escape -> { onDismiss(); true } + Key.DirectionLeft -> { focusedIndex = 0; true } + Key.DirectionRight -> { focusedIndex = 1; true } + Key.Enter, Key.DirectionCenter -> { + if (focusedIndex == 0) onDismiss() else onOpenSettings() + true + } + else -> false } - else -> false } + ) { + Text("Allow Unknown Sources", style = ArflixTypography.sectionTitle, color = TextPrimary) + Spacer(modifier = Modifier.height(12.dp)) + Text( + "Allow installs from unknown sources for ARVIO so the downloaded update APK can be installed.", + style = ArflixTypography.body, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + UpdateActionButton("Close", focusedIndex == 0, onDismiss) + UpdateActionButton("Open Settings", focusedIndex == 1, onOpenSettings, highlighted = true) } - ) { - Text("Allow Unknown Sources", style = ArflixTypography.sectionTitle, color = TextPrimary) - Spacer(modifier = Modifier.height(12.dp)) - Text( - "Allow installs from unknown sources for ARVIO so the downloaded update APK can be installed.", - style = ArflixTypography.body, - color = TextSecondary - ) - Spacer(modifier = Modifier.height(24.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - UpdateActionButton("Close", focusedIndex == 0, onDismiss) - UpdateActionButton("Open Settings", focusedIndex == 1, onOpenSettings, highlighted = true) } } } @@ -1951,13 +2015,23 @@ private fun UpdateActionButton( ) { val background = when { !enabled -> Color.White.copy(alpha = 0.06f) - isFocused -> Color.White - else -> Color.Black.copy(alpha = 0.72f) + highlighted && isFocused -> Pink + isFocused -> Color.White.copy(alpha = 0.16f) + highlighted -> Pink.copy(alpha = 0.18f) + else -> Color.White.copy(alpha = 0.08f) } val textColor = when { !enabled -> TextSecondary.copy(alpha = 0.6f) - isFocused -> Color.Black - else -> Color.White + highlighted -> Color.White + isFocused -> TextPrimary + else -> TextSecondary + } + val borderColor = when { + !enabled -> Color.White.copy(alpha = 0.1f) + highlighted && isFocused -> Pink.copy(alpha = 0.95f) + highlighted -> Pink.copy(alpha = 0.4f) + isFocused -> Color.White.copy(alpha = 0.75f) + else -> Color.White.copy(alpha = 0.12f) } Box( @@ -1965,8 +2039,8 @@ private fun UpdateActionButton( .clip(RoundedCornerShape(10.dp)) .background(background, RoundedCornerShape(10.dp)) .border( - width = if (isFocused) 2.dp else 0.dp, - color = if (isFocused) Color.White.copy(alpha = 0.92f) else Color.White.copy(alpha = 0.12f), + width = if (isFocused || highlighted) 1.5.dp else 1.dp, + color = borderColor, shape = RoundedCornerShape(10.dp) ) .clickable(enabled = enabled) { onClick() } @@ -3308,73 +3382,73 @@ private fun InputModalLegacy( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( dismissOnBackPress = true, - dismissOnClickOutside = false, + dismissOnClickOutside = true, usePlatformDefaultWidth = false ) ) { - Column( - modifier = Modifier - .width(550.dp) - .background(BackgroundElevated, RoundedCornerShape(16.dp)) - .padding(32.dp) - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.Back, Key.Escape -> { - onDismiss() - true - } - Key.DirectionUp -> { - if (focusedIndex > 0) { - focusedIndex-- - } - true - } - Key.DirectionDown -> { - if (focusedIndex < totalItems - 1) { - focusedIndex++ + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .width(550.dp) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(32.dp) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true } - true - } - Key.DirectionLeft -> { - if (focusedIndex == fields.size + 2) { - focusedIndex = fields.size + 1 + Key.DirectionUp -> { + if (focusedIndex > 0) { + focusedIndex-- + } + true } - true - } - Key.DirectionRight -> { - if (focusedIndex == fields.size + 1) { - focusedIndex = fields.size + 2 + Key.DirectionDown -> { + if (focusedIndex < totalItems - 1) { + focusedIndex++ + } + true } - true - } - Key.Enter, Key.DirectionCenter -> { - when { - focusedIndex == fields.size -> { - // Paste button - paste clipboard to first field (URL) - val clipboardText = clipboardManager.getText()?.text - if (clipboardText != null && fields.isNotEmpty()) { - fields[0].onValueChange(clipboardText) - } - true + Key.DirectionLeft -> { + if (focusedIndex == fields.size + 2) { + focusedIndex = fields.size + 1 } - focusedIndex == fields.size + 1 -> { - onDismiss() - true + true + } + Key.DirectionRight -> { + if (focusedIndex == fields.size + 1) { + focusedIndex = fields.size + 2 } - focusedIndex == fields.size + 2 -> { - onConfirm() - true + true + } + Key.Enter, Key.DirectionCenter -> { + when { + focusedIndex == fields.size -> { + val clipboardText = clipboardManager.getText()?.text + if (clipboardText != null && fields.isNotEmpty()) { + fields[0].onValueChange(clipboardText) + } + true + } + focusedIndex == fields.size + 1 -> { + onDismiss() + true + } + focusedIndex == fields.size + 2 -> { + onConfirm() + true + } + else -> false } - else -> false // Let text field handle Enter } + else -> false } - else -> false - } - } else false - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { + } else false + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( text = title, style = ArflixTypography.sectionTitle, @@ -3527,6 +3601,7 @@ private fun InputModalLegacy( style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) + } } } } @@ -3593,15 +3668,15 @@ private fun InputModal( }, properties = androidx.compose.ui.window.DialogProperties( dismissOnBackPress = true, - dismissOnClickOutside = false, + dismissOnClickOutside = true, usePlatformDefaultWidth = false ) ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), - contentAlignment = Alignment.Center + ModalScrim( + onDismiss = { + hideKeyboardAll() + onDismiss() + } ) { Column( modifier = Modifier @@ -3918,7 +3993,7 @@ private fun InputModal( text = if (LocalDeviceType.current.isTouchDevice()) "Tap a field to edit, tap Confirm when done" else "OK: edit/select \u2022 Back: close keyboard first", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.56f) - ) + ) } } } @@ -3948,107 +4023,111 @@ private fun SubtitlePickerModal( focusRequester.requestFocus() } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)) - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.Back, Key.Escape -> { - onDismiss() - true - } - Key.DirectionUp -> { - if (safeIndex > 0) onFocusChange(safeIndex - 1) - true - } - Key.DirectionDown -> { - if (safeIndex < options.size - 1) onFocusChange(safeIndex + 1) - true - } - Key.Enter, Key.DirectionCenter -> { - if (options.isNotEmpty()) { - onSelect(options[safeIndex]) + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 520.dp) + else Modifier.width(520.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true + } + Key.DirectionUp -> { + if (safeIndex > 0) onFocusChange(safeIndex - 1) + true + } + Key.DirectionDown -> { + if (safeIndex < options.size - 1) onFocusChange(safeIndex + 1) + true + } + Key.Enter, Key.DirectionCenter -> { + if (options.isNotEmpty()) { + onSelect(options[safeIndex]) + } + true + } + else -> false } - true - } - else -> false + } else false } - } else false - }, - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .then( - if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 520.dp) - else Modifier.width(520.dp) + ) { + Text( + text = title, + style = ArflixTypography.sectionTitle, + color = TextPrimary ) - .background(BackgroundElevated, RoundedCornerShape(16.dp)) - .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) - ) { - Text( - text = title, - style = ArflixTypography.sectionTitle, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - LazyColumn( - state = listState, - modifier = Modifier.heightIn(max = 360.dp) - ) { - itemsIndexed(options) { index, option -> - val isFocused = index == safeIndex - val isSelected = option.equals(selected, ignoreCase = true) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background( - if (isFocused) Color.White.copy(alpha = 0.12f) else Color.Transparent, - RoundedCornerShape(10.dp) - ) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) Pink else Color.White.copy(alpha = 0.1f), - shape = RoundedCornerShape(10.dp) - ) - .clickable { onSelect(option) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = option, - style = ArflixTypography.body, - color = if (isFocused) TextPrimary else TextSecondary, - modifier = Modifier.weight(1f) - ) - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = SuccessGreen, - modifier = Modifier.size(18.dp) + LazyColumn( + state = listState, + modifier = Modifier.heightIn(max = 360.dp) + ) { + itemsIndexed(options) { index, option -> + val isFocused = index == safeIndex + val isSelected = option.equals(selected, ignoreCase = true) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + if (isFocused) Color.White.copy(alpha = 0.12f) else Color.Transparent, + RoundedCornerShape(10.dp) + ) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) Pink else Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onSelect(option) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = option, + style = ArflixTypography.body, + color = if (isFocused) TextPrimary else TextSecondary, + modifier = Modifier.weight(1f) ) + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = SuccessGreen, + modifier = Modifier.size(18.dp) + ) + } } + Spacer(modifier = Modifier.height(10.dp)) } - Spacer(modifier = Modifier.height(10.dp)) } - } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "Press Enter to select", - style = ArflixTypography.caption, - color = TextSecondary.copy(alpha = 0.6f), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Press Enter to select", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } } } } diff --git a/app/src/main/res/drawable-nodpi/arvio_loading_logo.png b/app/src/main/res/drawable-nodpi/arvio_loading_logo.png new file mode 100644 index 000000000..1fd26e944 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/arvio_loading_logo.png differ