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.
This commit is contained in:
Jeena 2026-10-11 00:48:23 +00:00
parent e55f7f53c1
commit 0d583c9660
6 changed files with 320 additions and 98 deletions

View file

@ -119,6 +119,16 @@ class PeerTubeRepository(
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
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<Pair<List<VideoChannelDto>, Long>> =
runCatching {
val response = network.api.channels(search = null, start = start, count = count)
response.data to response.total
}
suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username)

View file

@ -67,7 +67,9 @@ data class VideoChannelDto(
val host: String,
val description: String? = null,
val avatars: List<Avatar> = emptyList(),
val banners: List<Avatar> = 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<Avatar> = emptyList(),
)

View file

@ -3,56 +3,118 @@ 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<VideoChannelDto>() {
/**
* 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<VideoChannelDto>? = null
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
var followedChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
override suspend fun fetch(start: Long, count: Int): Result<List<VideoChannelDto>> =
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<VideoChannelDto>()
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 {
@ -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) {
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(error, style = MaterialTheme.typography.bodySmall)
Button(onClick = { viewModel.retryInitial() }) { Text("Retry") }
} else {
Text("Nothing here yet")
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<VideoChannelDto>,
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<VideoChannelDto>,
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<Int, FocusRequester>() }
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
) {
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()
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),
),
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(Color(0xFF222222)),
) {
val banner = channel.banners.maxByOrNull { it.width }
if (banner != null) {
AsyncImage(
model = avReq,
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.size(72.dp).clip(CircleShape).background(Color(0xFF222222)),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
)
}
Column(modifier = Modifier.padding(start = 12.dp)) {
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(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,
)
}
}

View file

@ -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<VideoDto>,
progressOf: (String) -> Float?,
onOpenVideo: (VideoDto) -> Unit,

View file

@ -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.

View file

@ -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