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