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/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..ce8229ea7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFocusable.kt @@ -0,0 +1,34 @@ +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 +import com.nuvio.app.isDesktop + +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 } + .then(if (isDesktop) Modifier.focusable() else Modifier) + .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/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..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 @@ -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, @@ -641,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), ) { @@ -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/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/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 new file mode 100644 index 000000000..713a44630 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PlatformKeyboardNavigation.desktop.kt @@ -0,0 +1,58 @@ +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.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() { + val focusManager = LocalFocusManager.current + + 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) + 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) + onDispose { + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .removeKeyEventDispatcher(dispatcher) + } + } +} + +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 +} 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. +}