Compare commits

..

No commits in common. "e9cb32b1731b8c07a546ced2be2581ce8a1a23c1" and "ab1890b9311a9d560a791d99f372ad0a57a7e689" have entirely different histories.

4 changed files with 28 additions and 78 deletions

View file

@ -2,8 +2,6 @@ package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -471,7 +469,7 @@ private fun ChannelRow(
val notFullyVisible = target == null || val notFullyVisible = target == null ||
target.offset < 0 || target.offset < 0 ||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset target.offset + target.size > rowState.layoutInfo.viewportEndOffset
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
} }
} }
if (focused) { if (focused) {
@ -576,19 +574,13 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) } val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl ?: stats?.latestThumbUrl
if (coverUrl != null) { if (coverUrl != null) {
// The request must be remembered: ImageRequest has no value equality, ShimmerAsyncImage(
// a fresh request per recomposition reset the shimmer (and retried model = coil.request.ImageRequest.Builder(
// the load) every frame — the fps regression on the TV. androidx.compose.ui.platform.LocalContext.current,
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) .data(coverUrl)
.size(480, 270) // same key as prefetch .size(480, 270) // same key as prefetch
.build() .build(),
}
ShimmerAsyncImage(
model = coverReq,
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
@ -596,18 +588,13 @@ fun ChannelCard(
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
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(avatarUrl)
.size(80, 80)
.build()
}
ShimmerAsyncImage( ShimmerAsyncImage(
model = avatarReq, model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteAvatar(avatar))
.size(80, 80)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333), base = Color(0xFF333333),

View file

@ -1,7 +1,5 @@
package net.jeena.jtubetv.ui.screens 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -11,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -453,7 +450,7 @@ fun FocusScrollRow(
val notFullyVisible = target == null || val notFullyVisible = target == null ||
target.offset < 0 || target.offset < 0 ||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
} }
} }
LazyRow( LazyRow(
@ -498,19 +495,3 @@ 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))
}

View file

@ -124,18 +124,11 @@ fun VideoCard(
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
// The request object must be remembered: ImageRequest has no value val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
// equality, and a fresh request per recomposition reset the // search-index results carry an absolute URL for their own instance
// shimmer's loaded state (and retried the load) on every frame — .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
// the fps regression on the TV. .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
val ctx = androidx.compose.ui.platform.LocalContext.current .build()
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( ShimmerAsyncImage(
model = req, model = req,
contentDescription = video.name, contentDescription = video.name,

View file

@ -74,14 +74,9 @@ fun VideoListScreen(
), ),
) { ) {
val items = viewModel.items val items = viewModel.items
// per-item focus requesters so BACK from the player can refocus the opened card. // 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 val requesters = androidx.compose.runtime.remember(items.size, epoch) {
// every card, and a snapshot map here re-invalidated the reading scopes on androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
// 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. // Scroll position survives destination switches (per source), not just player exits.
val gridState = androidx.compose.runtime.saveable.rememberSaveable( val gridState = androidx.compose.runtime.saveable.rememberSaveable(
@ -97,14 +92,10 @@ fun VideoListScreen(
// handing the first card the focus animates the grid up (a focused card // 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 // 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 // snapped it back down — the jump that was still reported on the channel
// page when the first video got focus (#27 review). // page when the first video got focus (#27 review). Doing the park first,
// The park has to be unconditional, and has to reset the row's offset, // and only while the grid is actually displaced, leaves nothing to undo.
// 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) { LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) { if (items.isNotEmpty() && gridState.firstVisibleItemIndex > 0) {
runCatching { gridState.scrollToItem(0) } runCatching { gridState.scrollToItem(0) }
} }
} }
@ -445,14 +436,12 @@ private fun ChannelHeader(
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
.background(Color.Black), .background(Color.Black),
) { ) {
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the // Banner area: a fixed height with the identity + stats anchored to its
// description sits in the black strip below it. The whole band is 160 dp // bottom; the description sits in the black strip below it.
// (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( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(116.dp) .height(178.dp)
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)), .background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
) { ) {
if (banner != null) { if (banner != null) {
@ -574,7 +563,7 @@ private fun ChannelHeader(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(40.dp) .height(42.dp)
.padding(start = 16.dp, end = 16.dp, top = 6.dp), .padding(start = 16.dp, end = 16.dp, top = 6.dp),
) { ) {
if (description != null) { if (description != null) {