Compare commits

..

3 commits

Author SHA1 Message Date
e9cb32b173 ui: cap the eased scroll and drop the snapshot focus map
A system trace on the TV showed the eased focus scroll re-ticking for
hundreds of milliseconds per move — animateScrollToItem's default spec
scales with distance, and every tick redraws the whole window on a TV
GPU, with 145 ms spikes when new cards composed mid-animation. That is
what drags row scrolling back down to a few fps, again (same symptom
51077b8 and 5ea1ca5 each fixed a facet of). easedScrollToItem keeps the
eased feel but caps the tween at ~140 ms, so a move costs a handful of
cheap ticks instead of hundreds.

The per-card focus requesters in the video grid were back in a snapshot
state map written during composition — the exact recompose loop
5ea1ca5 fixed in the rows, still alive in the grid screens. Plain
HashMap again, keyed on epoch only: keyed on items.size the map was
dropped wholesale on every page load, and already-composed cards didn't
re-populate it until their next recompose.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 13:08:18 +00:00
96c53c6c89 images: rearm the loading shimmer only when the image changes
ShimmerAsyncImage keyed its loaded-state on the passed model, and the
callers built a fresh coil ImageRequest on every recomposition. ImageRequest
has no value equality, so the key changed every time: the loaded flag
reset, the shimmer overlay was re-added and the image load restarted with
each recomposition — an infinite animation per card that never disposed.
On the TV this showed as a general fps collapse wherever thumbnails
render (issue #28's feature shipped with this trap).

The requests are now remembered under stable keys (video/channel id and
the URL strings), so the sweep stops for real once the image (or an
error) lands.

Addresses #28.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 13:08:09 +00:00
afb3a0c871 channel page: park the grid unconditionally, tighter header band
The old park ran only while `firstVisibleItemIndex > 0`, but the focus
pull that hands the first card its focus leaves the header row at index
0 while slid up by ~96 dp — so the guard never fired and the top of the
channel header stayed clipped off screen (#31 review). scrollToItem(0)
re-anchors both index and offset.

The banner band drops to 116 dp with a 40 dp description strip, so the
whole header is under one card row: the grid never reflows around it and
the header stays fully on screen when the first card takes focus.

Addresses #27, #31.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 13:08:00 +00:00
4 changed files with 77 additions and 27 deletions

View file

@ -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),

View file

@ -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))
}

View file

@ -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,

View file

@ -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) {