diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 707db29..9fa1e26 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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, -) { - /** 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 ("'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, +) + class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) private set - var localChannels by mutableStateOf>(emptyList()) - private set - /** One row per (local) user, sorted by total video count; empties last. */ - var localUserRows by mutableStateOf>(emptyList()) - private set - - /** Channels from instances this server follows, grouped by their instance. */ - var followedRows by mutableStateOf>(emptyList()) + var userRows by mutableStateOf>(emptyList()) private set var loading by mutableStateOf(true) @@ -99,8 +97,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> videos + plays; filled in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> stats; filled progressively in the background. */ + var stats by mutableStateOf>(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() 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() + 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 { 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 { 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) = row.firstOrNull()?.ownerAccount?.displayName - private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } - private fun rowTotal(row: List) = 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 + "'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, 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() } - 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) } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index 7e9927d..5cdefeb 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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() } }, ) }