channels: Show a banner and harden D-pad activation

Channel pages now load the channel record itself and use the newest
video's thumbnail as a banner fallback, so channels never render
without a header image. Focus cards in re-sorting rows only hand out
initial focus once per screen visit so streaming stats can't steal
focus mid-navigation.

D-pad activation now also accepts ENTER/NUMPAD_ENTER/SPACE, matching
how the Compose platform fires clickable cards; consuming in the
preview phase keeps OK from double-activating.
This commit is contained in:
Jeena 2026-10-12 00:26:47 +00:00
parent 28f0751e48
commit 7584730646
6 changed files with 66 additions and 13 deletions

View file

@ -125,14 +125,20 @@ class PeerTubeRepository(
var videos = 0L var videos = 0L
var plays = 0L var plays = 0L
var total: Long? = null var total: Long? = null
var latestThumb: String? = null
repeat(maxPages) { repeat(maxPages) {
val page = network.api.channelVideos(handle, start, 25) val page = network.api.channelVideos(handle, start, 25)
if (total == null) total = page.total if (total == null) {
total = page.total
latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path ->
if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/')
}
}
plays += page.data.sumOf { it.views } plays += page.data.sumOf { it.views }
start += page.data.size start += page.data.size
videos = total ?: videos videos = total ?: videos
} }
net.jeena.jtubetv.data.api.ChannelStats(videos, plays) net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb)
} }
private fun unauthenticatedApiFor(base: String): PeerTubeApi = private fun unauthenticatedApiFor(base: String): PeerTubeApi =
@ -178,6 +184,9 @@ class PeerTubeRepository(
response.data to response.total response.data to response.total
} }
suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
runCatching { network.api.videoChannel(handle) }
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

@ -158,6 +158,8 @@ data class InstanceInfoDto(
data class ChannelStats( data class ChannelStats(
val videos: Long, val videos: Long,
val plays: Long, val plays: Long,
/** Thumbnail of the newest video — banner fallback for channels without one. */
val latestThumbUrl: String? = null,
) )
@Serializable @Serializable

View file

@ -83,6 +83,11 @@ interface PeerTubeApi {
@Path("account") account: String, @Path("account") account: String,
): VideoChannelListResponse ): VideoChannelListResponse
@GET("video-channels/{handle}")
suspend fun videoChannel(
@Path("handle") handle: String,
): VideoChannelDto
@GET("video-channels/{handle}/videos") @GET("video-channels/{handle}/videos")
suspend fun channelVideos( suspend fun channelVideos(
@Path("handle") handle: String, @Path("handle") handle: String,

View file

@ -1,8 +1,13 @@
package net.jeena.jtubetv.ui.list package net.jeena.jtubetv.ui.list
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
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.PeerTubeRepository
@ -30,6 +35,12 @@ class VideoListViewModel(
val query = MutableStateFlow("") val query = MutableStateFlow("")
private var activeSearch: String? = null private var activeSearch: String? = null
/** For channel pages: the channel itself (banner + display name) as page header. */
var channelHeader by androidx.compose.runtime.mutableStateOf<net.jeena.jtubetv.data.api.VideoChannelDto?>(
null,
)
private set
override fun onChunkLoaded(chunk: List<VideoListItem>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails( ImagePrefetcher.videoThumbnails(
chunk.mapNotNull { chunk.mapNotNull {
@ -60,6 +71,15 @@ class VideoListViewModel(
} }
} }
fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source
if (s is ListSource.Channel) {
viewModelScope.launch {
channelHeader = repository.videoChannel(s.handle).getOrNull()
}
}
}
fun triggerSearch(raw: String) { fun triggerSearch(raw: String) {
query.value = raw query.value = raw
activeSearch = raw.trim().ifBlank { null } activeSearch = raw.trim().ifBlank { null }

View file

@ -291,6 +291,10 @@ fun ChannelsScreen(
} }
} }
else -> { else -> {
// hand the initial focus to the first row exactly ONCE per screen visit:
// as stats stream in the rows re-sort, and re-focusing their first card
// would steal focus mid-navigation (user: "up jumps to the first row").
val focusHandedOut = remember { mutableStateOf(false) }
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
viewModel.userRows.forEach { group -> viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) { item(key = "hdr-" + group.ownerName) {
@ -304,7 +308,9 @@ fun ChannelsScreen(
group.channels, group.channels,
onOpenChannel, onOpenChannel,
onOpenDrawer, onOpenDrawer,
firstFocused = group == viewModel.userRows.firstOrNull(), focused = !focusHandedOut.value &&
group == viewModel.userRows.firstOrNull(),
onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] }, stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] }, platformName = { viewModel.platformNames[it.host ?: ""] },
) )
@ -355,7 +361,8 @@ private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
firstFocused: Boolean, focused: Boolean,
onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?, platformName: (VideoChannelDto) -> String?,
) { ) {
@ -373,9 +380,11 @@ private fun ChannelRow(
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
} }
} }
if (firstFocused) { if (focused) {
LaunchedEffect(channels.isNotEmpty()) { LaunchedEffect(channels.isNotEmpty()) {
if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } if (channels.isNotEmpty()) {
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
}
} }
} }
@ -448,13 +457,16 @@ fun ChannelCard(
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
// banner, or the newest video's thumbnail for bannerless channels
val banner = channel.banners.maxByOrNull { it.width } val banner = channel.banners.maxByOrNull { it.width }
if (banner != null) { val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
AsyncImage( AsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
.data(absoluteBanner(banner)) .data(coverUrl)
.size(480, 270) // same key as prefetch .size(480, 270) // same key as prefetch
.build(), .build(),
contentDescription = channel.displayName, contentDescription = channel.displayName,

View file

@ -14,11 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
*/ */
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
onPreviewKeyEvent { ke -> onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER val code = ke.nativeKeyEvent.keyCode
) { val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
onClick() code == android.view.KeyEvent.KEYCODE_ENTER ||
true code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == android.view.KeyEvent.KEYCODE_SPACE
if (activates) {
onClick()
true // consumed in preview -> the platform/card path never double-fires
} else false
} else false } else false
} }
} }