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