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.
This commit is contained in:
Jeena 2026-10-19 13:08:18 +00:00
parent 54dfe68726
commit 0ab19b835c
3 changed files with 31 additions and 5 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) {

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

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