From fb3db30708264ecd2638f13d72f50828d9dfeecb Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 31 Aug 2026 00:16:07 -0400 Subject: [PATCH 1/3] feat: keyboard arrow navigation with visual focus rings Add keyboard arrow navigation support for desktop using AWT KeyEventDispatcher, with visual focus rings (border) on all focusable UI elements. New files: - PlatformKeyboardNavigation.kt (expect/actual) - PlatformKeyboardNavigation.desktop.kt (AWT KeyEventDispatcher) - PlatformKeyboardNavigation.android.kt / .ios.kt (no-op) - NuvioFocusable.kt (Modifier.nuvioFocusBorder) Modified files: - App.kt: Register PlatformKeyboardNavigation in AppEnvironment - NavigationBar.kt: Focus border on all NavItem variants - ShelfComponents.kt: Focus border on NuvioPosterCard - ProfileSelectionScreen.kt: Focus border on ProfileAvatarCard and AddProfileCard - SettingsComponents.kt: Focus border on SettingsSidebarItem, SettingsNavigationRow, SettingsSwitchRow --- .../ui/PlatformKeyboardNavigation.android.kt | 8 +++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 2 ++ .../com/nuvio/app/core/ui/NavigationBar.kt | 12 +++++-- .../com/nuvio/app/core/ui/NuvioFocusable.kt | 33 +++++++++++++++++++ .../app/core/ui/PlatformKeyboardNavigation.kt | 6 ++++ .../com/nuvio/app/core/ui/ShelfComponents.kt | 1 + .../profiles/ProfileSelectionScreen.kt | 9 +++-- .../features/settings/SettingsComponents.kt | 7 +++- .../ui/PlatformKeyboardNavigation.desktop.kt | 33 +++++++++++++++++++ .../core/ui/PlatformKeyboardNavigation.ios.kt | 8 +++++ 10 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.android.kt new file mode 100644 index 000000000..59e3994b2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.android.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformKeyboardNavigation() { + // No-op on Android — keyboard navigation is handled by the platform. +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index fd7e60ebf..92c3d661f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -16,6 +16,7 @@ import coil3.request.crossfade import coil3.svg.SvgDecoder import com.nuvio.app.core.ui.NativeProfileSwitcherController import com.nuvio.app.core.ui.NuvioTheme +import com.nuvio.app.core.ui.PlatformKeyboardNavigation import com.nuvio.app.core.ui.configurePlatformImageLoader import com.nuvio.app.core.ui.desktopUiScaleForWindow import com.nuvio.app.features.settings.ThemeSettingsRepository @@ -102,6 +103,7 @@ internal fun AppEnvironment(content: @Composable () -> Unit) { amoled = amoledEnabled, desktopUiScale = desktopUiScaleForWindow(maxWidth.value, maxHeight.value), ) { + PlatformKeyboardNavigation() content() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt index 1d405e2e0..5bbea52df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt @@ -266,11 +266,13 @@ private class NuvioNavigationBarScopeImpl( ) with(rowScope) { + val navItemShape = RoundedCornerShape(NuvioTokens.Radius.full) Column( modifier = modifier .weight(1f) - .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .clip(navItemShape) .background(selectedBgColor) + .nuvioFocusBorder(navItemShape) .selectable( selected = selected, enabled = true, @@ -315,11 +317,13 @@ private class NuvioNavigationBarScopeImpl( ) with(rowScope) { + val navItemShape = RoundedCornerShape(NuvioTokens.Radius.full) Column( modifier = modifier .weight(1f) - .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .clip(navItemShape) .background(selectedBgColor) + .nuvioFocusBorder(navItemShape) .selectable( selected = selected, enabled = true, @@ -362,11 +366,13 @@ private class NuvioNavigationBarScopeImpl( ) with(rowScope) { + val navItemShape = RoundedCornerShape(NuvioTokens.Radius.full) Column( modifier = modifier .weight(1f) - .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .clip(navItemShape) .background(selectedBgColor) + .nuvioFocusBorder(navItemShape) .selectable( selected = selected, enabled = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt new file mode 100644 index 000000000..bee459e37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Modifier.nuvioFocusBorder( + shape: Shape, + borderWidth: Dp = 3.dp, +): Modifier = composed { + var focused by remember { mutableStateOf(false) } + val tokens = MaterialTheme.nuvio + this + .onFocusChanged { focused = it.isFocused } + .focusable() + .then( + if (focused) Modifier.border( + width = borderWidth, + color = tokens.colors.focusRing, + shape = shape, + ) else Modifier, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.kt new file mode 100644 index 000000000..e4140edbd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable + +@Composable +expect fun PlatformKeyboardNavigation() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index 62ef72195..ee4a61e20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -270,6 +270,7 @@ fun NuvioPosterCard( shape = cardShape, surface = NuvioCardDepthSurface.Posters, ) + .nuvioFocusBorder(cardShape) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index ea6c243ce..8b248215d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.isDesktop import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.features.membership.CosmeticEntitlement import com.nuvio.app.features.settings.MemberBrandWordmark import kotlinx.coroutines.delay @@ -383,6 +384,7 @@ private fun ProfileAvatarCard( } } + val profileShape = RoundedCornerShape(20.dp) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -393,7 +395,7 @@ private fun ProfileAvatarCard( scaleY = animScale.value * pressScale translationY = animOffset.value } - .clip(RoundedCornerShape(20.dp)) + .clip(profileShape) .then( if (isDesktop) { Modifier.hoverable(interactionSource) @@ -401,6 +403,7 @@ private fun ProfileAvatarCard( Modifier }, ) + .nuvioFocusBorder(profileShape) .clickable( enabled = enabled, interactionSource = interactionSource, @@ -540,6 +543,7 @@ private fun AddProfileCard( val isPressed by interactionSource.collectIsPressedAsState() val pressScale = if (isPressed) 0.95f else 1f + val addProfileShape = RoundedCornerShape(20.dp) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -550,7 +554,8 @@ private fun AddProfileCard( scaleY = animScale.value * pressScale translationY = animOffset.value } - .clip(RoundedCornerShape(20.dp)) + .clip(addProfileShape) + .nuvioFocusBorder(addProfileShape) .clickable( enabled = enabled, interactionSource = interactionSource, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index ef2e17d6c..21c8542f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.nuvio import com.nuvio.app.core.ui.nuvioConsumePointerEvents +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.features.home.HomeCatalogSettingsItem import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon @@ -165,11 +166,13 @@ internal fun SettingsSidebarItem( val iconChip = if (selected) primary.copy(alpha = tokens.opacity.selected) else Color.Transparent val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted + val sidebarItemShape = RoundedCornerShape(NuvioTokens.Space.s10) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = tokens.spacing.listGap, vertical = NuvioTokens.Space.s2) - .background(background, RoundedCornerShape(NuvioTokens.Space.s10)) + .background(background, sidebarItemShape) + .nuvioFocusBorder(sidebarItemShape) .clickable(onClick = onClick) .padding(horizontal = tokens.spacing.screenHorizontal, vertical = tokens.spacing.listGap), verticalAlignment = Alignment.CenterVertically, @@ -246,6 +249,7 @@ internal fun SettingsNavigationRow( Row( modifier = Modifier .fillMaxWidth() + .nuvioFocusBorder(RoundedCornerShape(NuvioTokens.Space.s4)) .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = horizontalPadding, vertical = verticalPadding) .alpha(if (enabled) NuvioTokens.Opacity.visible else tokens.opacity.medium), @@ -326,6 +330,7 @@ internal fun SettingsSwitchRow( Row( modifier = Modifier .fillMaxWidth() + .nuvioFocusBorder(RoundedCornerShape(NuvioTokens.Space.s4)) .clickable(enabled = enabled) { onCheckedChange(!checked) } .padding(horizontal = horizontalPadding, vertical = verticalPadding), horizontalArrangement = Arrangement.Start, diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt new file mode 100644 index 000000000..2c75ebb68 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import java.awt.KeyEventDispatcher +import java.awt.KeyboardFocusManager +import java.awt.event.KeyEvent + +@Composable +actual fun PlatformKeyboardNavigation() { + val focusManager = LocalFocusManager.current + + DisposableEffect(Unit) { + val dispatcher = KeyEventDispatcher { event: KeyEvent -> + if (event.id != KeyEvent.KEY_PRESSED) return@KeyEventDispatcher false + when (event.keyCode) { + KeyEvent.VK_LEFT -> { focusManager.moveFocus(FocusDirection.Left); true } + KeyEvent.VK_RIGHT -> { focusManager.moveFocus(FocusDirection.Right); true } + KeyEvent.VK_UP -> { focusManager.moveFocus(FocusDirection.Up); true } + KeyEvent.VK_DOWN -> { focusManager.moveFocus(FocusDirection.Down); true } + else -> false + } + } + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .addKeyEventDispatcher(dispatcher) + onDispose { + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .removeKeyEventDispatcher(dispatcher) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.ios.kt new file mode 100644 index 000000000..9e44d1a48 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.ios.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformKeyboardNavigation() { + // No-op on iOS — keyboard navigation is handled by the platform. +} From ac75ddfbb26b90e432bbf1fc1b68c6e7c9a015fa Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 6 Sep 2026 23:31:45 -0400 Subject: [PATCH 2/3] fix: smart arrow key consumption + focus rings on detail/stream components - PlatformKeyboardNavigation: skip consuming arrows when focused on JTextField, JTextArea, JSpinner, JSlider, JScrollPane, Scrollable - NuvioFocusable: guard .focusable() with isDesktop (desktop-only) - DetailActionButtons: nuvioFocusBorder on Play and More buttons - DetailSeriesContent: nuvioFocusBorder on season chips, season posters, episode horizontal cards, episode list cards - DetailCastSection: nuvioFocusBorder on cast avatar circles - StreamCard: nuvioFocusBorder on stream link cards - StreamsScreen: nuvioFocusBorder on provider filter chips --- .gitignore | 3 ++ .../com/nuvio/app/core/ui/NuvioFocusable.kt | 3 +- .../details/components/DetailActionButtons.kt | 8 +++-- .../details/components/DetailCastSection.kt | 4 ++- .../details/components/DetailSeriesContent.kt | 8 ++++- .../nuvio/app/features/streams/StreamCard.kt | 2 ++ .../app/features/streams/StreamsScreen.kt | 5 ++- .../ui/PlatformKeyboardNavigation.desktop.kt | 35 ++++++++++++++++--- 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 16890be1f..b6e57c533 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ scripts # Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs) .mpvkit-build.sparseimage + +composeApp/hs_err_pid*.log +hs_err_pid*.log diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt index bee459e37..ce8229ea7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.isDesktop fun Modifier.nuvioFocusBorder( shape: Shape, @@ -22,7 +23,7 @@ fun Modifier.nuvioFocusBorder( val tokens = MaterialTheme.nuvio this .onFocusChanged { focused = it.isFocused } - .focusable() + .then(if (isDesktop) Modifier.focusable() else Modifier) .then( if (focused) Modifier.border( width = borderWidth, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index d0e45ebec..3a639a053 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.appIconPainter +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.core.ui.secondaryClick import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_play @@ -94,7 +95,8 @@ fun DetailActionButtons( Surface( modifier = Modifier .weight(1f) - .height(buttonHeight), + .height(buttonHeight) + .nuvioFocusBorder(playShape), shape = playShape, color = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.background, @@ -175,7 +177,9 @@ fun DetailActionButtons( if (hasSecondaryActions) { Surface( - modifier = Modifier.size(iconButtonSize), + modifier = Modifier + .size(iconButtonSize) + .nuvioFocusBorder(CircleShape), shape = CircleShape, color = if (actionsExpanded) { MaterialTheme.colorScheme.onBackground diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 4a8cc86a8..50b0bc584 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -37,6 +37,7 @@ import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.nuvioDesktopDragScroll @@ -174,7 +175,8 @@ private fun CastItem( .nuvioCardDepth( shape = CircleShape, surface = NuvioCardDepthSurface.Cast, - ), + ) + .nuvioFocusBorder(CircleShape), contentAlignment = Alignment.Center, ) { if (person.photo != null) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 27577b196..2cfa53ab1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -69,6 +69,7 @@ import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.nuvioDesktopDragScroll +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.secondaryClick @@ -527,9 +528,10 @@ private fun SeasonTextChipScrollRow( items(seasons, key = { season -> season }) { season -> val isSelected = season == currentSeason val onSecondaryClick = onLongPress?.let { handler -> { handler(season) } } + val chipShape = RoundedCornerShape(sizing.seasonChipRadius) Box( modifier = Modifier - .clip(RoundedCornerShape(sizing.seasonChipRadius)) + .clip(chipShape) .background( if (isSelected) { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) @@ -537,6 +539,7 @@ private fun SeasonTextChipScrollRow( Color.Transparent }, ) + .nuvioFocusBorder(chipShape) .combinedClickable( onClick = { onSelect(season) }, onLongClick = onSecondaryClick, @@ -650,6 +653,7 @@ private fun SeasonPosterButton( .height(sizing.seasonPosterHeight) .clip(RoundedCornerShape(sizing.seasonPosterRadius)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .nuvioFocusBorder(RoundedCornerShape(sizing.seasonPosterRadius)) .border( width = if (isSelected) 2.dp else 1.dp, color = if (isSelected) { @@ -823,6 +827,7 @@ private fun EpisodeHorizontalCard( surface = NuvioCardDepthSurface.EpisodeCards, fallbackBorderAlpha = 0.12f, ) + .nuvioFocusBorder(cardShape) .posterCardClickable( onClick = onClick, onLongClick = onLongPress, @@ -1195,6 +1200,7 @@ private fun EpisodeListCard( color = Color.White.copy(alpha = 0.1f), shape = cardShape, ) + .nuvioFocusBorder(cardShape) .combinedClickable( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt index c84bc9f51..e95368344 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.secondaryClickAt import com.nuvio.app.core.ui.nuvioDesktopDragScroll +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.isDesktop @@ -99,6 +100,7 @@ internal fun StreamCard( Modifier }, ) + .nuvioFocusBorder(cardShape) .then( if (isDesktop) { Modifier.onGloballyPositioned { coordinates -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 0efd49bde..314e8c40e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -92,6 +92,7 @@ import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.nuvioDesktopDragScroll +import com.nuvio.app.core.ui.nuvioFocusBorder import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository @@ -883,6 +884,7 @@ private fun FilterChip( animationSpec = tween(durationMillis = 180), label = "filter_chip_content", ) + val chipShape = RoundedCornerShape(16.dp) Box( modifier = Modifier .graphicsLayer { @@ -890,8 +892,9 @@ private fun FilterChip( scaleY = scale } .height(36.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(chipShape) .background(containerColor) + .nuvioFocusBorder(chipShape) .clickable( interactionSource = interactionSource, indication = null, diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt index 2c75ebb68..713a44630 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt @@ -4,9 +4,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager +import java.awt.Component +import java.awt.Container import java.awt.KeyEventDispatcher import java.awt.KeyboardFocusManager +import java.awt.TextComponent import java.awt.event.KeyEvent +import javax.swing.JScrollPane +import javax.swing.JSlider +import javax.swing.JSpinner +import javax.swing.JTextArea +import javax.swing.JTextField +import javax.swing.Scrollable @Composable actual fun PlatformKeyboardNavigation() { @@ -15,13 +24,18 @@ actual fun PlatformKeyboardNavigation() { DisposableEffect(Unit) { val dispatcher = KeyEventDispatcher { event: KeyEvent -> if (event.id != KeyEvent.KEY_PRESSED) return@KeyEventDispatcher false + if (KeyboardFocusManager.getCurrentKeyboardFocusManager() + .focusOwner.isArrowKeyConsumer() + ) return@KeyEventDispatcher false + when (event.keyCode) { - KeyEvent.VK_LEFT -> { focusManager.moveFocus(FocusDirection.Left); true } - KeyEvent.VK_RIGHT -> { focusManager.moveFocus(FocusDirection.Right); true } - KeyEvent.VK_UP -> { focusManager.moveFocus(FocusDirection.Up); true } - KeyEvent.VK_DOWN -> { focusManager.moveFocus(FocusDirection.Down); true } - else -> false + KeyEvent.VK_LEFT -> focusManager.moveFocus(FocusDirection.Left) + KeyEvent.VK_RIGHT -> focusManager.moveFocus(FocusDirection.Right) + KeyEvent.VK_UP -> focusManager.moveFocus(FocusDirection.Up) + KeyEvent.VK_DOWN -> focusManager.moveFocus(FocusDirection.Down) + else -> return@KeyEventDispatcher false } + true } KeyboardFocusManager.getCurrentKeyboardFocusManager() .addKeyEventDispatcher(dispatcher) @@ -31,3 +45,14 @@ actual fun PlatformKeyboardNavigation() { } } } + +private fun Component?.isArrowKeyConsumer(): Boolean = when (this) { + is JTextField -> true + is JTextArea -> true + is TextComponent -> true + is JSpinner -> true + is JSlider -> true + is JScrollPane -> true + is Container -> this is Scrollable + else -> false +} From d71703c15e40208b387348b3bb7aac7a6bd481a1 Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 6 Sep 2026 23:39:54 -0400 Subject: [PATCH 3/3] fix: move nuvioFocusBorder before posterCardClickable on SeasonPosterButton The focus ring was on the inner Box but posterCardClickable on the Column was swallowing keyboard events. Move nuvioFocusBorder to the Column modifier chain ahead of posterCardClickable so focus works correctly. --- .../app/features/details/components/DetailSeriesContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 2cfa53ab1..b73e91a7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -644,6 +644,7 @@ private fun SeasonPosterButton( Column( modifier = Modifier .width(sizing.seasonPosterWidth) + .nuvioFocusBorder(RoundedCornerShape(sizing.seasonPosterRadius)) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -653,7 +654,6 @@ private fun SeasonPosterButton( .height(sizing.seasonPosterHeight) .clip(RoundedCornerShape(sizing.seasonPosterRadius)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .nuvioFocusBorder(RoundedCornerShape(sizing.seasonPosterRadius)) .border( width = if (isSelected) 2.dp else 1.dp, color = if (isSelected) {