From 545698f325b236db972edfbe3848be42effed186 Mon Sep 17 00:00:00 2001 From: sonyx Date: Wed, 28 Oct 2026 21:56:58 -0300 Subject: [PATCH] feat: add optional TV rail hiding and navigation restoration --- .../lagradost/cloudstream3/MainActivity.kt | 40 +++++++++-- .../cloudstream3/ui/AutoHideNavigationRail.kt | 71 +++++++++++++++++++ .../cloudstream3/ui/RailNavigationPolicy.kt | 6 ++ .../ui/settings/SettingsGeneralScreen.kt | 15 ++++ .../main/res/values-es/navigation_options.xml | 7 ++ .../main/res/values/navigation_options.xml | 7 ++ app/src/main/res/xml/settings_general.xml | 4 ++ .../ui/RailNavigationPolicyTest.kt | 18 +++++ .../cloudstream4/DevicePreferenceStore.kt | 2 + 9 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/AutoHideNavigationRail.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/RailNavigationPolicy.kt create mode 100644 app/src/main/res/values-es/navigation_options.xml create mode 100644 app/src/main/res/values/navigation_options.xml create mode 100644 app/src/test/java/com/lagradost/cloudstream3/ui/RailNavigationPolicyTest.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index c13b2600c13..5fe2d9121c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -567,6 +567,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 } } + autoHideRail?.destinationChanged(isNavVisible && isLandscape()) /** * We need to make sure if we return to a sub-fragment, @@ -669,8 +670,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + private var autoHideRail: com.lagradost.cloudstream3.ui.AutoHideNavigationRail? = null + override fun dispatchKeyEvent(event: KeyEvent): Boolean = - CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) + if (autoHideRail?.dispatch(event, currentFocus) == true) true + else CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) @@ -732,10 +736,32 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } override fun onNewIntent(intent: Intent) { + resetHomeOnResume = shouldResetLauncherEntry(intent) handleAppIntent(intent) super.onNewIntent(intent) } + private var resetHomeOnResume = false + + private fun shouldResetLauncherEntry(entry: Intent?): Boolean { + if (entry?.action != Intent.ACTION_MAIN || entry.data != null) return false + if (!entry.hasCategory(Intent.CATEGORY_LAUNCHER) && + !entry.hasCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)) return false + return !PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("restore_navigation", false) + } + + override fun onPostResume() { + super.onPostResume() + if (!resetHomeOnResume) return + resetHomeOnResume = false + val host = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment + ?: return + host.navController.navigate(R.id.navigation_home, null, + NavOptions.Builder().setPopUpTo(host.navController.graph.id, false) + .setLaunchSingleTop(true).build()) + } + private fun handleAppIntent(intent: Intent?) { if (intent == null) return val str = intent.dataString @@ -757,7 +783,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val destinationId = item.itemId // Check if we are already at the selected destination - if (navController.currentDestination?.id == destinationId) return false + val restoreNavigation = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("restore_navigation", false) + if (navController.currentDestination?.id == destinationId && restoreNavigation) return false // Make all nav buttons focus on this specific view when nextFocusRightId val targetView = when (destinationId) { @@ -788,7 +816,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true) + val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(restoreNavigation) .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) .setPopEnterAnim(R.anim.pop_enter) @@ -797,7 +825,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa builder.setPopUpTo( navController.graph.findStartDestination().id, inclusive = false, - saveState = true + saveState = restoreNavigation ) } return try { @@ -1183,6 +1211,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } override fun onCreate(savedInstanceState: Bundle?) { + resetHomeOnResume = savedInstanceState != null && lastNonConfigurationInstance == null && shouldResetLauncherEntry(intent) app.initClient(this, ignoreSSL = false) @OptIn(UnsafeSSL::class) insecureApp.initClient(this, ignoreSSL = true) @@ -1678,6 +1707,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHostFragment.navController + if (isLayout(TV or EMULATOR)) binding?.let { + autoHideRail = com.lagradost.cloudstream3.ui.AutoHideNavigationRail(it.root, it.navRailView, it.navHostFragment) + } navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> // Intercept search and add a query updateNavBar(navDestination) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutoHideNavigationRail.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/AutoHideNavigationRail.kt new file mode 100644 index 00000000000..ff024171daf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/AutoHideNavigationRail.kt @@ -0,0 +1,71 @@ +package com.lagradost.cloudstream3.ui + +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.navigationrail.NavigationRailView +import com.lagradost.cloudstream3.R +import java.lang.ref.WeakReference + +internal class AutoHideNavigationRail(private val root: View, private val rail: NavigationRailView, private val content: View) { + private var available = rail.visibility == View.VISIBLE + private var hidden = false + private var lastContent = WeakReference(null) + private val enabled get() = PreferenceManager.getDefaultSharedPreferences(root.context).getBoolean("auto_hide_navigation_rail", true) + init { + root.viewTreeObserver.addOnGlobalFocusChangeListener { _, focus -> + if (focus != null && !inside(focus, rail) && inside(focus, content)) { + lastContent = WeakReference(focus) + if (available && enabled && posterRow(focus) != null) show(false) + } + } + } + fun destinationChanged(visible: Boolean) { available = visible; if (!enabled) hidden = false; applyVisibility() } + fun dispatch(event: KeyEvent, focus: View?): Boolean { + if (!available || !enabled || event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return false + if (event.keyCode == KeyEvent.KEYCODE_MENU) { + if (hidden || !rail.hasFocus()) reveal() else restoreContent() + return true + } + if (event.keyCode == KeyEvent.KEYCODE_BACK && rail.hasFocus() && lastContent.get()?.isAttachedToWindow == true) { + restoreContent(); return true + } + val leadingKey = if (root.layoutDirection == View.LAYOUT_DIRECTION_RTL) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT + if (hidden && event.keyCode == leadingKey && focus != null) { + val row = posterRow(focus) ?: return false + val item = row.findContainingItemView(focus) ?: return false + val manager = row.layoutManager as? LinearLayoutManager ?: return false + val horizontal = manager.orientation == RecyclerView.HORIZONTAL + val span = (manager as? GridLayoutManager)?.spanCount ?: 1 + if (RailNavigationPolicy.isLeadingItem(row.getChildAdapterPosition(item), horizontal, span)) { reveal(); return true } + } + return false + } + private fun reveal() { show(true); (rail.findViewById(rail.selectedItemId) ?: rail).requestFocus() } + private fun restoreContent() { show(false); lastContent.get()?.takeIf { it.isAttachedToWindow }?.requestFocus() ?: content.requestFocus() } + private fun show(visible: Boolean) { hidden = !visible; applyVisibility() } + private fun applyVisibility() { + rail.visibility = if (!available || hidden) View.INVISIBLE else View.VISIBLE + val margin = if (available && !hidden) root.resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) else 0 + (content.layoutParams as? ViewGroup.MarginLayoutParams)?.let { if (it.marginStart != margin) { it.marginStart = margin; content.layoutParams = it } } + } + private fun posterRow(focus: View): RecyclerView? { + var node: View? = focus + var poster = false + while (node != null && node !== content) { + if (node is RecyclerView) return if (poster) node else null + poster = poster || ((node === focus || node.isFocusable || node.isClickable) && node.findViewById(R.id.imageView) != null) + node = node.parent as? View + } + return null + } + private fun inside(child: View, parent: View): Boolean { + var node: View? = child + while (node != null) { if (node === parent) return true; node = node.parent as? View } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/RailNavigationPolicy.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/RailNavigationPolicy.kt new file mode 100644 index 00000000000..3235ce5ad0d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/RailNavigationPolicy.kt @@ -0,0 +1,6 @@ +package com.lagradost.cloudstream3.ui + +internal object RailNavigationPolicy { + fun isLeadingItem(position: Int, horizontal: Boolean, spanCount: Int): Boolean = + position >= 0 && if (horizontal) position == 0 else position % spanCount.coerceAtLeast(1) == 0 +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt index 6aa94fa883c..08dc0dc70cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt @@ -114,6 +114,21 @@ object SettingsGeneralScreen : SearchableSettings { }.sortedBy { fromTagToLanguageName(it) ?: it } return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.navigation_options_title), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.general.restoreNavigation, + title = stringResource(R.string.restore_navigation_title), + subtitle = stringResource(R.string.restore_navigation_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.general.autoHideNavigationRail, + title = stringResource(R.string.auto_hide_rail_title), + subtitle = stringResource(R.string.auto_hide_rail_summary), + ), + ), + ), Preference.PreferenceGroup(title = stringResource(R.string.extension_language), preferenceItems = persistentListOf( Preference.PreferenceItem.ListPreference( preference = settings.general.locale, diff --git a/app/src/main/res/values-es/navigation_options.xml b/app/src/main/res/values-es/navigation_options.xml new file mode 100644 index 00000000000..cc0fb98e59a --- /dev/null +++ b/app/src/main/res/values-es/navigation_options.xml @@ -0,0 +1,7 @@ + + Navegación + Ocultar automáticamente el menú lateral en TV + Se oculta al recorrer carátulas. Vuelve desde el primer elemento o pulsa Opciones para mostrarlo. + Restaurar la pantalla anterior + Conserva la pantalla anterior al abrir la app y cambiar de sección. Desactivado: abre Inicio desde el lanzador y la raíz de cada sección. + diff --git a/app/src/main/res/values/navigation_options.xml b/app/src/main/res/values/navigation_options.xml new file mode 100644 index 00000000000..0853c223a1f --- /dev/null +++ b/app/src/main/res/values/navigation_options.xml @@ -0,0 +1,7 @@ + + Navigation + Automatically hide the TV sidebar + Hide while browsing posters. Return from the first item or press Menu to show it. + Restore previous screen + Keep the previous screen when opening the app and switching sections. When off, launcher entry opens Home and sections open their root screen. + diff --git a/app/src/main/res/xml/settings_general.xml b/app/src/main/res/xml/settings_general.xml index f8b9b4f452f..4255c3597d7 100644 --- a/app/src/main/res/xml/settings_general.xml +++ b/app/src/main/res/xml/settings_general.xml @@ -1,6 +1,10 @@ + + + +