diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
index f25c7a6c9..5d9a94a5e 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt
@@ -18,6 +18,8 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
+import android.view.View
+import android.view.ViewParent
import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
@@ -36,6 +38,8 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
import com.github.livingwithhippos.unchained.BuildConfig
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.data.model.UserAction
@@ -59,6 +63,7 @@ import com.github.livingwithhippos.unchained.utilities.SCHEME_MAGNET
import com.github.livingwithhippos.unchained.utilities.SIGNATURE
import com.github.livingwithhippos.unchained.utilities.TelemetryManager
import com.github.livingwithhippos.unchained.utilities.extension.downloadFileInStandardFolder
+import com.github.livingwithhippos.unchained.utilities.extension.isTv
import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
import com.github.livingwithhippos.unchained.utilities.extension.parcelable
import com.github.livingwithhippos.unchained.utilities.extension.showToast
@@ -221,6 +226,10 @@ class MainActivity : AppCompatActivity() {
setupBackPressedHandling()
+ setupTvBackFocusHandling()
+
+ setupTvInitialFocusHandling()
+
addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -938,6 +947,50 @@ class MainActivity : AppCompatActivity() {
}
}
+ /** On TV, a back press while focus is inside a long/endless list moves it to the bottom nav
+ * instead of navigating, since there is no other way to reach the nav bar from deep in a
+ * list (see issue #376). */
+ private fun setupTvBackFocusHandling() {
+ if (!isTv()) return
+
+ onBackPressedDispatcher.addCallback(this) {
+ val focusRescued =
+ currentFocus?.isInsideScrollableList() == true &&
+ binding.bottomNavView.requestFocus()
+ if (!focusRescued) {
+ // let the other callbacks or the default handling manage this back press
+ isEnabled = false
+ onBackPressedDispatcher.onBackPressed()
+ isEnabled = true
+ }
+ }
+ }
+
+ /** On TV, the toolbar's overflow button is first in layout order and silently grabs default
+ * focus on every new destination. Rescue focus onto the bottom nav when that happens, without
+ * touching focus a fragment or back navigation already placed deliberately. */
+ private fun setupTvInitialFocusHandling() {
+ if (!isTv()) return
+
+ navController.addOnDestinationChangedListener { _, _, _ ->
+ binding.root.post {
+ if (binding.appBarLayout.findFocus() != null) {
+ binding.bottomNavView.requestFocus()
+ }
+ }
+ }
+ }
+
+ /** True if inside a scrollable list, ignoring the RecyclerView ViewPager2 uses internally. */
+ private fun View.isInsideScrollableList(): Boolean {
+ var current: ViewParent? = parent
+ while (current != null) {
+ if (current is RecyclerView && current.parent !is ViewPager2) return true
+ current = current.parent
+ }
+ return false
+ }
+
private fun showUpdateDialog(description: String, link: String) {
// passing the baseContext or applicationContext cause a crash in the release version build
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
index 255b419dd..29dd9021b 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt
@@ -487,8 +487,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
// only in landscape view
viewModel.postEventNotice(ListEvent.NewDownload)
}
- binding.bRefresh?.setOnClickListener {
- // only in landscape view
+ binding.bRefresh.setOnClickListener {
if (!binding.srLayout.isRefreshing) {
binding.srLayout.isRefreshing = true
downloadAdapter.refresh()
@@ -739,7 +738,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
// landscape only
viewModel.postEventNotice(ListEvent.NewDownload)
}
- binding.bRefresh?.setOnClickListener {
+ binding.bRefresh.setOnClickListener {
if (!binding.srLayout.isRefreshing) {
binding.srLayout.isRefreshing = true
torrentAdapter.refresh()
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt
index a1996a082..bb29a08c2 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt
@@ -17,6 +17,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
+import com.google.android.material.color.MaterialColors
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
@@ -120,11 +121,24 @@ class CustomColorPickerDialog : BottomSheetDialogFragment() {
val marginPx = resources.getDimensionPixelSize(R.dimen.custom_color_swatch_margin)
return View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(sizePx, sizePx).apply { marginEnd = marginPx }
- background =
+ val swatchDrawable =
GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(color)
}
+ background = swatchDrawable
+ // plain Views are not reachable with a d-pad by default, so make the swatches
+ // focusable and show a ring around the focused one for TV and keyboard users
+ isFocusable = true
+ setOnFocusChangeListener { v, hasFocus ->
+ val strokeWidth =
+ if (hasFocus) v.resources.getDimensionPixelSize(R.dimen.focus_stroke_width)
+ else 0
+ swatchDrawable.setStroke(
+ strokeWidth,
+ MaterialColors.getColor(v, com.google.android.material.R.attr.colorOnSurface),
+ )
+ }
setOnClickListener { onClick() }
}
}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt
new file mode 100644
index 000000000..bc07e04bb
--- /dev/null
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt
@@ -0,0 +1,30 @@
+package com.github.livingwithhippos.unchained.utilities
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.core.widget.NestedScrollView
+
+/**
+ * A [NestedScrollView] that never takes d-pad focus itself.
+ *
+ * NestedScrollView calls setFocusable(true) in its constructor, overriding any android:focusable
+ * attribute set in the layout. On Android TV this makes the scroll container itself a focus
+ * target: it sits directly next to the toolbar and the navigation bar, so FocusFinder routes the
+ * d-pad onto it instead of the buttons inside, and since a focused scroll view has no visual
+ * focus state the screen looks like it cannot be navigated at all (see issue #376).
+ *
+ * Focused children still scroll into view through requestChildFocus, so scrolling with the d-pad
+ * keeps working as long as the content contains at least one focusable view.
+ */
+class NonFocusableNestedScrollView
+@JvmOverloads
+constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = androidx.core.R.attr.nestedScrollViewStyle,
+) : NestedScrollView(context, attrs, defStyleAttr) {
+
+ init {
+ isFocusable = false
+ }
+}
diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
index a4c8cda44..2ac8a545f 100644
--- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
+++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt
@@ -10,6 +10,7 @@ import android.content.ClipboardManager
import android.content.ContentResolver.SCHEME_CONTENT
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Configuration
import android.content.res.Resources
@@ -365,6 +366,9 @@ fun Uri.getFileName(context: Context): String {
return fileName
}
+/** True on Android TV, where several controls need d-pad-specific handling. */
+fun Context.isTv(): Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
+
/**
* Open an url from available apps
*
diff --git a/app/app/src/main/res/color/focus_fill.xml b/app/app/src/main/res/color/focus_fill.xml
new file mode 100644
index 000000000..6b145eae3
--- /dev/null
+++ b/app/app/src/main/res/color/focus_fill.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/app/src/main/res/color/focus_fill_on_container.xml b/app/app/src/main/res/color/focus_fill_on_container.xml
new file mode 100644
index 000000000..5f49278bc
--- /dev/null
+++ b/app/app/src/main/res/color/focus_fill_on_container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/app/src/main/res/color/focus_fill_on_primary.xml b/app/app/src/main/res/color/focus_fill_on_primary.xml
new file mode 100644
index 000000000..bffab042f
--- /dev/null
+++ b/app/app/src/main/res/color/focus_fill_on_primary.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/app/src/main/res/color/list_card_stroke.xml b/app/app/src/main/res/color/list_card_stroke.xml
new file mode 100644
index 000000000..0724c3582
--- /dev/null
+++ b/app/app/src/main/res/color/list_card_stroke.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/app/src/main/res/drawable/focus_ring_control.xml b/app/app/src/main/res/drawable/focus_ring_control.xml
new file mode 100644
index 000000000..cc4a3ff7e
--- /dev/null
+++ b/app/app/src/main/res/drawable/focus_ring_control.xml
@@ -0,0 +1,17 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/app/src/main/res/drawable/focus_ring_fab.xml b/app/app/src/main/res/drawable/focus_ring_fab.xml
new file mode 100644
index 000000000..142990855
--- /dev/null
+++ b/app/app/src/main/res/drawable/focus_ring_fab.xml
@@ -0,0 +1,20 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/app/src/main/res/drawable/focus_ring_pill.xml b/app/app/src/main/res/drawable/focus_ring_pill.xml
new file mode 100644
index 000000000..b2190e780
--- /dev/null
+++ b/app/app/src/main/res/drawable/focus_ring_pill.xml
@@ -0,0 +1,19 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/app/src/main/res/drawable/focus_ring_toolbar.xml b/app/app/src/main/res/drawable/focus_ring_toolbar.xml
new file mode 100644
index 000000000..b00c00ee4
--- /dev/null
+++ b/app/app/src/main/res/drawable/focus_ring_toolbar.xml
@@ -0,0 +1,19 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/app/src/main/res/layout-land/fragment_downloads_list.xml b/app/app/src/main/res/layout-land/fragment_downloads_list.xml
index 25e0b79d1..1effc80a1 100644
--- a/app/app/src/main/res/layout-land/fragment_downloads_list.xml
+++ b/app/app/src/main/res/layout-land/fragment_downloads_list.xml
@@ -26,6 +26,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
+ android:nextFocusRight="@id/cbSelectAll"
+ android:nextFocusDown="@id/rvDownloadList"
android:text="@string/add"
app:icon="@drawable/icon_add" />
@@ -101,6 +103,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
+ android:nextFocusUp="@id/cbSelectAll"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_list_download" />
diff --git a/app/app/src/main/res/layout-land/fragment_torrents_list.xml b/app/app/src/main/res/layout-land/fragment_torrents_list.xml
index 41d1a2a88..1151aee72 100644
--- a/app/app/src/main/res/layout-land/fragment_torrents_list.xml
+++ b/app/app/src/main/res/layout-land/fragment_torrents_list.xml
@@ -26,7 +26,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:nextFocusRight="@id/cbSelectAll"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusDown="@id/rvTorrentList"
android:text="@string/add"
app:icon="@drawable/icon_add" />
@@ -36,7 +36,7 @@
android:layout_height="wrap_content"
android:checked="false"
android:nextFocusRight="@id/bDeleteSelected"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusDown="@id/rvTorrentList"
android:text="0"
android:tooltipText="@string/select_deselect_all" />
@@ -47,7 +47,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/delete"
android:nextFocusRight="@id/bDownloadSelected"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/delete_selected"
app:icon="@drawable/icon_delete"
app:iconSize="25dp" />
@@ -59,7 +59,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/download"
android:nextFocusRight="@id/bDetailsSelected"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/download_selected"
app:icon="@drawable/icon_download"
app:iconSize="25dp" />
@@ -71,7 +71,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/torrent_details"
android:nextFocusRight="@id/bRefresh"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/torrent_details"
app:icon="@drawable/icon_info"
app:iconSize="25dp" />
@@ -82,8 +82,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/refresh"
- android:nextFocusRight="@id/rvDownloadList"
- android:nextFocusDown="@id/rvDownloadList"
+ android:nextFocusRight="@id/rvTorrentList"
+ android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/refresh"
app:icon="@drawable/icon_reload"
app:iconSize="25dp" />
@@ -104,6 +104,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
+ android:nextFocusUp="@id/cbSelectAll"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toBottomOf="@id/llMultiSelection"
diff --git a/app/app/src/main/res/layout/custom_chip_layout.xml b/app/app/src/main/res/layout/custom_chip_layout.xml
index 97da800b1..93429e95f 100644
--- a/app/app/src/main/res/layout/custom_chip_layout.xml
+++ b/app/app/src/main/res/layout/custom_chip_layout.xml
@@ -1,5 +1,5 @@
diff --git a/app/app/src/main/res/layout/dialog_manage_repository.xml b/app/app/src/main/res/layout/dialog_manage_repository.xml
index 0ee5c5aad..ac8ca78ce 100644
--- a/app/app/src/main/res/layout/dialog_manage_repository.xml
+++ b/app/app/src/main/res/layout/dialog_manage_repository.xml
@@ -8,7 +8,7 @@
android:layout_height="wrap_content"
tools:ignore="HardcodedText">
-
@@ -178,5 +178,5 @@
app:layout_constraintTop_toBottomOf="@id/bUninstallRepo" />
-
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/dialog_service_picker.xml b/app/app/src/main/res/layout/dialog_service_picker.xml
index c1d9faade..41ef8dd11 100644
--- a/app/app/src/main/res/layout/dialog_service_picker.xml
+++ b/app/app/src/main/res/layout/dialog_service_picker.xml
@@ -1,6 +1,6 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_complete_service.xml b/app/app/src/main/res/layout/fragment_complete_service.xml
index 9c7ce5ad7..a3f3d57eb 100644
--- a/app/app/src/main/res/layout/fragment_complete_service.xml
+++ b/app/app/src/main/res/layout/fragment_complete_service.xml
@@ -1,6 +1,6 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_download_details.xml b/app/app/src/main/res/layout/fragment_download_details.xml
index e249b9302..131497756 100644
--- a/app/app/src/main/res/layout/fragment_download_details.xml
+++ b/app/app/src/main/res/layout/fragment_download_details.xml
@@ -1,6 +1,6 @@
-
@@ -164,6 +165,7 @@
android:id="@+id/fabOpenLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/open_link"
app:srcCompat="@drawable/icon_open_external" />
@@ -189,6 +191,7 @@
android:id="@+id/fabCopyLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/copy_link"
app:srcCompat="@drawable/icon_copy" />
@@ -214,6 +217,7 @@
android:id="@+id/fabDownloadLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/download"
app:srcCompat="@drawable/icon_download" />
@@ -238,6 +242,7 @@
android:id="@+id/fabSendToPlayer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/send_to_player"
app:srcCompat="@drawable/icon_play_local" />
@@ -263,6 +268,7 @@
android:id="@+id/fabPickStreaming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/streaming"
app:srcCompat="@drawable/icon_remote" />
@@ -315,6 +321,7 @@
android:id="@+id/fabLoadStreams"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:focusable="true"
android:contentDescription="@string/load_streams"
app:srcCompat="@drawable/icon_transform" />
@@ -347,4 +354,4 @@
-
+
diff --git a/app/app/src/main/res/layout/fragment_downloads_list.xml b/app/app/src/main/res/layout/fragment_downloads_list.xml
index 540f3e5a5..ed531e83a 100644
--- a/app/app/src/main/res/layout/fragment_downloads_list.xml
+++ b/app/app/src/main/res/layout/fragment_downloads_list.xml
@@ -58,12 +58,24 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/share"
- android:nextFocusRight="@id/rvDownloadList"
+ android:nextFocusRight="@id/bRefresh"
android:nextFocusDown="@id/rvDownloadList"
android:tooltipText="@string/share_selected"
app:icon="@drawable/icon_share"
app:iconSize="25dp" />
+
+
diff --git a/app/app/src/main/res/layout/fragment_remote_service.xml b/app/app/src/main/res/layout/fragment_remote_service.xml
index 4520395fe..c8667a4b7 100644
--- a/app/app/src/main/res/layout/fragment_remote_service.xml
+++ b/app/app/src/main/res/layout/fragment_remote_service.xml
@@ -1,6 +1,6 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_search_plugins_tab.xml b/app/app/src/main/res/layout/fragment_search_plugins_tab.xml
index d628ab5e0..653f4d771 100644
--- a/app/app/src/main/res/layout/fragment_search_plugins_tab.xml
+++ b/app/app/src/main/res/layout/fragment_search_plugins_tab.xml
@@ -55,7 +55,7 @@