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> = suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
runCatching { network.api.channels(start = start, count = count, search = search) } 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>> = suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
runCatching { runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username) val response: VideoChannelListResponse = network.api.accountChannels(username)

View file

@ -67,7 +67,9 @@ data class VideoChannelDto(
val host: String, val host: String,
val description: String? = null, val description: String? = null,
val avatars: List<Avatar> = emptyList(), val avatars: List<Avatar> = emptyList(),
val banners: List<Avatar> = emptyList(),
val videosCount: Long = 0, val videosCount: Long = 0,
val followersCount: Long = 0,
) )
@Serializable @Serializable
@ -135,4 +137,5 @@ data class VideoTokenFiles(
@Serializable @Serializable
data class UserDto( data class UserDto(
val username: String, val username: String,
val avatars: List<Avatar> = emptyList(),
) )

View file

@ -3,57 +3,119 @@ package net.jeena.jtubetv.ui.screens
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.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.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.lazy.LazyColumn import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape 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.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.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.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.input.key.onPreviewKeyEvent
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
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.viewmodel.compose.viewModel 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.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage import coil.compose.AsyncImage
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.auth.TokenStore 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.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>> = private val repo: PeerTubeRepository = AppGraph.repository
runCatching {
val existing = cached fun load(force: Boolean = false) {
if (existing == null) { if (!force && userChannels.isNotEmpty()) return
// MVP: the user's own channels (fully local). A paged, searchable loading = true
// local-channels listing needs a non-empty search term server-side. error = null
val response = AppGraph.repository.accountChannels( viewModelScope.launch {
store.accountName ?: store.username ?: "", try {
) // row header (avatar + real username)
response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) user = repo.me().getOrNull()
} else existing 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 { companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@ -71,115 +133,240 @@ fun ChannelsScreen(
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) { ) {
LaunchedEffect(Unit) { viewModel.loadMore(force = true) } LaunchedEffect(epoch) { viewModel.load(force = true) }
val viewModel = viewModel when {
val items = viewModel.items viewModel.loading && viewModel.localChannels.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
Box(Modifier.fillMaxSize()) { ) { androidx.compose.material3.CircularProgressIndicator() }
when { viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() ->
viewModel.loadingInitial && items.isEmpty() -> Box( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize(), Column(horizontalAlignment = Alignment.CenterHorizontally) {
contentAlignment = Alignment.Center, Text("Could not load channels", color = MaterialTheme.colorScheme.error)
) { CircularProgressIndicator() } Text(
items.isEmpty() -> { viewModel.error ?: "",
val error = viewModel.initialError style = MaterialTheme.typography.bodySmall,
Column( color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxSize().padding(16.dp), )
horizontalAlignment = Alignment.CenterHorizontally, androidx.compose.material3.Button(onClick = { viewModel.load(force = true) }) {
verticalArrangement = Arrangement.Center, Text("Retry")
) {
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")
} }
} }
} }
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 @Composable
private fun ChannelsList( private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) {
items: List<VideoChannelDto>, Row(
viewModel: ChannelsViewModel, 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, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
firstFocused: Boolean,
onLeftAtStart: () -> Unit,
) { ) {
val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
LaunchedEffect(items.isNotEmpty()) { val focusRequesters = remember { mutableMapOf<Int, FocusRequester>() }
if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } 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)) { if (firstFocused) {
itemsIndexed(items) { index, channel -> 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( ChannelCard(
channel = channel, channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onClick = { onOpenChannel("${channel.name}@${channel.host}") },
onLeftAtStart = if (index == 0) onOpenDrawer else null, 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 @Composable
private fun ChannelCard( fun ChannelCard(
channel: VideoChannelDto, channel: VideoChannelDto,
onClick: () -> Unit, onClick: () -> Unit,
onLeftAtStart: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLeftAtStart: (() -> Unit)? = null,
) { ) {
var cardFocused by remember { mutableStateOf(false) }
Card( Card(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.fillMaxWidth() .width(rowCardWidth())
.padding(horizontal = 16.dp, vertical = 6.dp), .padding(6.dp)
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), .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)) { Column {
val avatar = channel.avatars.maxByOrNull { it.width } Box(
if (avatar != null) { modifier = Modifier
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null } .fillMaxWidth()
?: (AppGraph.tokenStore.baseUrl + avatar.path) .aspectRatio(16f / 9f)
.background(Color(0xFF222222)),
val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) ) {
.data(url) val banner = channel.banners.maxByOrNull { it.width }
.size(160) if (banner != null) {
.build() AsyncImage(
AsyncImage( model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
model = avReq, .data(absolute(banner.fileUrl ?: banner.path))
contentDescription = channel.displayName, .size(480)
contentScale = ContentScale.Crop, .build(),
modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), 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(
text = channel.displayName.ifBlank { channel.name }, text = channel.displayName.ifBlank { channel.name },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
maxLines = 1, 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(
text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos", text = channel.host + " · " + channel.videosCount + " videos · " +
channel.followersCount + " followers",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A), color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
maxLines = 1, 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. */ /** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */
@Composable @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 val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp
return ((windowWidth / 4.35f) - 10).dp return ((windowWidth / 4.35f) - 10).dp
} }
@ -243,7 +243,7 @@ fun HomeRowsScreen(
* the abrupt jump the built-in lazy scroll produces. * the abrupt jump the built-in lazy scroll produces.
*/ */
@Composable @Composable
private fun FocusScrollRow( fun FocusScrollRow(
videos: List<VideoDto>, videos: List<VideoDto>,
progressOf: (String) -> Float?, progressOf: (String) -> Float?,
onOpenVideo: (VideoDto) -> Unit, 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.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
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.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
@ -106,7 +105,25 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
drawerOpen = false 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) { androidx.compose.runtime.key(epoch) {
Column { Column {
// SmartTube-style top bar: red round search + avatar opening the menu. // 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 -> val handleKey: (KeyEvent) -> Boolean = { event ->
if (event.action == KeyEvent.ACTION_DOWN) { if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) { 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 -> { KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
player.playWhenReady = !player.playWhenReady player.playWhenReady = !player.playWhenReady
true true