diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index af4406f3b..f25d59222 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ android:name=".ArflixApplication" android:allowBackup="false" android:banner="@drawable/app_banner" - android:enableOnBackInvokedCallback="false" + android:enableOnBackInvokedCallback="true" android:extractNativeLibs="false" android:hardwareAccelerated="true" android:largeHeap="true" diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index 7e96f5223..674872a11 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -119,6 +120,10 @@ fun AppUpdateModal( focusRequester.requestFocus() } + BackHandler { + onDismiss() + } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt index a3190a389..88ac13a80 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AudioTrackSelector.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -73,6 +74,10 @@ fun AudioTrackSelector( ) { var focusedIndex by remember(isVisible) { mutableIntStateOf(0) } + BackHandler(enabled = isVisible) { + onClose() + } + AnimatedVisibility( visible = isVisible, enter = fadeIn(), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt index a245fa5c9..ee539c8c6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -117,6 +118,10 @@ fun ContextMenu( } } + BackHandler(enabled = isVisible) { + onDismiss() + } + if (!isMobile) { // --- TV layout: centered card with D-pad navigation --- AnimatedVisibility( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt index 549c8e690..f6a2f09a4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaContextMenu.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -128,6 +129,10 @@ fun MediaContextMenu( } } + BackHandler(enabled = isVisible) { + onDismiss() + } + if (!isMobile) { // --- TV layout: centered card with D-pad navigation --- AnimatedVisibility( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt index 00b85b703..05b301cf4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/NextEpisodeOverlay.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -126,6 +127,10 @@ fun NextEpisodeOverlay( } } + BackHandler(enabled = isVisible) { + onCancel() + } + AnimatedVisibility( visible = isVisible, enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index 8a248fbbf..be73f5bbf 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -127,6 +128,10 @@ fun PersonModal( } } + BackHandler(enabled = isVisible) { + onClose() + } + AnimatedVisibility( visible = isVisible, enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt index b978e8661..7c6f4fe07 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/QuickActionMenu.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -70,6 +71,10 @@ fun QuickActionMenu( } } + BackHandler(enabled = isVisible) { + onDismiss() + } + AnimatedVisibility( visible = isVisible, enter = fadeIn() + scaleIn(initialScale = 0.92f), 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 dbd05023f..45852eded 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 @@ -1,5 +1,11 @@ package com.arflix.tv.ui.components +import com.arflix.tv.ui.motion.* + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.runtime.mutableFloatStateOf +import kotlinx.coroutines.CancellationException import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -223,6 +229,8 @@ fun StreamSelector( onClose: () -> Unit = {} ) { val isRtlLayoutDirection = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl + val backMotion = rememberArvioPredictiveBack(enabled = isVisible, onCommit = onClose) + var focusedIndex by remember { mutableIntStateOf(0) } var focusedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) } @@ -378,15 +386,20 @@ fun StreamSelector( AnimatedVisibility( visible = isVisible, enter = fadeIn(tween(200)) + slideInVertically(tween(300)) { it / 4 }, - exit = fadeOut(tween(150)) + slideOutVertically(tween(200)) { it / 4 } + exit = fadeOut(tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + slideOutVertically(tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)) { it / 4 } ) { Box( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester) - .focusable() - .background(Color.Black.copy(alpha = 0.95f)) - .onKeyEvent { event -> + .background(Color.Black.copy(alpha = (0.95f * (1f - backMotion.eased * 0.5f)).coerceIn(0f, 0.95f))) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .arvioBackModal(backMotion) + .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { val isRtl = isRtlLayoutDirection val actualKey = event.key @@ -399,7 +412,7 @@ fun StreamSelector( } else actualKey when (logicalKey) { - Key.Back, Key.Escape -> { + Key.Escape -> { onClose() true } @@ -691,6 +704,7 @@ fun StreamSelector( } } } +} @OptIn(ExperimentalTvMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt index 3b16adbb8..856a24ed2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt @@ -5,6 +5,7 @@ import android.view.KeyEvent import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.EditText +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -127,6 +128,11 @@ fun TextInputModal( } } + BackHandler(enabled = isVisible) { + hideKeyboard() + onCancel() + } + AnimatedVisibility( visible = isVisible, enter = fadeIn(), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt b/app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt new file mode 100644 index 000000000..cbe21f630 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt @@ -0,0 +1,130 @@ +package com.arflix.tv.ui.motion + +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * M3 STANDARD_DECELERATE — PathInterpolator(0f, 0f, 0f, 1f). + * Deliberately NOT FastOutSlowInEasing, which is (0.4, 0, 0.2, 1) ease-in-out. + */ +val ArvioStandardDecelerate: Easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +private const val MinSurfaceScale = 0.90f // M3: surface scales to 90% +private val EdgeMargin = 8.dp // M3: 8dp from screen edges + +/** Raw gesture state. `progress` is unmodified pointer progress; ease at consumption. */ +@Stable +class ArvioBackMotion internal constructor() { + var progress by mutableFloatStateOf(0f) + internal set + var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT) + internal set + var touchY by mutableFloatStateOf(Float.NaN) + internal set + val eased: Float get() = ArvioStandardDecelerate.transform(progress) +} + +/** + * PredictiveBackHandler with real settle animations. + * + * Commit: animates the remaining travel to 1f *before* invoking [onCommit], so releasing + * at 80% is never a jump cut. Cancel: animates back to 0f. + * + * The settle is launched on [rememberCoroutineScope] because on cancel the handler's own + * coroutine is already cancelled — suspending in the catch block would throw immediately. + */ +@Composable +fun rememberArvioPredictiveBack( + enabled: Boolean, + commitDurationMs: Int = 280, + cancelDurationMs: Int = 240, + onCommit: () -> Unit, +): ArvioBackMotion { + val motion = remember { ArvioBackMotion() } + val anim = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val commit by rememberUpdatedState(onCommit) + + LaunchedEffect(anim) { + snapshotFlow { anim.value }.collect { motion.progress = it } + } + + PredictiveBackHandler(enabled = enabled) { events -> + try { + events.collect { e -> + motion.swipeEdge = e.swipeEdge + motion.touchY = e.touchY + anim.snapTo(e.progress) + } + anim.animateTo(1f, tween(commitDurationMs, easing = ArvioStandardDecelerate)) + commit() + } catch (e: CancellationException) { + scope.launch { + anim.animateTo(0f, tween(cancelDurationMs, easing = ArvioStandardDecelerate)) + } + throw e + } + } + return motion +} + +/** The leaving surface. Full M3 spec: 90% scale, edge-aware X-shift, touchY-weighted Y-shift. */ +fun Modifier.arvioBackSurface(motion: ArvioBackMotion): Modifier = graphicsLayer { + val p = motion.eased + if (p <= 0f) return@graphicsLayer + + val s = 1f - (1f - MinSurfaceScale) * p + scaleX = s + scaleY = s + + val margin = EdgeMargin.toPx() + val maxX = (size.width / 20f - margin).coerceAtLeast(0f) + val dir = if (motion.swipeEdge == BackEventCompat.EDGE_RIGHT) -1f else 1f + translationX = dir * p * maxX + + val maxY = (size.height / 20f - margin).coerceAtLeast(0f) + val pivot = if (motion.touchY.isNaN()) 0f + else ((motion.touchY - size.height / 2f) / (size.height / 2f)).coerceIn(-1f, 1f) + translationY = p * pivot * maxY +} + +/** The revealed surface underneath. Matches SettingsScreen.kt peeking behavior. */ +fun Modifier.arvioBackPeek(motion: ArvioBackMotion, active: Boolean): Modifier = graphicsLayer { + if (!active) return@graphicsLayer + val p = motion.eased + if (p <= 0f) return@graphicsLayer + val s = 0.96f + p * 0.04f + scaleX = s + scaleY = s + alpha = 0.6f + p * 0.4f +} + +/** Modal dismiss: shrink + fade in place. No positional shift — a modal closes, it doesn't traverse. */ +fun Modifier.arvioBackModal(motion: ArvioBackMotion): Modifier = graphicsLayer { + val p = motion.eased + if (p <= 0f) return@graphicsLayer + val s = 1f - (1f - MinSurfaceScale) * p + scaleX = s + scaleY = s + alpha = 1f - p * 0.3f +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt index ec696bfcd..544e04faf 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt @@ -591,6 +591,10 @@ fun CollectionDetailsScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + BackHandler { + onBack() + } + Box( modifier = Modifier .fillMaxSize() 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 8d31432fa..6e33dde63 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 @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.details +import androidx.activity.compose.BackHandler import android.content.Context import android.graphics.Bitmap import android.content.Intent @@ -539,6 +540,14 @@ fun DetailsScreen( } } + BackHandler(enabled = !showStreamSelector && !showEpisodeContextMenu && !showSeasonContextMenu && !uiState.showPersonModal) { + if (showTrailerPlayer) { + showTrailerPlayer = false + } else { + onBack() + } + } + // D-pad key handler — only used on TV (skipped on mobile/touch devices) val dpadRepeatGate = rememberArvioDpadRepeatGate( horizontalMinRepeatIntervalMs = 80L, 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 81b2fd5b8..37226dbc0 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 @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.home +import androidx.activity.compose.BackHandler import android.content.Context import android.graphics.Bitmap import androidx.compose.animation.AnimatedContent @@ -2330,6 +2331,19 @@ private fun HomeInputLayer( preferredCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id } + BackHandler { + selectPressedInHome = false + selectDownAtMs = 0L + if (focusState.isSidebarFocused) { + onExitApp() + } else { + categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> + focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex + } + focusState.isSidebarFocused = true + } + } + val focusedCategory = categories.getOrNull(focusState.currentRowIndex) val focusedCategoryId = focusedCategory?.id val focusedItemKeys = remember(focusedCategoryId, focusedCategory?.items) { 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 2b017a5fb..dd3481279 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 @@ -2490,6 +2490,10 @@ fun PlayerScreen( cancelNextEpisodePrompt() } + BackHandler(enabled = uiState.error != null) { + onBack() + } + BackHandler( enabled = !showSubtitleMenu && !showSourceMenu && !showNextEpisodePrompt && !showSubtitleSettings && uiState.error == null ) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt index 924fe0336..950b8a222 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt @@ -1,6 +1,7 @@ package com.arflix.tv.ui.screens.plugin import android.content.Context +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -123,6 +124,10 @@ fun PluginScreen( } } + BackHandler { + onBackPressed() + } + if (isMobile) { Column( modifier = Modifier diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 2ad0c03db..04a4a1edd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.search +import androidx.activity.compose.BackHandler import android.os.SystemClock import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -297,6 +298,37 @@ fun SearchScreen( val showFilters = uiState.query.isEmpty() + BackHandler { + if (isSearchEditing) { + isSearchEditing = false + keyboardController?.hide() + runCatching { searchFocusRequester.requestFocus() } + } else { + when (focusZone) { + FocusZone.RESULTS -> { + if (showFilters && quickFilters.isNotEmpty()) { + focusZone = FocusZone.FILTERS + focusedFilterIndex = focusedFilterIndex.coerceIn(0, (quickFilters.size - 1).coerceAtLeast(0)) + try { filtersFocusRequester.requestFocus() } catch (_: Exception) {} + } else { + focusZone = FocusZone.SEARCH_INPUT + searchFocusRequester.requestFocus() + } + } + FocusZone.FILTERS -> { + focusZone = FocusZone.SEARCH_INPUT + searchFocusRequester.requestFocus() + } + FocusZone.SEARCH_INPUT -> { + focusZone = FocusZone.SIDEBAR + } + FocusZone.SIDEBAR -> { + onBack() + } + } + } + } + // D-pad handler: manages zone transitions. FILTERS zone lets native focus handle Left/Right. val dpadModifier = if (!isTouchDevice) { Modifier.onPreviewKeyEvent { event -> 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 d46605746..77f830568 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 @@ -1,5 +1,14 @@ package com.arflix.tv.ui.screens.settings +import androidx.activity.compose.BackHandler +import com.arflix.tv.ui.motion.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import android.content.Context import android.content.Intent import android.net.Uri @@ -723,6 +732,19 @@ fun SettingsScreen( uiState.pendingPackManifest != null || pluginsModalOpen + BackHandler(enabled = !isTouchDevice && !hasBlockingModal) { + when (activeZone) { + Zone.SIDEBAR -> onBack() + Zone.SECTION -> { + activeZone = Zone.SIDEBAR + isSidebarFocused = true + } + Zone.CONTENT -> { + activeZone = Zone.SECTION + } + } + } + Box( modifier = Modifier .fillMaxSize() @@ -3536,16 +3558,20 @@ private fun MobileSettingsLayout( onDisconnectCloud: () -> Unit = {}, onDisconnectTrakt: () -> Unit = {} ) { - BackHandler(enabled = page != "MAIN") { + val backMotion = rememberArvioPredictiveBack(enabled = page != "MAIN") { onNavigate("MAIN") } - Column( + Box( modifier = Modifier .fillMaxSize() .background(appBackgroundDark()) ) { - if (page == "MAIN") { + Column( + modifier = Modifier + .fillMaxSize() + .arvioBackPeek(backMotion, active = page != "MAIN") + ) { Row( modifier = Modifier .fillMaxWidth() @@ -3582,57 +3608,70 @@ private fun MobileSettingsLayout( onSwitchProfile = onSwitchProfile, onNavigateToTelegram = onNavigateToTelegram ) - } else { - Row( + } + + AnimatedVisibility( + visible = page != "MAIN", + enter = fadeIn(tween(200)) + slideInHorizontally(tween(250)) { it / 6 }, + exit = fadeOut(tween(220, easing = FastOutSlowInEasing)) + slideOutHorizontally(tween(220, easing = FastOutSlowInEasing)) { it / 4 } + ) { + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically + .fillMaxSize() + .arvioBackSurface(backMotion) + .background(appBackgroundDark()) ) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = TextPrimary, + Row( modifier = Modifier - .clickable { onNavigate("MAIN") } - .padding(end = 16.dp) - .size(28.dp) - ) - Text( - text = mobileCategoryTitle(page), - style = ArflixTypography.heroTitle.copy(fontSize = 24.sp), - color = TextPrimary, - modifier = Modifier.weight(1f) + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = TextPrimary, + modifier = Modifier + .clickable { onNavigate("MAIN") } + .padding(end = 16.dp) + .size(28.dp) + ) + Text( + text = mobileCategoryTitle(page), + style = ArflixTypography.heroTitle.copy(fontSize = 24.sp), + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + } + MobileSettingsSubPage( + page = page, + onNavigate = onNavigate, + uiState = uiState, + viewModel = viewModel, + stremioAddons = stremioAddons, + onSwitchProfile = onSwitchProfile, + openDnsProviderPicker = openDnsProviderPicker, + openUiModeWarningDialog = openUiModeWarningDialog, + openQualityFiltersModal = openQualityFiltersModal, + onSubtitleAiModelClick = onSubtitleAiModelClick, + onSubtitleAiApiKeyClick = onSubtitleAiApiKeyClick, + onSubtitleAiQrClick = onSubtitleAiQrClick, + onAddIptvClick = onAddIptvClick, + onEditIptvClick = onEditIptvClick, + onAddCatalogClick = onAddCatalogClick, + onImportCatalogPackClick = onImportCatalogPackClick, + onRenameCatalogClick = onRenameCatalogClick, + onDeleteCatalogClick = onDeleteCatalogClick, + onConnectHomeServerClick = onConnectHomeServerClick, + onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, + onAddCustomAddonClick = onAddCustomAddonClick, + openCustomUserAgentDialog = openCustomUserAgentDialog, + onConnectTrakt = { viewModel.startTraktAuth() }, + onDisconnectTrakt = onDisconnectTrakt, + onConnectMdbList = viewModel::connectMdbList, + onDisconnectMdbList = { viewModel.disconnectMdbList() } ) } - MobileSettingsSubPage( - page = page, - onNavigate = onNavigate, - uiState = uiState, - viewModel = viewModel, - stremioAddons = stremioAddons, - onSwitchProfile = onSwitchProfile, - openDnsProviderPicker = openDnsProviderPicker, - openUiModeWarningDialog = openUiModeWarningDialog, - openQualityFiltersModal = openQualityFiltersModal, - onSubtitleAiModelClick = onSubtitleAiModelClick, - onSubtitleAiApiKeyClick = onSubtitleAiApiKeyClick, - onSubtitleAiQrClick = onSubtitleAiQrClick, - onAddIptvClick = onAddIptvClick, - onEditIptvClick = onEditIptvClick, - onAddCatalogClick = onAddCatalogClick, - onImportCatalogPackClick = onImportCatalogPackClick, - onRenameCatalogClick = onRenameCatalogClick, - onDeleteCatalogClick = onDeleteCatalogClick, - onConnectHomeServerClick = onConnectHomeServerClick, - onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, - onAddCustomAddonClick = onAddCustomAddonClick, - openCustomUserAgentDialog = openCustomUserAgentDialog, - onConnectTrakt = { viewModel.startTraktAuth() }, - onDisconnectTrakt = onDisconnectTrakt, - onConnectMdbList = viewModel::connectMdbList, - onDisconnectMdbList = { viewModel.disconnectMdbList() } - ) } } } @@ -4554,6 +4593,9 @@ private fun UnknownSourcesModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -8544,6 +8586,10 @@ private fun AccountDisconnectConfirmDialog( // Disconnect button, or the Box itself). This is intentional: it intercepts key // events BEFORE clickable children can handle them, preventing the Cancel button // from firing via its own clickable when the user presses OK/Enter to open the dialog. + BackHandler { + onDismiss() + } + Box( modifier = Modifier .fillMaxSize() @@ -9163,6 +9209,9 @@ private fun InputModalLegacy( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -9497,6 +9546,10 @@ private fun InputModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + hideKeyboardAll() + onDismiss() + } ModalScrim( onDismiss = { hideKeyboardAll() @@ -9936,6 +9989,9 @@ private fun SubtitlePickerModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -10060,6 +10116,9 @@ private fun UiModeWarningDialog( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -10415,6 +10474,9 @@ private fun CatalogPackImportDialog( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -10656,6 +10718,9 @@ private fun CatalogPackDeleteConfirmDialog( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt index 9117a2383..186989fb2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.settings.telegram +import androidx.activity.compose.BackHandler import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -592,6 +593,10 @@ private fun DisconnectConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + BackHandler { + onDismiss() + } + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index eb71488a7..af2802a96 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -332,13 +332,18 @@ fun TvScreen( } } - BackHandler(enabled = isMobile && isFullScreen) { - if (showFullscreenOverlay) { - showFullscreenOverlay = false - } else { - // Always return to EPG guide first, regardless of how we got here - isFullScreen = false - showFullscreenOverlay = false + BackHandler(enabled = showGroupContextMenu || isFullScreen || !isMobile) { + when { + showGroupContextMenu -> showGroupContextMenu = false + isFullScreen && showFullscreenOverlay -> showFullscreenOverlay = false + isFullScreen -> { + // Always return to EPG guide first, regardless of how we got here. + isFullScreen = false + showFullscreenOverlay = false + } + focusZone == TvFocusZone.GUIDE -> focusZone = TvFocusZone.GROUPS + focusZone == TvFocusZone.GROUPS -> focusZone = TvFocusZone.SIDEBAR + else -> onBack() } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index fd699d97a..c33c58646 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -176,6 +177,10 @@ fun CategorySidebar( ?.invoke() } + BackHandler(enabled = activeMenu != null) { + activeMenu = null + } + val categoriesLoaded = LiveTvStartup.searchIsReachable(tree.top.size) // Compose gives the initial D-pad focus to the first focusable row, which diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt index 74bd85f02..ab3ee84c2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/EpgGrid.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -281,6 +282,15 @@ fun EpgGrid( handledSelectedFocusSignal = focusSelectedChannelSignal } + BackHandler { + if (focusMode == EpgGridFocusMode.Epg) { + onExitEpg(selectedChannel) + runCatching { selectedChannelFocusRequester.requestFocus() } + } else { + onMoveLeftFromChannels() + } + } + var handledEpgFocusSignal by remember { mutableIntStateOf(0) } LaunchedEffect(focusEpgSignal, selectedChannelId, channelWindowIdentity, windowStartMillis) { if (focusEpgSignal == 0) return@LaunchedEffect diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt index d8fb703c7..5115602c3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvEnhancements.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -426,6 +427,10 @@ fun VariantPickerOverlay( LaunchedEffect(channel.id, variants) { runCatching { firstFocus.requestFocus() } } + BackHandler { + onDismiss() + } + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 9d238dbb7..ece1d9e61 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -2649,7 +2649,20 @@ fun LiveTvScreen( ) if (fsProgress > 0f && playingChannel != null) { val scale = 0.35f + 0.65f * fsProgress - Box( + BackHandler(enabled = isFullScreen) { + if (fullscreenGuideOpen) { + fullscreenGuideOpen = false + hudPokeSignal++ + } else if (!quickZapOpen) { + if (playingCatchupProgram != null) { + returnCatchupToLive() + } else { + exitFullScreenPlayback() + } + } + } + + Box( modifier = Modifier .fillMaxSize() .graphicsLayer { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt index 33d19b8e3..64dd86a21 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/QuickZapOverlay.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -135,6 +136,15 @@ fun QuickZapOverlay( } } + BackHandler(enabled = visible) { + if (categoryListFocused) { + onCategorySelected(originalCategoryId) + categoryListFocused = false + } else { + onDismiss() + } + } + AnimatedVisibility( visible = visible, enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 6 }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt index f3b426e32..83706abca 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/SearchOverlay.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.tv.live +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusable @@ -160,6 +161,10 @@ fun SearchOverlay( } } + BackHandler { + onDismiss() + } + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt index f8cf94a45..846b0a768 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.watchlist +import androidx.activity.compose.BackHandler import android.os.SystemClock import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -100,6 +101,10 @@ fun WatchlistScreen( } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl var isSidebarFocused by remember { mutableStateOf(false) } + + BackHandler { + if (isSidebarFocused) onBack() else isSidebarFocused = true + } val hasProfile = currentProfile != null val maxSidebarIndex = topBarMaxIndex(hasProfile) var sidebarFocusIndex by remember { mutableIntStateOf(if (hasProfile) 3 else 2) }