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:
parent
e55f7f53c1
commit
0d583c9660
6 changed files with 320 additions and 98 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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<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 {
|
||||
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<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
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue