fix focus for android tv

This commit is contained in:
2026-06-23 19:53:43 +03:00
parent ebc2ed7dc9
commit 16a78e8f6f
9 changed files with 124 additions and 68 deletions
@@ -39,6 +39,7 @@ fun AppBarWithSearchWithBackPreview() {
@Composable @Composable
fun InternalAppBarPreview() { fun InternalAppBarPreview() {
SearchAppBar( SearchAppBar(
Modifier,
TextFieldValue(""), TextFieldValue(""),
FocusRequester(), FocusRequester(),
{}, {},
@@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,9 +33,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -142,6 +145,7 @@ fun VideosInfo(
emptyResource: StringResource, emptyResource: StringResource,
onClick: (item: VideoItem) -> Unit onClick: (item: VideoItem) -> Unit
) { ) {
val requester = remember { FocusRequester() }
val blur = LocalBlurState.current val blur = LocalBlurState.current
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
@@ -160,11 +164,19 @@ fun VideosInfo(
), ),
state = gridState state = gridState
) { ) {
items(items) { item -> itemsIndexed(
items,
key = { _, item -> item.id }
) { index, item ->
Poster( Poster(
imageUrl = item.poster, imageUrl = item.poster,
label = item.title, label = item.title,
onClick = { onClick(item) }, onClick = { onClick(item) },
modifier = if (index == 0) {
Modifier.focusRequester(requester)
} else {
Modifier
}
) )
} }
} }
@@ -172,6 +184,12 @@ fun VideosInfo(
VideosNotFound(emptyResource) VideosNotFound(emptyResource)
} }
} }
LaunchedEffect(items) {
if (items.isNotEmpty()) {
withFrameNanos {}
requester.requestFocus()
}
}
} }
@Composable @Composable
@@ -35,6 +35,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,9 +43,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -240,6 +244,7 @@ fun DetailsInfo(
paddingValues: PaddingValues, paddingValues: PaddingValues,
onWatchClicked: () -> Unit onWatchClicked: () -> Unit
) { ) {
val requester = remember { FocusRequester() }
val blur = LocalBlurState.current val blur = LocalBlurState.current
val showDetails = rememberSaveable { mutableStateOf(false) } val showDetails = rememberSaveable { mutableStateOf(false) }
LazyColumn( LazyColumn(
@@ -251,8 +256,12 @@ fun DetailsInfo(
item { item {
Button( Button(
onClick = onWatchClicked, onClick = onWatchClicked,
modifier = Modifier.fillMaxWidth().padding(8.dp) modifier = Modifier.fillMaxWidth().padding(8.dp).focusRequester(requester)
) { Text(stringResource(Res.string.watch)) } ) { Text(stringResource(Res.string.watch)) }
LaunchedEffect(null) {
withFrameNanos {}
requester.requestFocus()
}
} }
} }
} }
@@ -30,14 +30,18 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -183,26 +187,39 @@ fun EpisodesOverviewList(
ErrorAndRetry(errorMsg = errorCode) { loadLinks() } ErrorAndRetry(errorMsg = errorCode) { loadLinks() }
} else if (episodes != null) { } else if (episodes != null) {
val blur = LocalBlurState.current val blur = LocalBlurState.current
val requester = remember { FocusRequester() }
ExpressiveLazyColumn( ExpressiveLazyColumn(
modifier = Modifier.applyBlurSource(blur).fillMaxSize(), modifier = Modifier.applyBlurSource(blur).fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
content = { content = {
section { section {
episodes.mapIndexed { index, episode -> itemsIndexed(
item(key = episode.episode.episode) { episodes,
EpisodeInfo( { it.episode.episode }
onLaunch, ) { index, episode ->
episode, EpisodeInfo(
onClipboardAction, if (index == 0) {
index, Modifier.focusRequester(requester)
onOfflineClicked, } else {
isSaveMode Modifier
) },
} onLaunch,
episode,
onClipboardAction,
index,
onOfflineClicked,
isSaveMode
)
} }
} }
} }
) )
LaunchedEffect(episodes) {
if (episodes.isNotEmpty()) {
withFrameNanos {}
requester.requestFocus()
}
}
} else { } else {
Loading() Loading()
} }
@@ -211,6 +228,7 @@ fun EpisodesOverviewList(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun EpisodeInfo( private fun EpisodeInfo(
modifier: Modifier,
onLaunch: (Episode) -> Unit, onLaunch: (Episode) -> Unit,
ep: EpisodeWithOfflineInfo, ep: EpisodeWithOfflineInfo,
onClipboardAction: (Episode) -> Unit, onClipboardAction: (Episode) -> Unit,
@@ -219,8 +237,7 @@ private fun EpisodeInfo(
isSaveMode: Boolean isSaveMode: Boolean
) { ) {
Row( Row(
modifier = Modifier modifier = modifier.fillMaxWidth()
.fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onClick = {
if (isSaveMode) { if (isSaveMode) {
@@ -14,7 +14,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.LocalBlurState import ru.shadowsparky.ui.LocalBlurState
@@ -73,20 +77,35 @@ fun SeasonsInfo(
onClick: (Long) -> Unit onClick: (Long) -> Unit
) { ) {
val blur = LocalBlurState.current val blur = LocalBlurState.current
val requester = remember { FocusRequester() }
ExpressiveLazyColumn( ExpressiveLazyColumn(
Modifier.applyBlurSource(blur).fillMaxWidth(), Modifier.applyBlurSource(blur).fillMaxWidth(),
contentPadding = paddingValues, contentPadding = paddingValues,
content = { content = {
section { section {
items(seasons) { itemsIndexed(seasons) { index, season ->
SeasonInfo(it.title) { onClick(it.id) } SeasonInfo(
if (index == 0) {
Modifier.focusRequester(requester)
} else {
Modifier
},
season.title
) { onClick(season.id) }
} }
} }
} }
) )
LaunchedEffect(seasons) {
if (seasons.isNotEmpty()) {
withFrameNanos {}
requester.requestFocus()
}
}
} }
@Composable @Composable
fun SeasonInfo(titleText: String, onClick: () -> Unit) { fun SeasonInfo(modifier: Modifier, titleText: String, onClick: () -> Unit) {
Text(titleText, modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(16.dp)) Text(titleText, modifier = modifier.fillMaxWidth().clickable { onClick() }.padding(16.dp))
} }
@@ -92,6 +92,7 @@ fun SearchScreen(
BaseMainScreen( BaseMainScreen(
appBar = { appBar = {
SearchAppBar( SearchAppBar(
modifier = Modifier.fillMaxWidth(),
textFieldValue = textFieldValue, textFieldValue = textFieldValue,
onDone = { component.done(textFieldValue.text) }, onDone = { component.done(textFieldValue.text) },
onValueChange = { textFieldValue = it }, onValueChange = { textFieldValue = it },
@@ -213,6 +214,7 @@ expect fun Modifier.requestFocusOnDownEvent(focusRequester: FocusRequester): Mod
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
fun SearchAppBar( fun SearchAppBar(
modifier: Modifier,
textFieldValue: TextFieldValue, textFieldValue: TextFieldValue,
focusRequester: FocusRequester, focusRequester: FocusRequester,
onDone: () -> Unit, onDone: () -> Unit,
@@ -228,7 +230,7 @@ fun SearchAppBar(
} }
val blur = LocalBlurState.current val blur = LocalBlurState.current
AppBarWithSearch( AppBarWithSearch(
modifier = Modifier.applyBlurEffect(blur), modifier = modifier.applyBlurEffect(blur),
state = rememberSearchBarState(), state = rememberSearchBarState(),
colors = ComponentDefaults.appBarWithSearchDefaults(), colors = ComponentDefaults.appBarWithSearchDefaults(),
inputField = { inputField = {
@@ -12,9 +12,14 @@ import androiddev.apps.vbox.client.shared.generated.resources.user_tags
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.LocalBlurState import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurSource import ru.shadowsparky.ui.applyBlurSource
@@ -28,6 +33,7 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
val tokenInfo by comp.tokenInfo.collectAsState(null) val tokenInfo by comp.tokenInfo.collectAsState(null)
val externalPlayerState by comp.externalPlayerState.collectAsState(null) val externalPlayerState by comp.externalPlayerState.collectAsState(null)
val blur = LocalBlurState.current val blur = LocalBlurState.current
val requester = remember { FocusRequester() }
ExpressiveLazyColumn( ExpressiveLazyColumn(
modifier = Modifier.applyBlurSource(blur), modifier = Modifier.applyBlurSource(blur),
contentPadding = paddingValues, contentPadding = paddingValues,
@@ -47,7 +53,8 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
SwitchPreference( SwitchPreference(
text = stringResource(Res.string.external_player_title), text = stringResource(Res.string.external_player_title),
state = it, state = it,
onStateChanged = { newState -> comp.setExternalPlayerState(newState) } onStateChanged = { newState -> comp.setExternalPlayerState(newState) },
modifier = Modifier.focusRequester(requester)
) )
} }
} }
@@ -99,4 +106,8 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
} }
} }
) )
LaunchedEffect(null) {
withFrameNanos {}
requester.requestFocus()
}
} }
@@ -39,11 +39,20 @@ class ExpressiveSectionScope {
contentType: ((T) -> Any)? = null, contentType: ((T) -> Any)? = null,
itemContent: @Composable (T) -> Unit itemContent: @Composable (T) -> Unit
) { ) {
items.forEach { t -> itemsIndexed(items, key, contentType) { _, t -> itemContent(t) }
}
fun <T> itemsIndexed(
items: List<T>,
key: ((T) -> Any)? = null,
contentType: ((T) -> Any)? = null,
itemContent: @Composable (Int, T) -> Unit
) {
items.forEachIndexed { index, t ->
nodes += ExpressiveNode( nodes += ExpressiveNode(
key = key?.invoke(t), key = key?.invoke(t),
contentType = contentType?.invoke(t), contentType = contentType?.invoke(t),
content = { itemContent(t) } content = { itemContent(index, t) }
) )
} }
} }
@@ -42,11 +42,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusProperties
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
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
@@ -144,10 +143,7 @@ fun DefaultControls(
} }
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.focusableWithBorder(focus.slider) { .focusableWithBorder(focus.slider)
next = if (audioTracksState.isNotEmpty()) focus.audioTrack else focus.lock
previous = focus.next
}
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.type == KeyEventType.KeyDown) { if (it.type == KeyEventType.KeyDown) {
when (it.key) { when (it.key) {
@@ -179,10 +175,7 @@ private fun LockUiButton(
) { ) {
IconButton( IconButton(
onClick = { onChanged(!state) }, onClick = { onChanged(!state) },
modifier = Modifier.focusableWithBorder(focusContainer.lock) { modifier = Modifier.focusableWithBorder(focusContainer.lock)
next = focusContainer.prev
previous = if (hasAudioTrack) focusContainer.audioTrack else focusContainer.slider
}
) { ) {
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)
@@ -202,10 +195,7 @@ private fun AudioTracksButton(
val tracks = tracks.map { track -> track to FocusRequester() } val tracks = tracks.map { track -> track to FocusRequester() }
IconButton( IconButton(
onClick = { expanded = true }, onClick = { expanded = true },
modifier = Modifier.focusableWithBorder(focusContainer.audioTrack) { modifier = Modifier.focusableWithBorder(focusContainer.audioTrack)
next = focusContainer.lock
previous = focusContainer.slider
}
) { ) {
Icon( Icon(
painter = painterResource(Res.drawable.music_note_24px), painter = painterResource(Res.drawable.music_note_24px),
@@ -239,10 +229,7 @@ private fun AudioTracksButton(
expanded = false expanded = false
onSelect(track.id) onSelect(track.id)
}, },
modifier = Modifier.focusableWithBorder(requester) { modifier = Modifier.focusableWithBorder(requester)
previous = tracks.getOrNull(i - 1)?.second ?: focusContainer.audioTrack
next = tracks.getOrNull(i + 1)?.second ?: focusContainer.audioTrack
}
) )
} }
} }
@@ -311,10 +298,7 @@ private fun BoxScope.CenterControls(
controller.seekTo(0) controller.seekTo(0)
controller.play() controller.play()
} }
}, modifier = Modifier.focusableWithBorder(focusContainer.next) { }, modifier = Modifier.focusableWithBorder(focusContainer.next)
previous = focusContainer.lock
next = focusContainer.slider
}
) { ) {
Icon( Icon(
painter = painterResource(Res.drawable.replay_24px), painter = painterResource(Res.drawable.replay_24px),
@@ -333,10 +317,7 @@ private fun BoxScope.CenterControls(
IconButton( IconButton(
onClick = { onClick = {
scope.launch { controller.seek(true) } scope.launch { controller.seek(true) }
}, modifier = Modifier.focusableWithBorder(focusContainer.prev) { }, modifier = Modifier.focusableWithBorder(focusContainer.prev)
next = focusContainer.next
previous = focusContainer.play
}
) { ) {
Text( Text(
text = "-$DEF_SEEK_TIME", text = "-$DEF_SEEK_TIME",
@@ -350,10 +331,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.next) { }, modifier = Modifier.focusableWithBorder(focusContainer.play)
next = focusContainer.next
previous = focusContainer.prev
}
) { ) {
Icon( Icon(
painter = painterResource( painter = painterResource(
@@ -371,10 +349,7 @@ private fun BoxScope.CenterControls(
IconButton( IconButton(
onClick = { onClick = {
scope.launch { controller.seek(false) } scope.launch { controller.seek(false) }
}, modifier = Modifier.focusableWithBorder(focusContainer.next) { }, modifier = Modifier.focusableWithBorder(focusContainer.next)
next = focusContainer.slider
previous = focusContainer.play
}
) { ) {
Text( Text(
text = "+$DEF_SEEK_TIME", text = "+$DEF_SEEK_TIME",
@@ -383,9 +358,8 @@ private fun BoxScope.CenterControls(
) )
} }
LaunchedEffect(null) { LaunchedEffect(null) {
delay(1_000) withFrameNanos {}
val result = focusContainer.play.requestFocus() focusContainer.play.requestFocus()
println("DefaultControls focus changed? $result")
} }
} }
} }
@@ -434,18 +408,14 @@ private class FocusContainer(
) )
@Composable @Composable
private fun Modifier.focusableWithBorder( private fun Modifier.focusableWithBorder(requester: FocusRequester): Modifier {
requester: FocusRequester,
focusProperties: FocusProperties.() -> Unit
): Modifier {
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
return focusRequester(requester) return this.focusRequester(requester)
.focusProperties(focusProperties)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.border( .border(
width = 2.dp, width = 2.dp,
color = if (isFocused) { color = if (isFocused) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) MaterialTheme.colorScheme.primary
} else { } else {
Color.Transparent Color.Transparent
}, },