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:
parent
f5535eec3f
commit
8d21cf35ec
2 changed files with 173 additions and 182 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue