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:
Jeena 2026-10-10 23:51:41 +00:00
parent b84be3e65e
commit e55f7f53c1
5 changed files with 149 additions and 39 deletions

View file

@ -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),

View file

@ -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

View file

@ -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.

View file

@ -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
}