diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 5079c0d..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -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 = + runCatching { network.api.videoChannel(handle) } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index b807ebc..91d3777 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -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 diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 4c505ce..1c5940a 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -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, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 33bf4aa..a723e8e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -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( + null, + ) + private set + override fun onChunkLoaded(chunk: List) { 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 } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 8bcf936..a97a119 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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, 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, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt index b7adec0..7088eb4 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -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 } } \ No newline at end of file