From 0d583c9660e933792a4851e440fd489e582b6173 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 00:48:23 +0000 Subject: [PATCH] player: Treat Escape (and plain keyboard back) as BACK scrcpy testing from a laptop keyboard: Ctrl+B depends on the mod binding and was unreliable; the app itself now consumes KEYCODE_ESCAPE - player exits to the list, drawer and channel drilldowns close. Right-click in scrcpy remains the other back. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 10 + .../java/net/jeena/jtubetv/data/api/Dtos.kt | 3 + .../jtubetv/ui/screens/ChannelsScreen.kt | 367 +++++++++++++----- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 29 +- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 5 + 6 files changed, 320 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 50e2eee..93a0e9e 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -119,6 +119,16 @@ class PeerTubeRepository( suspend fun localChannels(start: Long, count: Int, search: String? = null): Result = runCatching { network.api.channels(start = start, count = count, search = search) } + /** + * Plain channel listing (the API has no local-only filter; returns local + + * followed-instance channels). Caller splits by host. + */ + suspend fun channelList(start: Long, count: Int): Result, Long>> = + runCatching { + val response = network.api.channels(search = null, start = start, count = count) + response.data to response.total + } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 52a1de0..231aa31 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -67,7 +67,9 @@ data class VideoChannelDto( val host: String, val description: String? = null, val avatars: List = emptyList(), + val banners: List = emptyList(), val videosCount: Long = 0, + val followersCount: Long = 0, ) @Serializable @@ -135,4 +137,5 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val avatars: List = emptyList(), ) \ No newline at end of file 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 f10bd04..382cdb5 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 @@ -3,57 +3,119 @@ package net.jeena.jtubetv.ui.screens 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.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.focus.focusRequester +import androidx.compose.runtime.setValue 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.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow 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.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.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto -import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.screens.list.ContainerShape +import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel() { +/** + * 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. + */ +class ChannelsViewModel(private val store: TokenStore) : ViewModel() { - private var cached: List? = null + var user by mutableStateOf(null) + private set + var userChannels by mutableStateOf>(emptyList()) + private set + var localChannels by mutableStateOf>(emptyList()) + private set + var followedChannels by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set - override suspend fun fetch(start: Long, count: Int): Result> = - runCatching { - val existing = cached - if (existing == null) { - // MVP: the user's own channels (fully local). A paged, searchable - // local-channels listing needs a non-empty search term server-side. - val response = AppGraph.repository.accountChannels( - store.accountName ?: store.username ?: "", - ) - response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) - } else existing + private val repo: PeerTubeRepository = AppGraph.repository + + fun load(force: Boolean = false) { + if (!force && userChannels.isNotEmpty()) return + loading = true + error = null + viewModelScope.launch { + try { + // row header (avatar + real username) + user = repo.me().getOrNull() + userChannels = user?.let { u -> + repo.accountChannels(u.username).getOrNull() ?: emptyList() + } ?: emptyList() + + // enumerate the whole channel list, split by host client-side + val all = mutableListOf() + var start = 0L + var total: Long? = null + while (total == null || start < total) { + val page = repo.channelList(start, 100).getOrNull() ?: break + 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 } + start += 100 + } + 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 }.sortedBy { it.displayName } + followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + } catch (e: Exception) { + error = e.message ?: "Could not load channels" + } finally { + loading = false + } } + } companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -71,115 +133,240 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(Unit) { viewModel.loadMore(force = true) } + LaunchedEffect(epoch) { viewModel.load(force = true) } - val viewModel = viewModel - val items = viewModel.items - - Box(Modifier.fillMaxSize()) { - when { - viewModel.loadingInitial && items.isEmpty() -> Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } - items.isEmpty() -> { - val error = viewModel.initialError - Column( - modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - if (error != null) { - Text("Could not load channels", color = MaterialTheme.colorScheme.error) - Text(error, style = MaterialTheme.typography.bodySmall) - Button(onClick = { viewModel.retryInitial() }) { Text("Retry") } - } else { - Text("Nothing here yet") + when { + viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.Center, + ) { androidx.compose.material3.CircularProgressIndicator() } + viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Could not load channels", color = MaterialTheme.colorScheme.error) + Text( + viewModel.error ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.Button(onClick = { viewModel.load(force = true) }) { + Text("Retry") } } } - else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer) + else -> { + val lastOpened = remember { mutableIntStateOf(-1) } + var focusedRow by remember { mutableIntStateOf(0) } + + Column(Modifier.fillMaxSize()) { + if (viewModel.userChannels.isNotEmpty()) { + // row 1: the user's own channels; header = account icon + username + RowHeader( + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.username ?: "You") + " — your channels", + ) + ChannelCardRow( + viewModel.userChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = true, + onLeftAtStart = onOpenDrawer, + ) + } + + if (viewModel.localChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.localChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + + if (viewModel.followedChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.followedChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + } } } } +/** Row header: user icon + username, or plain label. */ @Composable -private fun ChannelsList( - items: List, - viewModel: ChannelsViewModel, +private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (avatar != null) { + AsyncImage( + model = absolute(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF222222)), + ) + } + Text(label, style = MaterialTheme.typography.titleMedium, color = Color.White) + } +} + +private fun absolute(pathOrUrl: String): String = + if (pathOrUrl.startsWith("http")) pathOrUrl + else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl + +/** Horizontal row of channel cards; eases newly focused cards into view. */ +@Composable +private fun ChannelCardRow( + channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, + firstFocused: Boolean, + onLeftAtStart: () -> Unit, ) { - val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } - LaunchedEffect(items.isNotEmpty()) { - if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + val rowState = androidx.compose.foundation.lazy.rememberLazyListState() + val focusRequesters = remember { mutableMapOf() } + var focusedIndex by remember { mutableIntStateOf(-1) } + var clickedIndex by remember { mutableIntStateOf(-1) } + + LaunchedEffect(focusedIndex) { + if (focusedIndex > 0) { + val visible = rowState.layoutInfo.visibleItemsInfo + val target = visible.firstOrNull { it.index == focusedIndex } + val notFullyVisible = target == null || + target.offset < 0 || + target.offset + target.size > rowState.layoutInfo.viewportEndOffset + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) + } } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, channel -> + if (firstFocused) { + LaunchedEffect(channels.isNotEmpty()) { + if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + } + } + + androidx.compose.foundation.lazy.LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(channels) { index, channel -> + val fr = remember { FocusRequester() } + focusRequesters[index] = fr ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, - modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, + modifier = Modifier.focusRequester(fr), ) - if (index >= items.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { viewModel.loadMore() } - } - } - if (!viewModel.exhausted) { - item { - Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } } } } +/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */ @Composable -private fun ChannelCard( +fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, - onLeftAtStart: (() -> Unit)? = null, modifier: Modifier = Modifier, + onLeftAtStart: (() -> Unit)? = null, ) { + var cardFocused by remember { mutableStateOf(false) } Card( onClick = onClick, modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + .width(rowCardWidth()) + .padding(6.dp) + .onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && + onLeftAtStart != null + ) { + onLeftAtStart() + true + } else false + }, + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + pressedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + ), ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { - val avatar = channel.avatars.maxByOrNull { it.width } - if (avatar != null) { - val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null } - ?: (AppGraph.tokenStore.baseUrl + avatar.path) - - val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) - .data(url) - .size(160) - .build() - AsyncImage( - model = avReq, - contentDescription = channel.displayName, - contentScale = ContentScale.Crop, - modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), - ) + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(Color(0xFF222222)), + ) { + val banner = channel.banners.maxByOrNull { it.width } + if (banner != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(banner.fileUrl ?: banner.path)) + .size(480) + .build(), + contentDescription = channel.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) + } + val avatar = channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(avatar.fileUrl ?: avatar.path)) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF333333)), + ) + } } - Column(modifier = Modifier.padding(start = 12.dp)) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( text = channel.displayName.ifBlank { channel.name }, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, + minLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + color = if (cardFocused) Color(0xFF101010) else Color.White, ) Text( - text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos", + text = channel.host + " · " + channel.videosCount + " videos · " + + channel.followersCount + " followers", style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 290d4ee..a68bfac 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -43,7 +43,7 @@ import net.jeena.jtubetv.ui.screens.list.VideoCard /** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ @Composable -private fun rowCardWidth(): androidx.compose.ui.unit.Dp { +fun rowCardWidth(): androidx.compose.ui.unit.Dp { val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp return ((windowWidth / 4.35f) - 10).dp } @@ -243,7 +243,7 @@ fun HomeRowsScreen( * the abrupt jump the built-in lazy scroll produces. */ @Composable -private fun FocusScrollRow( +fun FocusScrollRow( videos: List, progressOf: (String) -> Float?, onOpenVideo: (VideoDto) -> Unit, 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 491eaf1..a602d88 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 @@ -40,13 +40,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon @@ -106,7 +105,25 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi drawerOpen = false } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { ke -> + // Keyboard/scrcpy: Esc behaves like BACK for drawer + drilldowns + // (the player consumes Esc itself when playing). + val native = ke.nativeKeyEvent + if (native.action == android.view.KeyEvent.ACTION_DOWN && + native.keyCode == android.view.KeyEvent.KEYCODE_ESCAPE && + playingVideo == null + ) { + when { + drawerOpen -> { drawerOpen = false; true } + channelDetails != null -> { channelDetails = null; true } + else -> false + } + } else false + }, + ) { androidx.compose.runtime.key(epoch) { Column { // SmartTube-style top bar: red round search + avatar opening the menu. diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 0297c1a..6c21ee1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -324,6 +324,11 @@ private fun PlayerSurface( val handleKey: (KeyEvent) -> Boolean = { event -> if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { + // scrcpy/keyboard users: Esc = back (Ctrl+B relies on the mod binding) + KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_BACK -> { + onBack() + true + } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true