Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
android:name=".ArflixApplication"
android:allowBackup="false"
android:banner="@drawable/app_banner"
android:enableOnBackInvokedCallback="false"
android:enableOnBackInvokedCallback="true"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Finish migrating Key.Back before enabling predictive back

With this application-wide flag enabled on API 33+ / targetSdk 36, system Back is delivered through the OnBackInvoked/OnBackPressed path instead of the Compose key-event path, but a repo-wide check still shows screens and modals that only handle Key.Back in onKeyEvent/onPreviewKeyEvent and have no BackHandler in those files, e.g. app/src/main/kotlin/com/arflix/tv/ui/components/TextInputModal.kt:142 and app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt:130. In those contexts Back will no longer dismiss/navigate as intended and can fall through to the activity/root back behavior, so either keep this flag off or migrate the remaining Key.Back handlers to BackHandler before enabling it globally.

Useful? React with 👍 / 👎.

android:extractNativeLibs="false"
android:hardwareAccelerated="true"
android:largeHeap="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -119,6 +120,10 @@ fun AppUpdateModal(
focusRequester.requestFocus()
}

BackHandler {
onDismiss()
}

Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -73,6 +74,10 @@ fun AudioTrackSelector(
) {
var focusedIndex by remember(isVisible) { mutableIntStateOf(0) }

BackHandler(enabled = isVisible) {
onClose()
}

AnimatedVisibility(
visible = isVisible,
enter = fadeIn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -117,6 +118,10 @@ fun ContextMenu(
}
}

BackHandler(enabled = isVisible) {
onDismiss()
}

if (!isMobile) {
// --- TV layout: centered card with D-pad navigation ---
AnimatedVisibility(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -128,6 +129,10 @@ fun MediaContextMenu(
}
}

BackHandler(enabled = isVisible) {
onDismiss()
}

if (!isMobile) {
// --- TV layout: centered card with D-pad navigation ---
AnimatedVisibility(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -126,6 +127,10 @@ fun NextEpisodeOverlay(
}
}

BackHandler(enabled = isVisible) {
onCancel()
}

AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -127,6 +128,10 @@ fun PersonModal(
}
}

BackHandler(enabled = isVisible) {
onClose()
}

AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -70,6 +71,10 @@ fun QuickActionMenu(
}
}

BackHandler(enabled = isVisible) {
onDismiss()
}

AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + scaleIn(initialScale = 0.92f),
Expand Down
26 changes: 20 additions & 6 deletions app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand All @@ -399,7 +412,7 @@ fun StreamSelector(
} else actualKey

when (logicalKey) {
Key.Back, Key.Escape -> {
Key.Escape -> {
onClose()
true
}
Expand Down Expand Up @@ -691,6 +704,7 @@ fun StreamSelector(
}
}
}
}

@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +128,11 @@ fun TextInputModal(
}
}

BackHandler(enabled = isVisible) {
hideKeyboard()
onCancel()
}

AnimatedVisibility(
visible = isVisible,
enter = fadeIn(),
Expand Down
130 changes: 130 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ui/motion/ArvioPredictiveBack.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,10 @@ fun CollectionDetailsScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

BackHandler {
onBack()
}

Box(
modifier = Modifier
.fillMaxSize()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading