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 7502c04..4207968 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,8 +2,6 @@ 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 @@ -471,7 +469,7 @@ private fun ChannelRow( val notFullyVisible = target == null || target.offset < 0 || target.offset + target.size > rowState.layoutInfo.viewportEndOffset - if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } if (focused) { @@ -576,19 +574,13 @@ fun ChannelCard( val coverUrl = banner?.let { absoluteBanner(it) } ?: stats?.latestThumbUrl if (coverUrl != null) { - // 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) + ShimmerAsyncImage( + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(coverUrl) .size(480, 270) // same key as prefetch - .build() - } - ShimmerAsyncImage( - model = coverReq, + .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -596,18 +588,13 @@ fun ChannelCard( } val avatar = channel.avatars.maxByOrNull { it.width } 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( - model = avatarReq, + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80, 80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, base = Color(0xFF333333), 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 53d8625..f8a14a9 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,7 +1,5 @@ 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 @@ -11,7 +9,6 @@ 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 @@ -453,7 +450,7 @@ fun FocusScrollRow( val notFullyVisible = target == null || target.offset < 0 || target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset - if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } 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)) -} 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 e910f5b..0a1c3a2 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,18 +124,11 @@ fun VideoCard( .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> - // 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() - } + 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() ShimmerAsyncImage( model = req, contentDescription = video.name, 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 8e06582..ff7a4e7 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,14 +74,9 @@ fun VideoListScreen( ), ) { val items = viewModel.items - // 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() + // 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() } // Scroll position survives destination switches (per source), not just player exits. 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 // 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). - // 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. + // 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. LaunchedEffect(items.isNotEmpty(), source) { - if (items.isNotEmpty()) { + if (items.isNotEmpty() && gridState.firstVisibleItemIndex > 0) { runCatching { gridState.scrollToItem(0) } } } @@ -445,14 +436,12 @@ private fun ChannelHeader( .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) .background(Color.Black), ) { - // 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). + // Banner area: a fixed height with the identity + stats anchored to its + // bottom; the description sits in the black strip below it. Box( Modifier .fillMaxWidth() - .height(116.dp) + .height(178.dp) .background(androidx.compose.ui.graphics.Brush.linearGradient(accent)), ) { if (banner != null) { @@ -574,7 +563,7 @@ private fun ChannelHeader( Box( Modifier .fillMaxWidth() - .height(40.dp) + .height(42.dp) .padding(start = 16.dp, end = 16.dp, top = 6.dp), ) { if (description != null) {