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:
parent
28f0751e48
commit
7584730646
6 changed files with 66 additions and 13 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
code == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||||
|
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
|
||||||
|
code == android.view.KeyEvent.KEYCODE_SPACE
|
||||||
|
if (activates) {
|
||||||
onClick()
|
onClick()
|
||||||
true
|
true // consumed in preview -> the platform/card path never double-fires
|
||||||
|
} else false
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue