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, okHttpClient = okHttpClient,
debugLogging = false, debugLogging = debugLogging,
enableCache = true, enableCache = enableCache,
) )
/** /**

View file

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

View file

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

View file

@ -24,11 +24,9 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -63,20 +61,19 @@ fun ItemCardImage(
imageType: ImageType = ImageType.PRIMARY, imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
fillWidth: Int? = null,
fillHeight: Int? = null,
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
var size by remember { mutableStateOf(IntSize.Zero) }
val imageUrl = val imageUrl =
remember(size, item) { remember(item) {
if (size != IntSize.Zero && item != null) { item?.let {
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
item, item,
imageType, imageType,
fillWidth = size.width, fillWidth = fillWidth,
fillHeight = size.height, fillHeight = fillHeight,
) )
} else {
null
} }
} }
ItemCardImage( ItemCardImage(
@ -88,13 +85,7 @@ fun ItemCardImage(
unwatchedCount = unwatchedCount, unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent, watchedPercent = watchedPercent,
numberOfVersions = numberOfVersions, numberOfVersions = numberOfVersions,
modifier = modifier = modifier,
modifier.onLayoutRectChanged(
throttleMillis = 100,
debounceMillis = 25,
) {
size = IntSize(width = it.width, height = it.height)
},
useFallbackText = useFallbackText, useFallbackText = useFallbackText,
contentScale = contentScale, 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.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid 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.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
@ -1060,7 +1061,7 @@ fun CollectionFolderGridContent(
position = newPosition position = newPosition
positionCallback?.invoke(columns, newPosition) positionCallback?.invoke(columns, newPosition)
}, },
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
@ -1069,6 +1070,7 @@ fun CollectionFolderGridContent(
imageAspectRatio = viewOptions.aspectRatio.ratio, imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType, imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles, showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1109,17 +1111,13 @@ data class PositionItem(
data class CollectionFolderGridParameters( data class CollectionFolderGridParameters(
val columns: Int = 6, val columns: Int = 6,
val spacing: Dp = 16.dp, val spacing: Dp = 16.dp,
val cardContent: @Composable ( val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1129,13 +1127,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 6, columns = 6,
spacing = 16.dp, spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.TALL, imageAspectRatio = AspectRatios.TALL,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1144,13 +1143,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 4, columns = 4,
spacing = 24.dp, spacing = 24.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.Crop, imageContentScale = ContentScale.Crop,
imageAspectRatio = AspectRatios.WIDE, imageAspectRatio = AspectRatios.WIDE,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },
@ -1159,13 +1159,14 @@ data class CollectionFolderGridParameters(
CollectionFolderGridParameters( CollectionFolderGridParameters(
columns = 6, columns = 6,
spacing = 16.dp, spacing = 16.dp,
cardContent = { item, onClick, onLongClick, mod -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
imageAspectRatio = AspectRatios.SQUARE, imageAspectRatio = AspectRatios.SQUARE,
fillWidth = widthPx,
modifier = mod, modifier = mod,
) )
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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