From d9302caa289671b21264f16398c24eff9f37a84b Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 19 Aug 2026 19:59:38 +0300 Subject: [PATCH] rewrite focus logic in player screen --- .../shared/presentation/list/VideoScreen.kt | 2 +- .../presentation/player/PlayerScreen.kt | 3 +- .../chat/presentation/ChatScreen.kt | 4 +- .../videoplayer/ui/DefaultControls.kt | 107 +++++------------- .../videoplayer/ui/VideoPlayer.kt | 25 +++- .../ui/theme/ComponentDefaults.kt | 2 +- 6 files changed, 52 insertions(+), 91 deletions(-) diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt index 76c8ef9..f8dc9cc 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt @@ -171,7 +171,7 @@ private fun Poster( Box( modifier = modifier .aspectRatio(0.7f) - .focusBorder() + .focusBorder(true) .clickable(onClick = onClick) ) { Image( diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerScreen.kt index 3dec711..117aac8 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerScreen.kt @@ -31,6 +31,7 @@ import org.jetbrains.compose.resources.stringResource import ru.shadowsparky.ui.AppScaffold import ru.shadowsparky.ui.components.ErrorContent import ru.shadowsparky.ui.components.Loading +import ru.shadowsparky.ui.theme.focusBorder import ru.shadowsparky.videoplayer.PlayerEvent import ru.shadowsparky.videoplayer.ui.VideoPlayer @@ -107,7 +108,7 @@ private fun PlayerActionOverlay( color = MaterialTheme.colorScheme.primaryContainer, tonalElevation = 0.dp, shadowElevation = 0.dp, - modifier = Modifier.padding(end = 20.dp).alpha(0.6f) + modifier = Modifier.padding(end = 20.dp).alpha(0.6f).focusBorder() ) { Text( text = text, diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt index 704df6f..7d3df7c 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt @@ -280,7 +280,7 @@ private fun MessageBubble( var boxModifier = Modifier.widthIn(max = 280.dp) if (isTv) { boxModifier = boxModifier.focusRequester(itemFocusRequester) - .focusBorder(false) + .focusBorder() .onKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { @@ -356,7 +356,7 @@ private fun TextDialog( } else { LazyColumn { itemsIndexed(links) { index, item -> - val mod = Modifier.focusBorder(false) + val mod = Modifier.focusBorder() TextPreference( text = item.text, onClick = { onClickLink(item.text) }, diff --git a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt index 4e33cda..27561ec 100644 --- a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt +++ b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.DropdownMenu @@ -49,14 +48,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import ru.shadowsparky.ui.components.BackButton @@ -67,7 +60,6 @@ import ru.shadowsparky.videoplayer.AudioTrackInfo import ru.shadowsparky.videoplayer.ControlsState import ru.shadowsparky.videoplayer.PlayerController import ru.shadowsparky.videoplayer.PlayerState -import kotlin.time.Duration.Companion.milliseconds private const val DEF_SEEK_TIME = 10L @@ -77,6 +69,7 @@ fun DefaultControls( controlsState: ControlsState, controller: PlayerController, modifier: Modifier = Modifier, + requestInteraction: () -> Unit, onBack: () -> Unit ) { val isTv = isTv() @@ -90,11 +83,8 @@ fun DefaultControls( val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat() val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f) - val focus = remember { FocusContainer() } - Box( - modifier = modifier - .fillMaxSize() + modifier = modifier.fillMaxSize() .background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f)) .safeDrawingPadding() ) { @@ -105,19 +95,19 @@ fun DefaultControls( Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) { if (audioTracksState.isNotEmpty() && !isUiLocked.value) { AudioTracksButton( - focus, tracks = audioTracksState, onSelect = { id -> scope.launch { controller.selectAudioTrack(id) } - } + }, + requestInteraction = requestInteraction ) } if (!isTv) { - LockUiButton(focus, isUiLocked.value) { isUiLocked.value = it } + LockUiButton(isUiLocked.value) { isUiLocked.value = it } } } if (!isUiLocked.value) { - CenterControls(focus, state, controller) + CenterControls(state, controller) Column( modifier = Modifier .align(Alignment.BottomCenter) @@ -153,26 +143,7 @@ fun DefaultControls( controller.seekTo(sliderPosition.toLong()) } }, - modifier = Modifier.fillMaxWidth() - .focusableWithBorder(focus.slider) - .onPreviewKeyEvent { - if (it.type == KeyEventType.KeyDown) { - when (it.key) { - Key.DirectionLeft -> { - scope.launch { controller.seek(true) } - } - - Key.DirectionRight -> { - scope.launch { controller.seek(false) } - } - - Key.DirectionUp -> focus.play.requestFocus() - Key.DirectionDown -> focus.audioTrack.requestFocus() - else -> return@onPreviewKeyEvent false - } - } - true - } + modifier = Modifier.fillMaxWidth().focusBorder() ) } } @@ -181,14 +152,10 @@ fun DefaultControls( @Composable private fun LockUiButton( - focusContainer: FocusContainer, state: Boolean, onChanged: (Boolean) -> Unit ) { - IconButton( - onClick = { onChanged(!state) }, - modifier = Modifier.focusableWithBorder(focusContainer.lock) - ) { + IconButton(onClick = { onChanged(!state) }) { val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px Icon(painterResource(icon), null, tint = Color.White) } @@ -196,39 +163,34 @@ private fun LockUiButton( @Composable private fun AudioTracksButton( - focusContainer: FocusContainer, tracks: List, onSelect: (String) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + requestInteraction: () -> Unit ) { var expanded by remember { mutableStateOf(false) } + val requester = remember { FocusRequester() } Box(modifier = modifier) { - val tracks = tracks.map { track -> track to FocusRequester() } - IconButton( - onClick = { expanded = true }, - modifier = Modifier.focusableWithBorder(focusContainer.audioTrack) - ) { + IconButton(onClick = { expanded = true }) { Icon( painter = painterResource(Res.drawable.music_note_24px), contentDescription = "Audio tracks", tint = Color.White ) } - LaunchedEffect(expanded) { - if (expanded) { - delay(500.milliseconds) - tracks.firstOrNull()?.second?.requestFocus() - } - } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.focusable() ) { - for (i in tracks.indices) { - val (track, requester) = tracks[i] + tracks.forEachIndexed { index, track -> DropdownMenuItem( + modifier = if (index == 0) { + Modifier.focusRequester(requester) + } else { + Modifier + }.onFocusChanged { requestInteraction() }, text = { Text( text = if (track.isSelected) @@ -240,11 +202,16 @@ private fun AudioTracksButton( onClick = { expanded = false onSelect(track.id) - }, - modifier = Modifier.focusableWithBorder(requester) + } ) } } + LaunchedEffect(expanded) { + if (expanded) { + withFrameNanos {} + requester.requestFocus() + } + } } } @@ -286,7 +253,6 @@ private fun BoxScope.SideDoubleTapSeekZones(controller: PlayerController, enable @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun BoxScope.CenterControls( - focusContainer: FocusContainer, state: PlayerState, controller: PlayerController ) { @@ -328,7 +294,7 @@ private fun BoxScope.CenterControls( scope.launch { if (state.isPlaying) controller.pause() else controller.play() } - }, modifier = Modifier.focusableWithBorder(focusContainer.play) + } ) { Icon( painter = painterResource( @@ -348,12 +314,6 @@ private fun BoxScope.CenterControls( } } LaunchedEffect(state.videoUrl) { canRequestFocus = false } - LaunchedEffect(canRequestFocus) { - if (canRequestFocus) { - withFrameNanos {} - focusContainer.play.requestFocus() - } - } } private suspend fun PlayerController.seek(minus: Boolean) { @@ -386,18 +346,3 @@ private fun formatDuration(millis: Long): String { return "$h:$m:$s" } - -private class FocusContainer( - val audioTrack: FocusRequester = FocusRequester(), - val lock: FocusRequester = FocusRequester(), - val play: FocusRequester = FocusRequester(), - val slider: FocusRequester = FocusRequester() -) - -@Composable -private fun Modifier.focusableWithBorder(requester: FocusRequester): Modifier { - var isFocused by remember { mutableStateOf(false) } - return this.focusRequester(requester) - .onFocusChanged { isFocused = it.isFocused } - .focusBorder(false, CircleShape) -} diff --git a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt index 21c6503..438b22a 100644 --- a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt +++ b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt @@ -47,17 +47,24 @@ import ru.shadowsparky.videoplayer.PlayerEvent import ru.shadowsparky.videoplayer.PlayerState import kotlin.time.Duration.Companion.seconds +class OverlayHolder( + val playerState: PlayerState, + val controlsState: ControlsState, + val requestInteraction: () -> Unit +) + @Composable fun VideoPlayer( controller: PlayerController, modifier: Modifier = Modifier, onBack: () -> Unit, - overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls -> + overlayContent: @Composable (OverlayHolder) -> Unit = { DefaultControls( - player, - controls, + it.playerState, + it.controlsState, controller = controller, - onBack = onBack + onBack = onBack, + requestInteraction = it.requestInteraction ) } ) { @@ -135,7 +142,15 @@ fun VideoPlayer( enter = fadeIn(), exit = fadeOut(), modifier = Modifier.matchParentSize() - ) { overlayContent(state, controlsState) } + ) { + overlayContent( + OverlayHolder( + state, + controlsState, + requestInteraction = { interactionCount++ } + ) + ) + } } } } diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt index 7d6a61a..37a5dfd 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt @@ -66,7 +66,7 @@ object ComponentDefaults { @Composable fun Modifier.focusBorder( - needScale: Boolean = true, + needScale: Boolean = false, shape: Shape = RoundedCornerShape(12.dp) ): Modifier { return ComponentDefaults.focusBorder(