Compare commits
3 commits
ab1890b931
...
e9cb32b173
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cb32b173 | |||
| 96c53c6c89 | |||
| afb3a0c871 |
4 changed files with 77 additions and 27 deletions
|
|
@ -2,6 +2,8 @@ package net.jeena.jtubetv.ui.screens
|
|||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -469,7 +471,7 @@ private fun ChannelRow(
|
|||
val notFullyVisible = target == null ||
|
||||
target.offset < 0 ||
|
||||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset
|
||||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
if (focused) {
|
||||
|
|
@ -574,13 +576,19 @@ fun ChannelCard(
|
|||
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||
?: stats?.latestThumbUrl
|
||||
if (coverUrl != null) {
|
||||
ShimmerAsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
// The request must be remembered: ImageRequest has no value equality,
|
||||
// a fresh request per recomposition reset the shimmer (and retried
|
||||
// the load) every frame — the fps regression on the TV.
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host }
|
||||
val coverReq = remember(coverKey, coverUrl) {
|
||||
coil.request.ImageRequest.Builder(ctx)
|
||||
.data(coverUrl)
|
||||
.size(480, 270) // same key as prefetch
|
||||
.build(),
|
||||
.build()
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = coverReq,
|
||||
contentDescription = channel.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
||||
|
|
@ -588,13 +596,18 @@ fun ChannelCard(
|
|||
}
|
||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
ShimmerAsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
|
||||
val avatarReq = remember(avatarUrl) {
|
||||
coil.request.ImageRequest.Builder(
|
||||
ctx,
|
||||
)
|
||||
.data(absoluteAvatar(avatar))
|
||||
.data(avatarUrl)
|
||||
.size(80, 80)
|
||||
.build(),
|
||||
.build()
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = avatarReq,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package net.jeena.jtubetv.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
|
@ -450,7 +453,7 @@ fun FocusScrollRow(
|
|||
val notFullyVisible = target == null ||
|
||||
target.offset < 0 ||
|
||||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
||||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
|
|
@ -495,3 +498,19 @@ fun FocusScrollRow(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eased scroll to [index] with a capped duration (perf): [animateScrollToItem]'s
|
||||
* default spec keeps re-ticking for hundreds of ms on long distances, and every
|
||||
* tick is a full-window redraw on a TV GPU — which is what dragged the scroll
|
||||
* down to a few fps. A short tween keeps the eased feel at a fraction of the
|
||||
* ticks. (Shared by the home rows and the channels rows.)
|
||||
*/
|
||||
suspend fun LazyListState.easedScrollToItem(index: Int) {
|
||||
val info = layoutInfo
|
||||
val first = info.visibleItemsInfo.firstOrNull() ?: return
|
||||
val stride = first.size + info.mainAxisItemSpacing
|
||||
if (stride <= 0) return
|
||||
val delta = index * stride - (first.index * stride + firstVisibleItemScrollOffset)
|
||||
animateScrollBy(delta.toFloat(), tween(140))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,11 +124,18 @@ fun VideoCard(
|
|||
.background(Color(0xFF222222)),
|
||||
) {
|
||||
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||
// The request object must be remembered: ImageRequest has no value
|
||||
// equality, and a fresh request per recomposition reset the
|
||||
// shimmer's loaded state (and retried the load) on every frame —
|
||||
// the fps regression on the TV.
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val req = remember(video.id, video.thumbnailUrl) {
|
||||
coil.request.ImageRequest.Builder(ctx)
|
||||
// search-index results carry an absolute URL for their own instance
|
||||
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
||||
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||
.build()
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = req,
|
||||
contentDescription = video.name,
|
||||
|
|
|
|||
|
|
@ -74,9 +74,14 @@ fun VideoListScreen(
|
|||
),
|
||||
) {
|
||||
val items = viewModel.items
|
||||
// per-item focus requesters so BACK from the player can refocus the opened card
|
||||
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
||||
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
||||
// per-item focus requesters so BACK from the player can refocus the opened card.
|
||||
// A plain HashMap, like the rows: this is/was written during composition of
|
||||
// every card, and a snapshot map here re-invalidated the reading scopes on
|
||||
// each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5).
|
||||
// Keyed only on [epoch]: on items.size the map was dropped wholesale, and
|
||||
// already-composed cards didn't re-populate it until their next recompose.
|
||||
val requesters = androidx.compose.runtime.remember(epoch) {
|
||||
HashMap<Int, FocusRequester>()
|
||||
}
|
||||
// Scroll position survives destination switches (per source), not just player exits.
|
||||
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
|
||||
|
|
@ -92,10 +97,14 @@ fun VideoListScreen(
|
|||
// handing the first card the focus animates the grid up (a focused card
|
||||
// that sits partly off-screen is pulled into view), and this effect then
|
||||
// snapped it back down — the jump that was still reported on the channel
|
||||
// page when the first video got focus (#27 review). Doing the park first,
|
||||
// and only while the grid is actually displaced, leaves nothing to undo.
|
||||
// page when the first video got focus (#27 review).
|
||||
// The park has to be unconditional, and has to reset the row's offset,
|
||||
// not just its index: the focus pull leaves the header row at index 0 but
|
||||
// slid up by ~96 dp, so a guard on firstVisibleItemIndex > 0 never fires
|
||||
// in that state and the top of the channel header stays clipped off
|
||||
// screen (#31 review). scrollToItem(0) re-anchors both.
|
||||
LaunchedEffect(items.isNotEmpty(), source) {
|
||||
if (items.isNotEmpty() && gridState.firstVisibleItemIndex > 0) {
|
||||
if (items.isNotEmpty()) {
|
||||
runCatching { gridState.scrollToItem(0) }
|
||||
}
|
||||
}
|
||||
|
|
@ -436,12 +445,14 @@ private fun ChannelHeader(
|
|||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Banner area: a fixed height with the identity + stats anchored to its
|
||||
// bottom; the description sits in the black strip below it.
|
||||
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
|
||||
// description sits in the black strip below it. The whole band is 160 dp
|
||||
// (4 + 116 + 40) — under one card row — so the grid never reflows around it
|
||||
// and the band stays fully on screen when the first card takes focus (#31).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(178.dp)
|
||||
.height(116.dp)
|
||||
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
||||
) {
|
||||
if (banner != null) {
|
||||
|
|
@ -563,7 +574,7 @@ private fun ChannelHeader(
|
|||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
.height(40.dp)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
) {
|
||||
if (description != null) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue