ui: First card auto-focus, visible top-bar focus, account button
On launch and on every destination switch the first card of the content gets focus automatically - invisible focus on chrome confused 'Do I need to press DOWN first?'. The search button shows a white ring when focused, and the top bar gained a real account button (person icon + logged-in username) replacing the cryptic monitor icon. start-emulator.sh: headless-safe boot (no -gpuhost crash without a display), whitelists adb public keys headlessly (drop them into /tmp/jtube-adbkeys/), and starts the socat bridge itself.
This commit is contained in:
parent
b84be3e65e
commit
e55f7f53c1
5 changed files with 149 additions and 39 deletions
|
|
@ -18,6 +18,8 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -108,12 +110,17 @@ private fun ChannelsList(
|
|||
onOpenChannel: (String) -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
) {
|
||||
val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
LaunchedEffect(items.isNotEmpty()) {
|
||||
if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() }
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
||||
itemsIndexed(items) { index, channel ->
|
||||
ChannelCard(
|
||||
channel = channel,
|
||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier,
|
||||
)
|
||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.loadMore() }
|
||||
|
|
@ -130,10 +137,15 @@ private fun ChannelsList(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) {
|
||||
private fun ChannelCard(
|
||||
channel: VideoChannelDto,
|
||||
onClick: () -> Unit,
|
||||
onLeftAtStart: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
|
|
|
|||
|
|
@ -268,6 +268,14 @@ private fun FocusScrollRow(
|
|||
}
|
||||
}
|
||||
}
|
||||
// On app start and destination switch: focus the FIRST card automatically
|
||||
// (user request — otherwise invisible focus confuses "where am I").
|
||||
LaunchedEffect(videos.isNotEmpty()) {
|
||||
if (videos.isNotEmpty() && focusedIndex == -1) {
|
||||
kotlinx.coroutines.delay(150) // let images/measuring settle
|
||||
runCatching { focusRequesters[0]?.requestFocus() }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(focusedIndex) {
|
||||
if (focusedIndex > 0) {
|
||||
val visible = rowState.layoutInfo.visibleItemsInfo
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens
|
|||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
|
|
@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.PersonalVideo
|
||||
|
|
@ -37,6 +39,7 @@ import androidx.compose.ui.draw.clip
|
|||
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.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
|
|
@ -48,6 +51,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.data.api.VideoDto
|
||||
import net.jeena.jtubetv.ui.AppViewModel
|
||||
import net.jeena.jtubetv.ui.list.ListSource
|
||||
|
|
@ -212,38 +216,40 @@ private fun TopBar(
|
|||
.padding(horizontal = 24.dp, vertical = 10.dp),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
|
||||
Box(
|
||||
RoundTopButton(
|
||||
bg = Color(0xFFCE2B2B),
|
||||
icon = Icons.Filled.Search,
|
||||
desc = "Search",
|
||||
onClick = onSearchClick,
|
||||
)
|
||||
// "Who is logged in" — account button with the username (SmartTube shows the user).
|
||||
val accountFocused = remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFCE2B2B))
|
||||
.focusRequester(searchFocus)
|
||||
.onFocusChanged { accountFocused.value = it.isFocused }
|
||||
.background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent)
|
||||
.then(
|
||||
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
|
||||
else Modifier
|
||||
)
|
||||
.focusable()
|
||||
.clickable(onClick = onSearchClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
.clickable(onClick = onAccountClick)
|
||||
.padding(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Search",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF262626))
|
||||
.focusable()
|
||||
.clickable(onClick = onAccountClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PersonalVideo,
|
||||
imageVector = Icons.Filled.AccountCircle,
|
||||
contentDescription = "Menu",
|
||||
tint = Color(0xFFCCCCCC),
|
||||
)
|
||||
Text(
|
||||
text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
||||
color = Color(0xFFCCCCCC),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
|
|
@ -255,6 +261,37 @@ private fun TopBar(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoundTopButton(
|
||||
bg: Color,
|
||||
icon: ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.background(bg)
|
||||
.then(
|
||||
if (focused) Modifier.border(
|
||||
3.dp, Color.White, CircleShape
|
||||
) else Modifier
|
||||
)
|
||||
.focusable()
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = desc,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in menu drawer: destination list + sign out, readable on a dark background
|
||||
* (UX report: unselected grey was illegible). Selected item gets the red accent.
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ fun VideoListScreen(
|
|||
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
||||
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) }
|
||||
LaunchedEffect(items.isNotEmpty(), source) {
|
||||
if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() }
|
||||
if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
|
||||
}
|
||||
LaunchedEffect(restoreToken) {
|
||||
if (restoreToken > 0) {
|
||||
|
|
@ -67,7 +67,7 @@ fun VideoListScreen(
|
|||
viewModel.refresh()
|
||||
}
|
||||
// focus the card that was playing; fall back to the first card
|
||||
val target = requesters[lastOpenedIndex] ?: initialFocus
|
||||
val target = requesters[lastOpenedIndex] ?: requesters[0] ?: initialFocus
|
||||
runCatching { target.requestFocus() }
|
||||
lastOpenedIndex = -1
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue