rewrite focus logic in player screen

This commit is contained in:
2026-08-19 19:59:38 +03:00
parent a14a659876
commit d9302caa28
6 changed files with 52 additions and 91 deletions
@@ -171,7 +171,7 @@ private fun Poster(
Box( Box(
modifier = modifier modifier = modifier
.aspectRatio(0.7f) .aspectRatio(0.7f)
.focusBorder() .focusBorder(true)
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
Image( Image(
@@ -31,6 +31,7 @@ import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.AppScaffold import ru.shadowsparky.ui.AppScaffold
import ru.shadowsparky.ui.components.ErrorContent import ru.shadowsparky.ui.components.ErrorContent
import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.theme.focusBorder
import ru.shadowsparky.videoplayer.PlayerEvent import ru.shadowsparky.videoplayer.PlayerEvent
import ru.shadowsparky.videoplayer.ui.VideoPlayer import ru.shadowsparky.videoplayer.ui.VideoPlayer
@@ -107,7 +108,7 @@ private fun PlayerActionOverlay(
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = 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 = text, text = text,
@@ -280,7 +280,7 @@ private fun MessageBubble(
var boxModifier = Modifier.widthIn(max = 280.dp) var boxModifier = Modifier.widthIn(max = 280.dp)
if (isTv) { if (isTv) {
boxModifier = boxModifier.focusRequester(itemFocusRequester) boxModifier = boxModifier.focusRequester(itemFocusRequester)
.focusBorder(false) .focusBorder()
.onKeyEvent { .onKeyEvent {
if (it.type == KeyEventType.KeyDown) { if (it.type == KeyEventType.KeyDown) {
when (it.key) { when (it.key) {
@@ -356,7 +356,7 @@ private fun TextDialog(
} else { } else {
LazyColumn { LazyColumn {
itemsIndexed(links) { index, item -> itemsIndexed(links) { index, item ->
val mod = Modifier.focusBorder(false) val mod = Modifier.focusBorder()
TextPreference( TextPreference(
text = item.text, text = item.text,
onClick = { onClickLink(item.text) }, onClick = { onClickLink(item.text) },
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.DropdownMenu 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.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color 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.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.components.BackButton
@@ -67,7 +60,6 @@ import ru.shadowsparky.videoplayer.AudioTrackInfo
import ru.shadowsparky.videoplayer.ControlsState import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.PlayerState import ru.shadowsparky.videoplayer.PlayerState
import kotlin.time.Duration.Companion.milliseconds
private const val DEF_SEEK_TIME = 10L private const val DEF_SEEK_TIME = 10L
@@ -77,6 +69,7 @@ fun DefaultControls(
controlsState: ControlsState, controlsState: ControlsState,
controller: PlayerController, controller: PlayerController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
requestInteraction: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val isTv = isTv() val isTv = isTv()
@@ -90,11 +83,8 @@ fun DefaultControls(
val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat() val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat()
val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f) val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f)
val focus = remember { FocusContainer() }
Box( Box(
modifier = modifier modifier = modifier.fillMaxSize()
.fillMaxSize()
.background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f)) .background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f))
.safeDrawingPadding() .safeDrawingPadding()
) { ) {
@@ -105,19 +95,19 @@ fun DefaultControls(
Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) { Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) {
if (audioTracksState.isNotEmpty() && !isUiLocked.value) { if (audioTracksState.isNotEmpty() && !isUiLocked.value) {
AudioTracksButton( AudioTracksButton(
focus,
tracks = audioTracksState, tracks = audioTracksState,
onSelect = { id -> onSelect = { id ->
scope.launch { controller.selectAudioTrack(id) } scope.launch { controller.selectAudioTrack(id) }
} },
requestInteraction = requestInteraction
) )
} }
if (!isTv) { if (!isTv) {
LockUiButton(focus, isUiLocked.value) { isUiLocked.value = it } LockUiButton(isUiLocked.value) { isUiLocked.value = it }
} }
} }
if (!isUiLocked.value) { if (!isUiLocked.value) {
CenterControls(focus, state, controller) CenterControls(state, controller)
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -153,26 +143,7 @@ fun DefaultControls(
controller.seekTo(sliderPosition.toLong()) controller.seekTo(sliderPosition.toLong())
} }
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth().focusBorder()
.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
}
) )
} }
} }
@@ -181,14 +152,10 @@ fun DefaultControls(
@Composable @Composable
private fun LockUiButton( private fun LockUiButton(
focusContainer: FocusContainer,
state: Boolean, state: Boolean,
onChanged: (Boolean) -> Unit onChanged: (Boolean) -> Unit
) { ) {
IconButton( IconButton(onClick = { onChanged(!state) }) {
onClick = { onChanged(!state) },
modifier = Modifier.focusableWithBorder(focusContainer.lock)
) {
val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px
Icon(painterResource(icon), null, tint = Color.White) Icon(painterResource(icon), null, tint = Color.White)
} }
@@ -196,39 +163,34 @@ private fun LockUiButton(
@Composable @Composable
private fun AudioTracksButton( private fun AudioTracksButton(
focusContainer: FocusContainer,
tracks: List<AudioTrackInfo>, tracks: List<AudioTrackInfo>,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
requestInteraction: () -> Unit
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val requester = remember { FocusRequester() }
Box(modifier = modifier) { Box(modifier = modifier) {
val tracks = tracks.map { track -> track to FocusRequester() } IconButton(onClick = { expanded = true }) {
IconButton(
onClick = { expanded = true },
modifier = Modifier.focusableWithBorder(focusContainer.audioTrack)
) {
Icon( Icon(
painter = painterResource(Res.drawable.music_note_24px), painter = painterResource(Res.drawable.music_note_24px),
contentDescription = "Audio tracks", contentDescription = "Audio tracks",
tint = Color.White tint = Color.White
) )
} }
LaunchedEffect(expanded) {
if (expanded) {
delay(500.milliseconds)
tracks.firstOrNull()?.second?.requestFocus()
}
}
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
modifier = Modifier.focusable() modifier = Modifier.focusable()
) { ) {
for (i in tracks.indices) { tracks.forEachIndexed { index, track ->
val (track, requester) = tracks[i]
DropdownMenuItem( DropdownMenuItem(
modifier = if (index == 0) {
Modifier.focusRequester(requester)
} else {
Modifier
}.onFocusChanged { requestInteraction() },
text = { text = {
Text( Text(
text = if (track.isSelected) text = if (track.isSelected)
@@ -240,11 +202,16 @@ private fun AudioTracksButton(
onClick = { onClick = {
expanded = false expanded = false
onSelect(track.id) 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun BoxScope.CenterControls( private fun BoxScope.CenterControls(
focusContainer: FocusContainer,
state: PlayerState, state: PlayerState,
controller: PlayerController controller: PlayerController
) { ) {
@@ -328,7 +294,7 @@ private fun BoxScope.CenterControls(
scope.launch { scope.launch {
if (state.isPlaying) controller.pause() else controller.play() if (state.isPlaying) controller.pause() else controller.play()
} }
}, modifier = Modifier.focusableWithBorder(focusContainer.play) }
) { ) {
Icon( Icon(
painter = painterResource( painter = painterResource(
@@ -348,12 +314,6 @@ private fun BoxScope.CenterControls(
} }
} }
LaunchedEffect(state.videoUrl) { canRequestFocus = false } LaunchedEffect(state.videoUrl) { canRequestFocus = false }
LaunchedEffect(canRequestFocus) {
if (canRequestFocus) {
withFrameNanos {}
focusContainer.play.requestFocus()
}
}
} }
private suspend fun PlayerController.seek(minus: Boolean) { private suspend fun PlayerController.seek(minus: Boolean) {
@@ -386,18 +346,3 @@ private fun formatDuration(millis: Long): String {
return "$h:$m:$s" 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)
}
@@ -47,17 +47,24 @@ import ru.shadowsparky.videoplayer.PlayerEvent
import ru.shadowsparky.videoplayer.PlayerState import ru.shadowsparky.videoplayer.PlayerState
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class OverlayHolder(
val playerState: PlayerState,
val controlsState: ControlsState,
val requestInteraction: () -> Unit
)
@Composable @Composable
fun VideoPlayer( fun VideoPlayer(
controller: PlayerController, controller: PlayerController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBack: () -> Unit, onBack: () -> Unit,
overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls -> overlayContent: @Composable (OverlayHolder) -> Unit = {
DefaultControls( DefaultControls(
player, it.playerState,
controls, it.controlsState,
controller = controller, controller = controller,
onBack = onBack onBack = onBack,
requestInteraction = it.requestInteraction
) )
} }
) { ) {
@@ -135,7 +142,15 @@ fun VideoPlayer(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.matchParentSize() modifier = Modifier.matchParentSize()
) { overlayContent(state, controlsState) } ) {
overlayContent(
OverlayHolder(
state,
controlsState,
requestInteraction = { interactionCount++ }
)
)
}
} }
} }
} }
@@ -66,7 +66,7 @@ object ComponentDefaults {
@Composable @Composable
fun Modifier.focusBorder( fun Modifier.focusBorder(
needScale: Boolean = true, needScale: Boolean = false,
shape: Shape = RoundedCornerShape(12.dp) shape: Shape = RoundedCornerShape(12.dp)
): Modifier { ): Modifier {
return ComponentDefaults.focusBorder( return ComponentDefaults.focusBorder(