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 plays = 0L
var total: Long? = null
var latestThumb: String? = null
repeat(maxPages) {
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 }
start += page.data.size
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 =
@ -178,6 +184,9 @@ class PeerTubeRepository(
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>> =
runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username)

View file

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

View file

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

View file

@ -1,8 +1,13 @@
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.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
@ -30,6 +35,12 @@ class VideoListViewModel(
val query = MutableStateFlow("")
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>) {
ImagePrefetcher.videoThumbnails(
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) {
query.value = raw
activeSearch = raw.trim().ifBlank { null }

View file

@ -291,6 +291,10 @@ fun ChannelsScreen(
}
}
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()) {
viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) {
@ -304,7 +308,9 @@ fun ChannelsScreen(
group.channels,
onOpenChannel,
onOpenDrawer,
firstFocused = group == viewModel.userRows.firstOrNull(),
focused = !focusHandedOut.value &&
group == viewModel.userRows.firstOrNull(),
onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] },
)
@ -355,7 +361,8 @@ private fun ChannelRow(
channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {},
firstFocused: Boolean,
focused: Boolean,
onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?,
) {
@ -373,9 +380,11 @@ private fun ChannelRow(
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
}
}
if (firstFocused) {
if (focused) {
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)
.background(Color(0xFF222222)),
) {
// banner, or the newest video's thumbnail for bannerless channels
val banner = channel.banners.maxByOrNull { it.width }
if (banner != null) {
val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
AsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteBanner(banner))
.data(coverUrl)
.size(480, 270) // same key as prefetch
.build(),
contentDescription = channel.displayName,

View file

@ -14,11 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
*/
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER
) {
onClick()
true
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
val code = ke.nativeKeyEvent.keyCode
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
code == android.view.KeyEvent.KEYCODE_ENTER ||
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
}
}