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(
modifier = modifier
.aspectRatio(0.7f)
.focusBorder()
.focusBorder(true)
.clickable(onClick = onClick)
) {
Image(
@@ -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,
@@ -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) },
@@ -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<AudioTrackInfo>,
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)
}
@@ -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++ }
)
)
}
}
}
}
@@ -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(