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> =
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
@ -3,56 +3,118 @@ 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 {
|
||||||
|
|
@ -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
|
|
||||||
val items = viewModel.items
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
when {
|
when {
|
||||||
viewModel.loadingInitial && items.isEmpty() -> Box(
|
viewModel.loading && viewModel.localChannels.isEmpty() -> Box(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
|
||||||
contentAlignment = Alignment.Center,
|
) { androidx.compose.material3.CircularProgressIndicator() }
|
||||||
) { CircularProgressIndicator() }
|
viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() ->
|
||||||
items.isEmpty() -> {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
val error = viewModel.initialError
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
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("Could not load channels", color = MaterialTheme.colorScheme.error)
|
||||||
Text(error, style = MaterialTheme.typography.bodySmall)
|
Text(
|
||||||
Button(onClick = { viewModel.retryInitial() }) { Text("Retry") }
|
viewModel.error ?: "",
|
||||||
} else {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text("Nothing here yet")
|
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
|
@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)) {
|
}
|
||||||
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(
|
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
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
|
onLeftAtStart()
|
||||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
true
|
||||||
if (avatar != null) {
|
} else false
|
||||||
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
|
},
|
||||||
?: (AppGraph.tokenStore.baseUrl + avatar.path)
|
colors = CardDefaults.colors(
|
||||||
|
containerColor = UnfocusedCardBg,
|
||||||
val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
contentColor = Color.White,
|
||||||
.data(url)
|
focusedContainerColor = Color.White,
|
||||||
.size(160)
|
focusedContentColor = Color(0xFF101010),
|
||||||
.build()
|
),
|
||||||
|
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(
|
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,
|
contentDescription = channel.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
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(
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue