diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt index c1d319d..e3bdbc3 100644 --- a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt @@ -39,6 +39,7 @@ fun AppBarWithSearchWithBackPreview() { @Composable fun InternalAppBarPreview() { SearchAppBar( + Modifier, TextFieldValue(""), FocusRequester(), {}, 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 a29cdf1..afce804 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 @@ -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 diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewScreen.kt index b57d719..acabe32 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewScreen.kt @@ -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() + } } } } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewScreen.kt index 2569011..d5eb202 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewScreen.kt @@ -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) { diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewScreen.kt index c245079..f1cee46 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewScreen.kt @@ -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)) } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.kt index 83ffd99..a71f87c 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.kt @@ -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 = { diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index 4079bdb..fd75a78 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -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() + } } diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt index 3328812..57251d8 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt @@ -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 itemsIndexed( + items: List, + 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) } ) } } diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt index c5ec294..7d56c4a 100644 --- a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt @@ -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 },