Preload grid card images & other minor optimizations (#1328)

## Description
Preload ~6 rows worth of images on library grid pages. So depending on
device CPU & memory, most cards do not show a placeholder image when
scrolling unless you press and hold up/down to scroll as fast as
possible.

There's a couple other minor optimizations on the grid code as well, but
nothing huge.

### Dev notes

This PR makes the `cacheWindow` for pre-rendering cards applicable to
their images. Previously, images would not be fetched until the card is
laid out within the window. This was to get the width/height to pass to
the server request. This PR instead calculates the card widths when the
grid itself is laid out and then passes this width into the cards'
composables.

### Related issues
Related to #1246

### Testing
Tested with emulator, shield, fire stick 4k max

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-04-29 19:45:57 -04:00 committed by GitHub
parent b94faceb2f
commit d538b8d4f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 167 additions and 125 deletions

View file

@ -38,8 +38,8 @@ fun CoilConfig(
}
},
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
debugLogging = debugLogging,
enableCache = enableCache,
)
/**

View file

@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@ -70,6 +71,8 @@ fun EpisodeCard(
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
@ -102,6 +105,7 @@ fun EpisodeCard(
watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
fillWidth = imageWidthPx,
modifier =
Modifier
.fillMaxSize(),

View file

@ -47,6 +47,8 @@ fun GridCard(
imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
showTitle: Boolean = true,
fillWidth: Int? = null,
fillHeight: Int? = null,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@ -95,6 +97,8 @@ fun GridCard(
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false,
contentScale = imageContentScale,
fillWidth = fillWidth,
fillHeight = fillHeight,
modifier =
Modifier
.fillMaxWidth()

View file

@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
@ -63,20 +61,19 @@ fun ItemCardImage(
imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
fillWidth: Int? = null,
fillHeight: Int? = null,
) {
val imageUrlService = LocalImageUrlService.current
var size by remember { mutableStateOf(IntSize.Zero) }
val imageUrl =
remember(size, item) {
if (size != IntSize.Zero && item != null) {
remember(item) {
item?.let {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = size.width,
fillHeight = size.height,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
ItemCardImage(
@ -88,13 +85,7 @@ fun ItemCardImage(
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
numberOfVersions = numberOfVersions,
modifier =
modifier.onLayoutRectChanged(
throttleMillis = 100,
debounceMillis = 25,
) {
size = IntSize(width = it.width, height = it.height)
},
modifier = modifier,
useFallbackText = useFallbackText,
contentScale = contentScale,
)

View file

@ -79,6 +79,7 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
@ -1060,7 +1061,7 @@ fun CollectionFolderGridContent(
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
@ -1069,6 +1070,7 @@ fun CollectionFolderGridContent(
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1109,17 +1111,13 @@ data class PositionItem(
data class CollectionFolderGridParameters(
val columns: Int = 6,
val spacing: Dp = 16.dp,
val cardContent: @Composable (
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1129,13 +1127,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 6,
spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.TALL,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1144,13 +1143,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 4,
spacing = 24.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.Crop,
imageAspectRatio = AspectRatios.WIDE,
fillWidth = widthPx,
modifier = mod,
)
},
@ -1159,13 +1159,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters(
columns = 6,
spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.SQUARE,
fillWidth = widthPx,
modifier = mod,
)
},

View file

@ -309,7 +309,7 @@ fun GenreCardGrid(
},
columns = columns,
spacing = spacing,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GenreCard(
genre = item,
onClick = onClick,

View file

@ -133,11 +133,12 @@ fun ItemGrid(
showJumpButtons = false,
showLetterButtons = false,
spacing = 24.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
fillWidth = widthPx,
modifier = mod,
imageAspectRatio = AspectRatios.WIDE, // TODO
)

View file

@ -205,7 +205,7 @@ fun StudioCardGrid(
},
columns = columns,
spacing = spacing,
cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
StudioCard(
studio = item,
onClick = onClick,

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -34,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -51,7 +53,8 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@ -62,10 +65,8 @@ import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
@ -76,6 +77,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.ceil
private const val DEBUG = false
@ -85,6 +87,15 @@ interface CardGridItem {
val sortName: String
}
data class GridItemDetails<T : CardGridItem>(
val item: T?,
val index: Int,
val onClick: () -> Unit,
val onLongClick: () -> Unit,
val widthPx: Int,
val mod: Modifier,
)
/**
* Shows a vertical grid of [CardGridItem]s
*/
@ -102,31 +113,21 @@ fun <T : CardGridItem> CardGrid(
modifier: Modifier = Modifier,
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
item: T?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item as BaseItem?,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
modifier = mod,
)
},
cardContent: @Composable (GridItemDetails<T>) -> Unit,
columns: Int = 6,
spacing: Dp = 16.dp,
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val startPosition =
remember(initialPosition, pager.size) {
initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
}
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
val currentFocusedIndex by rememberUpdatedState(focusedIndex)
val gridState =
rememberLazyGridState(
cacheWindow = fractionCacheWindow,
cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f),
initialFirstVisibleItemIndex = focusedIndex,
)
val scope = rememberCoroutineScope()
@ -135,13 +136,16 @@ fun <T : CardGridItem> CardGrid(
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
if (index != focusedIndex) {
previouslyFocusedIndex = focusedIndex
val focusOn =
remember {
{ index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index")
if (index != currentFocusedIndex) {
previouslyFocusedIndex = focusedIndex
}
focusedIndex = index
}
}
focusedIndex = index
}
// Wait for a recomposition to focus
val alphabetFocusRequester = remember { FocusRequester() }
@ -180,27 +184,33 @@ fun <T : CardGridItem> CardGrid(
}
}
val jump = { jump: Int ->
scope.launch(ExceptionHandler()) {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
val jumpToTop = {
scope.launch(ExceptionHandler()) {
if (focusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
val jump =
remember {
{ jump: Int ->
scope.launch(ExceptionHandler()) {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
}
val jumpToTop =
remember {
{
scope.launch(ExceptionHandler()) {
if (currentFocusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
val jumpToLetter: (Char) -> Unit =
remember {
@ -298,6 +308,9 @@ fun <T : CardGridItem> CardGrid(
modifier = Modifier.align(Alignment.CenterVertically),
)
}
val density = LocalDensity.current
var cardWidthPx by rememberSaveable { mutableIntStateOf(0) }
Box(
modifier = Modifier.weight(1f),
) {
@ -314,57 +327,69 @@ fun <T : CardGridItem> CardGrid(
.focusGroup()
.focusRestorer(firstFocus)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
// focusedIndex = -1
}
onEnter = {
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
}
}
}.onLayoutRectChanged(0, 0) {
val width = it.width
val spacingPx = with(density) { spacing.toPx() }
val cardWidth =
ceil((width - (spacingPx * (columns - 1))) / columns)
cardWidthPx = cardWidth.toInt()
Timber.v("cardWidthPx=%s", cardWidthPx)
},
) {
items(pager.size) { index ->
val mod =
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
val item = pager[index]
cardContent(
item,
{
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
{ if (item != null) onLongClickItem.invoke(index, item) },
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
val details =
remember(index, item) {
val mod =
if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
}
},
)
GridItemDetails(
item = item,
index = index,
onClick = {
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
onLongClick = {
if (item != null) {
onLongClickItem.invoke(index, item)
}
},
widthPx = cardWidthPx,
mod =
mod
.ifElse(
index == 0,
Modifier.focusRequester(zeroFocus),
).onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
}
},
)
}
cardContent.invoke(details)
}
}
if (pager.isEmpty()) {
@ -429,11 +454,21 @@ fun <T : CardGridItem> CardGrid(
// Add end padding to push away from edge
letterClicked = jumpToLetter,
)
} else {
// Spacer ensures card sizes do not change if the alphabet buttons are not shown
Spacer(
Modifier
.padding(start = 16.dp)
.width(letterHorizontalPadding * 2 + letterButtonSize),
)
}
}
}
}
private val letterHorizontalPadding = 2.dp
private val letterButtonSize = 14.dp
@Composable
fun JumpButtons(
jump1: Int,
@ -500,7 +535,7 @@ fun AlphabetButtons(
var alphabetPickerFocused by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp),
contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding),
verticalArrangement = Arrangement.spacedBy(1.1.dp),
state = listState,
modifier =
@ -534,14 +569,14 @@ fun AlphabetButtons(
Box(
modifier =
Modifier
.size(14.dp)
.size(letterButtonSize)
.clip(CircleShape)
.alpha(itemAlpha),
) {
Button(
modifier =
Modifier
.size(14.dp)
.size(letterButtonSize)
.focusRequester(focusRequesters[index]),
contentPadding = PaddingValues(0.dp), // No padding to maximize text space
interactionSource = interactionSource,

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -781,6 +782,9 @@ fun PlaylistItem(
isQueued: Boolean = false,
) {
val focused by interactionSource.collectIsFocusedAsState()
val imageWidth = 160.dp
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
ListItem(
selected = false,
onClick = onClick,
@ -843,8 +847,9 @@ fun PlaylistItem(
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
watchedPercent = 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
modifier = Modifier.width(160.dp),
modifier = Modifier.width(imageWidth),
useFallbackText = false,
fillWidth = imageWidthPx,
)
}
}

View file

@ -60,7 +60,7 @@ fun CollectionMixedGrid(
positionCallback = { _, newPosition ->
onFocusPosition.invoke(RowColumn(0, newPosition))
},
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
@ -69,6 +69,7 @@ fun CollectionMixedGrid(
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
imageType = cardViewOptions.imageType,
showTitle = cardViewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},

View file

@ -142,7 +142,7 @@ fun DiscoverPersonPage(
showJumpButtons = false,
showLetterButtons = false,
spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item,
onClick = onClick,

View file

@ -182,7 +182,7 @@ fun DiscoverRequestGrid(
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
showLetterButtons = false,
cardContent = { item, onClick, onLongClick, mod ->
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item,
onClick = onClick,

View file

@ -204,7 +204,7 @@ fun SeerrRequestsPage(
showJumpButtons = false,
showLetterButtons = false,
spacing = 16.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
DiscoverItemCard(
item = item?.item,
onClick = onClick,