ui: Fix drawer focus-eater + BACK/RIGHT/LEFT menu affordances

The drawer's tap-to-close scrim was focusable: LEFT/RIGHT from a
drawer item moved focus onto the invisible fullscreen scrim, and OK
mysteriously dismissed - the drawer felt un-navigable. Scrim is no
longer focusable; RIGHT (from the drawer) closes it and BACK closes
it; closing restores focus to the search button. LEFT on any row's
leftmost card opens the drawer (used to be only the first row).
Banner prefetch keys now match the render keys (480x270) so channel
banners stop reloading on focus movement.
This commit is contained in:
Jeena 2026-10-11 06:45:56 +00:00
parent f5535eec3f
commit 8d21cf35ec
2 changed files with 173 additions and 182 deletions

View file

@ -1,14 +1,9 @@
package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -33,65 +35,61 @@ 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.input.key.onPreviewKeyEvent
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.BorderStroke
import androidx.tv.material3.Border
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.api.Avatar
import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.ui.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg
/** One row of channels belonging to one user (avatar + display name as header). */
data class UserGroup(
val label: String?,
val avatar: Avatar?,
val videos: Long,
val rows: List<VideoChannelDto>,
) {
/** Sort weight: users with videos first (more videos first), empty channels last. */
fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2
}
/**
* Channels as SmartTube-style rows:
* 1. "Your channels" — header with account avatar + username, then own channel cards.
* 2. "Local channels" — the instance's own channels. The public API has NO
* local-only filter (verified in v8.3.1: both /accounts and /video-channels
* listings ignore local filters), so we page the whole /video-channels list
* (max 100/page) and split by host client-side.
* 3. "Followed instances" — channels of instances this server follows.
* Channels, SmartTube-style: one row per LOCAL user.
*
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local
* users sorted by videos / subscribers / plays (desc).
* - Each row: that user's channels, same ordering; channels with no videos simply
* stay inside their user's row.
* - Followed-instance channels at the end, grouped per platform.
*
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
* have no isLocal filter and the listing carries no videosCount/views, so we page
* the full listing (max 100/page) and fetch per-channel stats (videos + sum of
* video views = plays) with small throttled calls.
*/
data class UserGroup(
val ownerName: String,
val label: String,
val avatar: Avatar?,
val channels: List<VideoChannelDto>,
)
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var user by mutableStateOf<UserDto?>(null)
private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set
var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set
/** One row per (local) user, sorted by total video count; empties last. */
var localUserRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
/** Channels from instances this server follows, grouped by their instance. */
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
var userRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
var loading by mutableStateOf(true)
@ -99,8 +97,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var error by mutableStateOf<String?>(null)
private set
/** "name@host" -> videos + plays; filled in the background. */
var stats by mutableStateOf<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(emptyMap())
/** "name@host" -> stats; filled progressively in the background. */
var stats by mutableStateOf<Map<String, ChannelStats>>(emptyMap())
private set
/** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */
@ -110,7 +108,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private val repo: PeerTubeRepository = AppGraph.repository
fun load(force: Boolean = false) {
if (!force && userChannels.isNotEmpty()) return
if (!force && userRows.isNotEmpty()) return
loading = true
error = null
viewModelScope.launch {
@ -120,11 +118,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
repo.accountChannels(u.username).getOrNull() ?: emptyList()
} ?: emptyList()
// enum the whole channel list (federation-follow listing incl. local)
val all = mutableListOf<VideoChannelDto>()
var start = 0L
var total: Long? = null
var retries = 0
while (total == null || start < total) {
val page = repo.channelList(start, 100).getOrNull() ?: break
val page = repo.channelList(start, 100).getOrNull()
if (page == null) {
retries++
if (retries > 2) break
delay(500)
continue
}
retries = 0
if (total == null) total = page.second
val known = all.map { it.name to it.host }.toSet()
all += page.first.filter { c -> (c.name to c.host) !in known }
@ -132,54 +139,50 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
}
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
val ownNames = userChannels.map { c -> c.name }.toSet()
val mine = all.filter { it.host == host && it.name in ownNames }
val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } }
userChannels = (userChannels + mine).distinctBy { it.name to it.host }
localChannels = rest.filter { it.host == host }
val locals = all.filter { it.host == host }
net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all)
// platform display names (local cached in prefs; foreign lazy)
platformNames = mapOf(
(host ?: "") to (store.instanceName ?: host ?: ""),
)
val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "")
for (fh in foreignHosts) {
val name = repo.instanceName(fh).getOrNull() ?: continue
platformNames = platformNames + (fh to name)
// platform display names: local one cached at login; foreign ones lazy
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) {
val n = repo.instanceName(fh).getOrNull() ?: continue
platformNames = platformNames + (fh to n)
}
// video + play counts per channel, one tiny listing call per channel
for (channel in (userChannels + localChannels)) {
// stats for every local channel: throttle below the server rate limit
val statsMap = HashMap<String, ChannelStats>()
for (channel in locals) {
val key = channel.name + "@" + channel.host
val cs = repo.channelStats(key).getOrNull() ?: continue
stats = stats + (key to cs)
statsMap[key] = cs
stats = statsMap.toMap()
delay(250)
}
// group LOCAL channels by owner (user): row per user, videos desc,
// channels with zero videos collected into a single last row.
val locals = rest.filter { it.host == host }
val withVideos = locals.filter { it.videosCount > 0 }
val empty = locals.filter { it.videosCount <= 0 }
val byOwner = withVideos.groupBy { ownerKey(it) }
localUserRows =
byOwner.values
.map { row -> UserGroup(
label = rowLabel(row),
avatar = rowAvatar(row),
videos = rowTotal(row),
rows = row.sortedByDescending { it.videosCount },
) }
.filter { it.label != null }
.sortedByDescending { it.rowWeight() }
if (empty.isNotEmpty()) {
localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty)
// --- rows per user, sorted by videos / subscribers / plays -----------
val ownNames = userChannels.map { c -> c.name }.toSet()
val byOwner = locals.groupBy { ownerKey(it) }
val groups = byOwner.values.map { row ->
val sortedChannels = row.sortedWith(
compareByDescending<VideoChannelDto> { statsMap[it.name + "@" + it.host]?.videos ?: 0 }
.thenByDescending { it.followersCount }
.thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 },
)
val own = row.firstOrNull { it.name in ownNames }
UserGroup(
ownerName = ownerKey(row.first()),
label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels",
avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width },
channels = sortedChannels,
)
}
followedRows = rest.filter { it.host != host }
.groupBy { it.host ?: "?" }
.values
.map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) }
.sortedByDescending { it.rowWeight() }
// logged-in user first, then the others by videos / subs / plays
userRows = groups.sortedWith(
compareByDescending<UserGroup> { g -> g.channels.any { it.name in ownNames } }
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
)
ImagePrefetcher.channelArt(all + userChannels)
} catch (e: Exception) {
error = e.message ?: "Could not load channels"
} finally {
@ -188,11 +191,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
}
}
private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—"
private fun rowLabel(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.displayName
private fun rowAvatar(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width }
private fun rowTotal(row: List<VideoChannelDto>) = row.sumOf { it.videosCount }
private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: ""
companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@ -212,11 +211,12 @@ fun ChannelsScreen(
) {
LaunchedEffect(epoch) { viewModel.load(force = true) }
when {
viewModel.loading && viewModel.localChannels.isEmpty() -> Box(
viewModel.loading && viewModel.userRows.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
) { androidx.compose.material3.CircularProgressIndicator() }
viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() ->
viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Could not load channels", color = MaterialTheme.colorScheme.error)
@ -231,60 +231,22 @@ fun ChannelsScreen(
}
}
else -> {
val lastOpened = remember { mutableIntStateOf(-1) }
var focusedRow by remember { mutableIntStateOf(0) }
androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) {
if (viewModel.userChannels.isNotEmpty()) {
item {
RowHeader(
avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels",
)
}
item {
ChannelRow(
viewModel.userChannels,
onOpenChannel,
onOpenDrawer,
firstFocused = true,
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
platformProvider = { viewModel.platformNames[it.host ?: ""] },
)
}
}
viewModel.localUserRows.forEach { group ->
item {
LazyColumn(Modifier.fillMaxSize()) {
viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) {
RowHeader(
avatar = group.avatar,
label = (group.label ?: "?") + " (" + group.videos + ")",
label = group.label,
)
}
item {
item(key = "row-" + group.ownerName) {
ChannelRow(
group.rows,
group.channels,
onOpenChannel,
{},
firstFocused = false,
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
platformProvider = { viewModel.platformNames[it.host ?: ""] },
)
}
}
viewModel.followedRows.forEach { group ->
item {
RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")")
}
item {
ChannelRow(
group.rows,
onOpenChannel,
{},
firstFocused = false,
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
platformProvider = { viewModel.platformNames[it.host ?: ""] },
onOpenDrawer,
firstFocused = group == viewModel.userRows.firstOrNull(),
stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] },
)
}
}
@ -293,9 +255,9 @@ fun ChannelsScreen(
}
}
/** Row header: user icon + username, or plain label. */
/** Row header: user icon + "<name>'s Channels". */
@Composable
private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) {
private fun RowHeader(avatar: Avatar?, label: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
@ -303,7 +265,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
) {
if (avatar != null) {
AsyncImage(
model = absolute(avatar.fileUrl ?: avatar.path),
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteAvatar(avatar))
.size(80)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@ -316,24 +283,25 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
}
}
private fun absolute(pathOrUrl: String): String =
if (pathOrUrl.startsWith("http")) pathOrUrl
else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl
private fun absoluteAvatar(avatar: Avatar): String =
(avatar.fileUrl ?: avatar.path).let {
if (it.startsWith("http")) it
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
}
/** Horizontal row of channel cards; eases newly focused cards into view. */
/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
@Composable
private fun ChannelRow(
channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit,
onOpenDrawer: () -> Unit = {},
firstFocused: Boolean,
statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformProvider: (VideoChannelDto) -> String?,
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?,
) {
val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { mutableIntStateOf(-1) }
var clickedIndex by remember { mutableIntStateOf(-1) }
var focusedIndex by remember { mutableStateOf(-1) }
LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) {
@ -351,7 +319,7 @@ private fun ChannelRow(
}
}
androidx.compose.foundation.lazy.LazyRow(
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -362,8 +330,8 @@ private fun ChannelRow(
ChannelCard(
channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
stats = statsProvider(channel),
platformName = platformProvider(channel),
stats = stats(channel),
platformName = platformName(channel),
onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier.focusRequester(fr),
)
@ -371,16 +339,16 @@ private fun ChannelRow(
}
}
/**
* Channel card: banner top (center-cropped), round avatar bottom-left, then
* channel name, "ownerDisplayName - platformDisplayName", stat line with icons.
/**
* Channel card: banner (center-cropped), round avatar bottom-left, channel name,
* "owner - platform" subtitle, then icon stats: videos | subscribers | plays.
*/
@Composable
fun ChannelCard(
channel: VideoChannelDto,
onClick: () -> Unit,
modifier: Modifier = Modifier,
stats: net.jeena.jtubetv.data.api.ChannelStats? = null,
stats: ChannelStats? = null,
platformName: String? = null,
onLeftAtStart: (() -> Unit)? = null,
) {
@ -424,8 +392,8 @@ fun ChannelCard(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absolute(banner.fileUrl ?: banner.path))
.size(480)
.data(absoluteBanner(banner))
.size(480, 270) // same key as prefetch
.build(),
contentDescription = channel.displayName,
contentScale = ContentScale.Crop,
@ -438,8 +406,8 @@ fun ChannelCard(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absolute(avatar.fileUrl ?: avatar.path))
.size(80)
.data(absoluteAvatar(avatar))
.size(80, 80)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
@ -471,34 +439,42 @@ fun ChannelCard(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) {
StatWithIcon(
icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary,
value = stats?.videos?.toString() ?: "-",
focused = cardFocused,
)
StatWithIcon(
icon = androidx.compose.material.icons.Icons.Filled.Groups,
value = channel.followersCount.toString(),
focused = cardFocused,
)
StatWithIcon(
icon = androidx.compose.material.icons.Icons.Filled.PlayArrow,
value = stats?.plays?.toString() ?: "-",
focused = cardFocused,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused)
StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused)
StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused)
}
}
}
}
}
private fun absoluteBanner(banner: Avatar): String =
(banner.fileUrl ?: banner.path).let {
if (it.startsWith("http")) it
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
}
@Composable
private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) {
private fun StatWithIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
focused: Boolean,
) {
val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
androidx.tv.material3.Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(13.dp),
)
Text(value, style = MaterialTheme.typography.labelSmall, color = color)
}
}
}

View file

@ -81,6 +81,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var drawerOpen by rememberSaveable { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current
var lastBackAt by rememberSaveable { mutableStateOf(0L) }
val searchFocus = remember { FocusRequester() }
// After BACK from the player: bump the restore token so lists re-focus the opened
// card and re-fetch positions (UX report BUG-3 + BUG-7).
@ -115,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
lastBackAt = now
drawerOpen = false
runCatching { searchFocus.requestFocus() }
}
Box(
@ -142,6 +144,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen },
searchFocus = searchFocus,
)
Box(
modifier = Modifier
@ -236,6 +239,7 @@ private fun Destination.icon(): ImageVector =
private fun TopBar(
onSearchClick: () -> Unit,
onAccountClick: () -> Unit,
searchFocus: FocusRequester,
modifier: Modifier = Modifier,
) {
Row(
@ -249,6 +253,7 @@ private fun TopBar(
icon = Icons.Filled.Search,
desc = "Search",
onClick = onSearchClick,
focusRequester = searchFocus,
)
// "Who is logged in" — account button: avatar circle + display name (SmartTube).
val accountFocused = remember { mutableStateOf(false) }
@ -317,6 +322,7 @@ private fun RoundTopButton(
icon: ImageVector,
desc: String,
onClick: () -> Unit,
focusRequester: FocusRequester? = null,
) {
var focused by remember { mutableStateOf(false) }
Box(
@ -330,6 +336,7 @@ private fun RoundTopButton(
3.dp, Color.White, CircleShape
) else Modifier
)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.focusable()
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
@ -364,7 +371,16 @@ private fun MenuDrawer(
.width(240.dp)
.fillMaxSize()
.background(Color(0xFF111111))
.padding(vertical = 24.dp),
.padding(vertical = 24.dp)
.onPreviewKeyEvent { ke ->
// RIGHT from the drawer closes it; focus returns to the top bar.
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT
) {
onDismiss()
true
} else false
},
) {
Text(
text = "JTube TV",
@ -401,12 +417,11 @@ private fun MenuDrawer(
modifier = Modifier,
)
}
// tap/click outside closes; focus LEFT from the drawer also closes
// tap/click outside closes the drawer
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusable()
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}