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 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
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
|
||||
true // consumed in preview -> the platform/card path never double-fires
|
||||
} else false
|
||||
} else false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue