From 5aa0c1b431ac9607a0de59fe917e178c4ad376e3 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 29 Jul 2026 17:55:28 +0530 Subject: [PATCH 1/4] feat(motion): add smooth predictive back to SettingsScreen and StreamSelector - Create ArvioPredictiveBack motion primitive (M3 surface scale 90%, edge-aware shift, touchY tilt, and instant release handling) - Apply ArvioBackSurface, ArvioBackPeek, and AnimatedVisibility exit transitions to SettingsScreen sub-pages - Apply ArvioBackModal and smooth fade/slide-down exit transitions to StreamSelector modal overlay --- .../arflix/tv/ui/components/StreamSelector.kt | 48 ++++--- .../tv/ui/motion/ArvioPredictiveBack.kt | 130 ++++++++++++++++++ .../tv/ui/screens/settings/SettingsScreen.kt | 113 +++++++++------ 3 files changed, 230 insertions(+), 61 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt 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 1b0bb850a..5e7285a32 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 @@ -77,9 +83,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.foundation.lazy.list.TvLazyColumn -import androidx.tv.foundation.lazy.list.TvLazyListState -import androidx.tv.foundation.lazy.list.rememberTvLazyListState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import com.arflix.tv.data.model.StreamSource @@ -113,7 +119,7 @@ private val AccentGold = Color.White * buffer on the approaching edge so its focus border isn't clipped by the * viewport or a scroll-indicator overlay. */ -private suspend fun TvLazyListState.scrollToKeepFocusVisible(focusedIndex: Int, itemCount: Int) { +private suspend fun LazyListState.scrollToKeepFocusVisible(focusedIndex: Int, itemCount: Int) { if (itemCount == 0) return val target = focusedIndex.coerceIn(0, itemCount - 1) val visibleItems = layoutInfo.visibleItemsInfo @@ -164,14 +170,16 @@ 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) } var focusedFilterIndex by remember { mutableIntStateOf(0) } var selectedFilterIndex by remember { mutableIntStateOf(0) } var focusZone by remember { mutableStateOf("streams") } // "streams" or "addons" - val listState = rememberTvLazyListState() - val addonListState = rememberTvLazyListState() + val listState = rememberLazyListState() + val addonListState = rememberLazyListState() val focusRequester = remember { FocusRequester() } val isMobile = LocalDeviceType.current.isTouchDevice() val pluginPrefix = stringResource(R.string.plugin_prefix) @@ -338,15 +346,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 @@ -359,7 +372,7 @@ fun StreamSelector( } else actualKey when (logicalKey) { - Key.Back, Key.Escape -> { + Key.Escape -> { onClose() true } @@ -650,6 +663,7 @@ fun StreamSelector( } } } +} @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -667,8 +681,8 @@ private fun OledSourceSelectorTv( selectedTabIndex: Int, focusedTabIndex: Int, addonRailFocused: Boolean, - listState: TvLazyListState, - addonListState: TvLazyListState, + listState: LazyListState, + addonListState: LazyListState, focusedIndex: Int, streamsFocused: Boolean, count4K: Int, @@ -810,7 +824,7 @@ private fun OledSourceSelectorTv( message = stringResource(R.string.stream_no_sources_match) ) else -> Box(modifier = Modifier.fillMaxSize()) { - TvLazyColumn( + LazyColumn( state = listState, contentPadding = PaddingValues(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(5.dp), @@ -1631,7 +1645,7 @@ private fun SourceAddonRail( selectedTabIndex: Int, focusedTabIndex: Int, isFocused: Boolean, - listState: TvLazyListState, + listState: LazyListState, totalSources: Int, count4K: Int, count1080: Int, @@ -1656,7 +1670,7 @@ private fun SourceAddonRail( ) Spacer(modifier = Modifier.height(10.dp)) Box(modifier = Modifier.weight(1f)) { - TvLazyColumn( + LazyColumn( state = listState, verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.fillMaxSize() 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..5d9e3e4e2 --- /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) + } + commit() + anim.animateTo(1f, tween(150, easing = ArvioStandardDecelerate)) + anim.snapTo(0f) + } 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 + val s = 0.95f + p * 0.05f + scaleX = s + scaleY = s + alpha = 0.5f + p * 0.5f +} + +/** 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/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 6e6197936..f16f954cd 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,13 @@ package com.arflix.tv.ui.screens.settings +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 @@ -3445,16 +3453,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() @@ -3492,52 +3504,65 @@ private fun MobileSettingsLayout( onNavigateToTelegram = onNavigateToTelegram, onDisconnectTrakt = onDisconnectTrakt ) - } 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, + 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 ) } - MobileSettingsSubPage( - page = page, - onNavigate = onNavigate, - uiState = uiState, - viewModel = viewModel, - stremioAddons = stremioAddons, - 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 - ) } } } From d65e5ffa547f6fdcf8d3c2c94c345cb6451c6e85 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 29 Jul 2026 18:00:43 +0530 Subject: [PATCH 2/4] fix: enable android:enableOnBackInvokedCallback=true for system predictive back gestures --- app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d70cb274a..5a7e4863e 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" From 5df9fb5c057cc139fbe756778d65902ff6b55c0d Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 29 Jul 2026 18:14:51 +0530 Subject: [PATCH 3/4] fix(motion): keep main Settings page at 100% scale on sub-page entry --- .../kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 5d9e3e4e2..67c6febd8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt @@ -113,10 +113,11 @@ fun Modifier.arvioBackSurface(motion: ArvioBackMotion): Modifier = graphicsLayer fun Modifier.arvioBackPeek(motion: ArvioBackMotion, active: Boolean): Modifier = graphicsLayer { if (!active) return@graphicsLayer val p = motion.eased - val s = 0.95f + p * 0.05f + if (p <= 0f) return@graphicsLayer + val s = 0.96f + p * 0.04f scaleX = s scaleY = s - alpha = 0.5f + p * 0.5f + alpha = 0.6f + p * 0.4f } /** Modal dismiss: shrink + fade in place. No positional shift — a modal closes, it doesn't traverse. */ From d50bb61b733c7650728fac0a119194797582191e Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 30 Jul 2026 22:31:52 +0530 Subject: [PATCH 4/4] feat(ui): migrate Key.Back to BackHandler across app for predictive back support --- .../arflix/tv/ui/components/AppUpdateModal.kt | 5 +++ .../tv/ui/components/AudioTrackSelector.kt | 5 +++ .../arflix/tv/ui/components/ContextMenu.kt | 5 +++ .../tv/ui/components/MediaContextMenu.kt | 5 +++ .../tv/ui/components/NextEpisodeOverlay.kt | 5 +++ .../arflix/tv/ui/components/PersonModal.kt | 5 +++ .../tv/ui/components/QuickActionMenu.kt | 5 +++ .../arflix/tv/ui/components/StreamSelector.kt | 22 +++++----- .../arflix/tv/ui/components/TextInputModal.kt | 6 +++ .../collections/CollectionDetailsScreen.kt | 4 ++ .../tv/ui/screens/details/DetailsScreen.kt | 9 +++++ .../arflix/tv/ui/screens/home/HomeScreen.kt | 14 +++++++ .../tv/ui/screens/player/PlayerScreen.kt | 18 +++++++++ .../tv/ui/screens/plugin/PluginScreen.kt | 5 +++ .../tv/ui/screens/search/SearchScreen.kt | 32 +++++++++++++++ .../tv/ui/screens/settings/SettingsScreen.kt | 40 +++++++++++++++++++ .../telegram/TelegramSettingsScreen.kt | 5 +++ .../tv/ui/screens/tv/live/CategorySidebar.kt | 5 +++ .../arflix/tv/ui/screens/tv/live/EpgGrid.kt | 10 +++++ .../ui/screens/tv/live/LiveTvEnhancements.kt | 5 +++ .../tv/ui/screens/tv/live/LiveTvScreen.kt | 15 ++++++- .../tv/ui/screens/tv/live/QuickZapOverlay.kt | 10 +++++ .../tv/ui/screens/tv/live/SearchOverlay.kt | 5 +++ .../ui/screens/watchlist/WatchlistScreen.kt | 5 +++ 24 files changed, 233 insertions(+), 12 deletions(-) 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 b5be9f00b..5af59f022 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 @@ -113,6 +114,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 5e7285a32..e5389052d 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 @@ -83,9 +83,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyListState +import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import com.arflix.tv.data.model.StreamSource @@ -119,7 +119,7 @@ private val AccentGold = Color.White * buffer on the approaching edge so its focus border isn't clipped by the * viewport or a scroll-indicator overlay. */ -private suspend fun LazyListState.scrollToKeepFocusVisible(focusedIndex: Int, itemCount: Int) { +private suspend fun TvLazyListState.scrollToKeepFocusVisible(focusedIndex: Int, itemCount: Int) { if (itemCount == 0) return val target = focusedIndex.coerceIn(0, itemCount - 1) val visibleItems = layoutInfo.visibleItemsInfo @@ -178,8 +178,8 @@ fun StreamSelector( var focusedFilterIndex by remember { mutableIntStateOf(0) } var selectedFilterIndex by remember { mutableIntStateOf(0) } var focusZone by remember { mutableStateOf("streams") } // "streams" or "addons" - val listState = rememberLazyListState() - val addonListState = rememberLazyListState() + val listState = rememberTvLazyListState() + val addonListState = rememberTvLazyListState() val focusRequester = remember { FocusRequester() } val isMobile = LocalDeviceType.current.isTouchDevice() val pluginPrefix = stringResource(R.string.plugin_prefix) @@ -681,8 +681,8 @@ private fun OledSourceSelectorTv( selectedTabIndex: Int, focusedTabIndex: Int, addonRailFocused: Boolean, - listState: LazyListState, - addonListState: LazyListState, + listState: TvLazyListState, + addonListState: TvLazyListState, focusedIndex: Int, streamsFocused: Boolean, count4K: Int, @@ -824,7 +824,7 @@ private fun OledSourceSelectorTv( message = stringResource(R.string.stream_no_sources_match) ) else -> Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( + TvLazyColumn( state = listState, contentPadding = PaddingValues(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(5.dp), @@ -1645,7 +1645,7 @@ private fun SourceAddonRail( selectedTabIndex: Int, focusedTabIndex: Int, isFocused: Boolean, - listState: LazyListState, + listState: TvLazyListState, totalSources: Int, count4K: Int, count1080: Int, @@ -1670,7 +1670,7 @@ private fun SourceAddonRail( ) Spacer(modifier = Modifier.height(10.dp)) Box(modifier = Modifier.weight(1f)) { - LazyColumn( + TvLazyColumn( state = listState, verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.fillMaxSize() 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/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 a6b44a639..bfd8465e8 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 @@ -2302,6 +2303,19 @@ private fun HomeInputLayer( } } + 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 focusedRowItemCount = focusedCategory?.items?.size ?: 0 LaunchedEffect(focusState.currentRowIndex, focusedRowItemCount) { 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 0ede4f2f6..77804ee47 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,24 @@ fun PlayerScreen( androidx.compose.runtime.CompositionLocalProvider( androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr ) { + BackHandler { + when { + showNextEpisodePrompt -> showNextEpisodePrompt = false + showSubtitleSettings -> { + showSubtitleSettings = false + showControls = true + } + showSubtitleMenu -> { + showSubtitleMenu = false + showControls = true + } + showSourceMenu -> showSourceMenu = false + uiState.error != null -> onBack() + showControls -> showControls = false + else -> onBack() + } + } + Box( modifier = Modifier .fillMaxSize() 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 cb56760c3..8792c646c 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 @@ -116,6 +117,10 @@ fun PluginScreen( } } + BackHandler { + onBackPressed() + } + Column( modifier = Modifier .padding(bottom = 80.dp) 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 f16f954cd..d8d37ad78 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,6 @@ 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 @@ -720,6 +721,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() @@ -4376,6 +4390,9 @@ private fun UnknownSourcesModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -8207,6 +8224,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() @@ -8524,6 +8545,9 @@ private fun InputModalLegacy( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -8858,6 +8882,10 @@ private fun InputModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + hideKeyboardAll() + onDismiss() + } ModalScrim( onDismiss = { hideKeyboardAll() @@ -9297,6 +9325,9 @@ private fun SubtitlePickerModal( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -9421,6 +9452,9 @@ private fun UiModeWarningDialog( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -9771,6 +9805,9 @@ private fun CatalogPackImportDialog( usePlatformDefaultWidth = false ) ) { + BackHandler { + onDismiss() + } ModalScrim(onDismiss = onDismiss) { Column( modifier = Modifier @@ -10012,6 +10049,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/live/CategorySidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/CategorySidebar.kt index 3c529b67d..6c8cca6ff 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 @@ -172,6 +173,10 @@ fun CategorySidebar( ?.invoke() } + BackHandler(enabled = activeMenu != null) { + activeMenu = null + } + LaunchedEffect(focusSearchSignal) { if (LiveTvStartup.shouldFocusSearch(focusSearchSignal)) { repeat(3) { 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 b5a5fe39a..166c3d65b 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 @@ -2573,7 +2573,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) }