Compare commits
3 commits
ab1890b931
...
e9cb32b173
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cb32b173 | |||
| 96c53c6c89 | |||
| afb3a0c871 |
4 changed files with 77 additions and 27 deletions
|
|
@ -2,6 +2,8 @@ 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
|
||||||
|
|
@ -469,7 +471,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.animateScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
|
|
@ -574,13 +576,19 @@ fun ChannelCard(
|
||||||
val coverUrl = banner?.let { absoluteBanner(it) }
|
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||||
?: stats?.latestThumbUrl
|
?: stats?.latestThumbUrl
|
||||||
if (coverUrl != null) {
|
if (coverUrl != null) {
|
||||||
ShimmerAsyncImage(
|
// The request must be remembered: ImageRequest has no value equality,
|
||||||
model = coil.request.ImageRequest.Builder(
|
// a fresh request per recomposition reset the shimmer (and retried
|
||||||
androidx.compose.ui.platform.LocalContext.current,
|
// 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)
|
.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),
|
||||||
|
|
@ -588,13 +596,18 @@ fun ChannelCard(
|
||||||
}
|
}
|
||||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
ShimmerAsyncImage(
|
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
model = coil.request.ImageRequest.Builder(
|
val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
|
||||||
androidx.compose.ui.platform.LocalContext.current,
|
val avatarReq = remember(avatarUrl) {
|
||||||
|
coil.request.ImageRequest.Builder(
|
||||||
|
ctx,
|
||||||
)
|
)
|
||||||
.data(absoluteAvatar(avatar))
|
.data(avatarUrl)
|
||||||
.size(80, 80)
|
.size(80, 80)
|
||||||
.build(),
|
.build()
|
||||||
|
}
|
||||||
|
ShimmerAsyncImage(
|
||||||
|
model = avatarReq,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
base = Color(0xFF333333),
|
base = Color(0xFF333333),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -9,6 +11,7 @@ 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
|
||||||
|
|
@ -450,7 +453,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.animateScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,18 @@ fun VideoCard(
|
||||||
.background(Color(0xFF222222)),
|
.background(Color(0xFF222222)),
|
||||||
) {
|
) {
|
||||||
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
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
|
// search-index results carry an absolute URL for their own instance
|
||||||
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
||||||
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
ShimmerAsyncImage(
|
ShimmerAsyncImage(
|
||||||
model = req,
|
model = req,
|
||||||
contentDescription = video.name,
|
contentDescription = video.name,
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,14 @@ 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.
|
||||||
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
// A plain HashMap, like the rows: this is/was written during composition of
|
||||||
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
// 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.
|
// 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(
|
||||||
|
|
@ -92,10 +97,14 @@ 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). Doing the park first,
|
// page when the first video got focus (#27 review).
|
||||||
// and only while the grid is actually displaced, leaves nothing to undo.
|
// 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) {
|
LaunchedEffect(items.isNotEmpty(), source) {
|
||||||
if (items.isNotEmpty() && gridState.firstVisibleItemIndex > 0) {
|
if (items.isNotEmpty()) {
|
||||||
runCatching { gridState.scrollToItem(0) }
|
runCatching { gridState.scrollToItem(0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,12 +445,14 @@ 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: a fixed height with the identity + stats anchored to its
|
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
|
||||||
// bottom; the description sits in the black strip below it.
|
// 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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(178.dp)
|
.height(116.dp)
|
||||||
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
||||||
) {
|
) {
|
||||||
if (banner != null) {
|
if (banner != null) {
|
||||||
|
|
@ -563,7 +574,7 @@ private fun ChannelHeader(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(42.dp)
|
.height(40.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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue