From afb3a0c87171b2442ad4f764a76599bd137dcfd6 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 19 Oct 2026 13:08:00 +0000 Subject: [PATCH 1/3] channel page: park the grid unconditionally, tighter header band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/screens/list/VideoListScreen.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index ff7a4e7..dcd57d3 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -92,10 +92,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 +440,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 +569,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) { From 96c53c6c8946dc7b80da82de673c3d09f98aafcc Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 19 Oct 2026 13:08:09 +0000 Subject: [PATCH 2/3] images: rearm the loading shimmer only when the image changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jtubetv/ui/screens/ChannelsScreen.kt | 31 +++++++++++++------ .../jtubetv/ui/screens/list/VideoCard.kt | 17 +++++++--- 2 files changed, 33 insertions(+), 15 deletions(-) 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 4207968..6dbba4c 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 @@ -574,13 +574,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 +594,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), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 0a1c3a2..e910f5b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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) - // 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() + // 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, From e9cb32b1731b8c07a546ced2be2581ce8a1a23c1 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 19 Oct 2026 13:08:18 +0000 Subject: [PATCH 3/3] ui: cap the eased scroll and drop the snapshot focus map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jtubetv/ui/screens/ChannelsScreen.kt | 4 +++- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 21 ++++++++++++++++++- .../ui/screens/list/VideoListScreen.kt | 11 +++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) 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 6dbba4c..7502c04 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 @@ -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) { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index f8a14a9..53d8625 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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)) +} diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index dcd57d3..8e06582 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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() + // 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() } // Scroll position survives destination switches (per source), not just player exits. val gridState = androidx.compose.runtime.saveable.rememberSaveable(