Prefetch nested shelf posters and size-qualify TV image cache keys

Home vertical lists on both platforms now compose the first four cards of
upcoming rows ahead of scroll via LazyListPrefetchStrategy. TV card
requests move to the size-qualified memory cache key helper mobile already
used, so the same URL requested at poster and expanded sizes no longer
collides under one key; the shelf neighbor prewarm and expanded-poster
prefetch write the matching keys.
This commit is contained in:
KhooLy 2026-07-02 01:01:55 +03:00
parent c9fdda4274
commit 876e7bcecb
4 changed files with 221 additions and 65 deletions

View file

@ -241,7 +241,7 @@ internal fun HomeShelfRow(
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(artwork)
.memoryCacheKey(artwork)
.memoryCacheKey(homeArtworkMemoryCacheKey(artwork, 640, 360))
.diskCacheKey(artwork)
.size(640, 360)
.build()

View file

@ -104,7 +104,7 @@ internal fun TvMovieCard(
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(expandedArtwork)
.memoryCacheKey(expandedArtwork)
.memoryCacheKey(homeArtworkMemoryCacheKey(expandedArtwork, 640, 360))
.diskCacheKey(expandedArtwork)
.size(640, 360)
.build()
@ -312,9 +312,9 @@ internal fun TvMovieCard(
ImageRequest.Builder(context)
.data(artwork)
.crossfade(false)
.memoryCacheKey(artwork)
.memoryCacheKey(homeArtworkMemoryCacheKey(artwork, requestWidth, requestHeight))
.diskCacheKey(artwork)
.placeholderMemoryCacheKey(meta.poster)
.placeholderMemoryCacheKey(homeArtworkMemoryCacheKey(meta.poster, 320, 480))
.size(requestWidth, requestHeight)
.build()
}

View file

@ -50,6 +50,7 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.ui.platform.LocalContext
@ -106,7 +107,8 @@ fun HomeScreen(
val stableLoadMoreCategory = remember<(String) -> Unit> { { categoryId -> viewModel.loadMore(categoryId) } }
val homeListState = rememberLazyListState(
initialFirstVisibleItemIndex = viewModel.savedHomeScrollIndex,
initialFirstVisibleItemScrollOffset = viewModel.savedHomeScrollOffset
initialFirstVisibleItemScrollOffset = viewModel.savedHomeScrollOffset,
prefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 4) }
)
DisposableEffect(Unit) {

View file

@ -1,22 +1,61 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package com.fluxa.app.ui.catalog
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private suspend fun FocusRequester.requestFocusWithRetry(maxAttempts: Int = 15) {
repeat(maxAttempts) {
try {
requestFocus()
return
} catch (e: Exception) {
withFrameNanos {}
}
}
}
@Composable
fun HomeScreen(
@ -34,11 +73,22 @@ fun HomeScreen(
) {
val catalogState = rememberHomeCatalogState(activeProfile, viewModel)
val billboardState = rememberHomeBillboardState(activeProfile, viewModel)
val focusedMovie by viewModel.focusedMovie.collectAsStateWithLifecycle()
val lang = activeProfile?.safeLanguage ?: "en"
val heroMovie = billboardState.displayBillboardMovie
val rotatingHeroMovie = billboardState.displayBillboardMovie
?: billboardState.filteredBillboardPool.firstOrNull()
?: billboardState.billboardPool.firstOrNull()
?: catalogState.orderedCategories.firstOrNull()?.items?.firstOrNull()
val heroMovie = if (activeProfile?.safeHeroFollowsFocusedItem == true) {
focusedMovie ?: rotatingHeroMovie
} else {
rotatingHeroMovie
}
val heroLogoUrl = if (activeProfile?.safeHeroFollowsFocusedItem == true && focusedMovie != null) {
focusedMovie?.logo ?: billboardState.billboardLogo
} else {
billboardState.billboardLogo
}
val rowSpecs = remember(catalogState.orderedCategories, activeProfile, catalogState.topTenFeedKeys, lang) {
catalogState.orderedCategories.map { category ->
category.toTvHomeRowSpec(activeProfile, catalogState.topTenFeedKeys, lang)
@ -46,83 +96,187 @@ fun HomeScreen(
}
val firstShelfFocusRequester = remember { FocusRequester() }
val heroPlayFocusRequester = remember { FocusRequester() }
val heroFocusAnchor = remember { FocusRequester() }
val topBarFirstItemFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
val listState = rememberLazyListState(prefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 4) })
val scope = rememberCoroutineScope()
val stableOnMovieClickState = rememberUpdatedState(onMovieClick)
val stableOnPlayDirectState = rememberUpdatedState(onPlayDirect)
val stableLoadMoreCategory = remember<(String) -> Unit> { { categoryId -> viewModel.loadMore(categoryId) } }
val onMovieFocus = remember<(Meta) -> Unit> { { meta -> viewModel.onMovieFocused(meta) } }
val useTopBar = activeProfile?.safeTvNavLayout == "top"
val railGutter = if (useTopBar) 42.dp else 126.dp
val contentTopPadding = if (useTopBar) 108.dp else 84.dp
val hasHeroItem = heroMovie != null && catalogState.showHeroSection
val heroActiveState = remember { mutableStateOf(false) }
var heroActive by heroActiveState
val onMovieFocus = remember<(Meta) -> Unit> { { meta ->
viewModel.onMovieFocused(meta)
heroActiveState.value = false
} }
var pendingFocusJob by remember { mutableStateOf<Job?>(null) }
val navigateToFirstShelf: () -> Unit = remember(hasHeroItem) {
{
pendingFocusJob?.cancel()
heroActiveState.value = false
pendingFocusJob = scope.launch {
if (hasHeroItem) listState.scrollToItem(1)
firstShelfFocusRequester.requestFocusWithRetry()
}
}
}
val navigateToHero: () -> Unit = remember {
{
pendingFocusJob?.cancel()
try { heroFocusAnchor.requestFocus() } catch (e: Exception) { }
pendingFocusJob = scope.launch {
listState.scrollToItem(0)
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
.first { items -> items.any { it.index == 0 } }
heroPlayFocusRequester.requestFocusWithRetry()
}
}
}
LaunchedEffect(hasHeroItem) {
if (hasHeroItem) {
heroActiveState.value = true
pendingFocusJob?.cancel()
pendingFocusJob = scope.launch { heroPlayFocusRequester.requestFocusWithRetry() }
}
}
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val heroMinHeight = (screenHeight - contentTopPadding - 110.dp).coerceAtLeast(0.dp)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
if (!catalogState.isLoading && rowSpecs.isEmpty() && heroMovie == null) {
HomeEmptyProviderState(lang)
return@Box
}
HomeEmptyProviderState(
lang,
onOpenSettings = onProfileClick,
modifier = Modifier.padding(start = railGutter)
)
} else {
HomeHeroBackdrop(
selectedContent = heroMovie,
activePreviewUrl = activePreviewUrl ?: billboardState.billboardTrailerUrl,
sharedPlayer = sharedPlayer,
seasonPostersOnHero = activeProfile?.safeHomeSeasonPostersOnHero ?: true
)
HomeHeroBackdrop(
selectedContent = heroMovie,
activePreviewUrl = activePreviewUrl,
sharedPlayer = sharedPlayer,
seasonPostersOnHero = activeProfile?.safeHomeSeasonPostersOnHero ?: true
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.focusRequester(contentFocusRequester)
.focusRestorer(fallback = heroPlayFocusRequester),
contentPadding = PaddingValues(top = contentTopPadding, bottom = 80.dp)
) {
if (heroMovie != null && catalogState.showHeroSection) {
item(key = "tv-home-hero", contentType = "hero") {
Box(
modifier = Modifier.heightIn(min = heroMinHeight),
contentAlignment = Alignment.BottomStart
) {
HomeHeroPanel(
movie = heroMovie,
logoUrl = heroLogoUrl,
lang = lang,
playButtonFocusRequester = heroPlayFocusRequester,
onNavigateDown = navigateToFirstShelf,
onHeroFocused = { heroActiveState.value = true },
showButtons = heroActive,
aboveHeroFocusRequester = if (useTopBar) topBarFirstItemFocusRequester else null,
modifier = Modifier.padding(start = railGutter, bottom = 32.dp),
onPlayClick = { stableOnMovieClickState.value(heroMovie, null, null) },
onInfoClick = { stableOnMovieClickState.value(heroMovie, null, null) }
)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(start = 84.dp),
contentPadding = PaddingValues(top = 84.dp, bottom = 80.dp)
) {
if (heroMovie != null && catalogState.showHeroSection) {
item(key = "tv-home-hero", contentType = "hero") {
HomeHeroPanel(
movie = heroMovie,
logoUrl = billboardState.billboardLogo,
lang = lang,
playButtonFocusRequester = heroPlayFocusRequester,
belowHeroFocusRequester = firstShelfFocusRequester,
modifier = Modifier.padding(start = 42.dp, bottom = 32.dp),
onPlayClick = { heroMovie?.let { stableOnMovieClickState.value(it, null, null) } },
onInfoClick = { heroMovie?.let { stableOnMovieClickState.value(it, null, null) } }
itemsIndexed(
rowSpecs,
key = { _, row -> row.categoryId },
contentType = { _, row -> "tv-shelf:${row.categoryType}:${row.cardLayout}:${row.isActionRow}:${row.topTenEnabled}" }
) { index, row ->
val isFirstRow = index == 0 && catalogState.showHeroSection && heroMovie != null
HomeShelfRow(
title = row.title,
titleStartPadding = railGutter,
items = row.items,
cardLayout = row.cardLayout,
artworkPreference = row.artworkPreference,
activeProfile = activeProfile,
topTenEnabled = row.topTenEnabled,
onItemClick = { meta ->
if (row.isActionRow) stableOnPlayDirectState.value(meta)
else stableOnMovieClickState.value(meta, row.addonTransportUrl, row.addonCatalogType)
},
onItemFocus = onMovieFocus,
firstItemFocusRequester = if (index == 0) firstShelfFocusRequester else null,
upFocusRequester = when {
isFirstRow -> null
index == 0 && useTopBar -> topBarFirstItemFocusRequester
else -> null
},
onNavigateUp = if (isFirstRow) navigateToHero else null,
onNeedMore = {
if (row.canLoadMore && !row.isActionRow) stableLoadMoreCategory(row.categoryId)
},
onResolveTrailer = { meta -> viewModel.resolveExpandedPosterTrailer(meta) }
)
}
}
itemsIndexed(
rowSpecs,
key = { _, row -> row.categoryId },
contentType = { _, row -> "tv-shelf:${row.categoryType}:${row.cardLayout}:${row.isActionRow}:${row.topTenEnabled}" }
) { index, row ->
HomeShelfRow(
title = row.title,
items = row.items,
cardLayout = row.cardLayout,
artworkPreference = row.artworkPreference,
activeProfile = activeProfile,
topTenEnabled = row.topTenEnabled,
onItemClick = { meta ->
if (row.isActionRow) stableOnPlayDirectState.value(meta)
else stableOnMovieClickState.value(meta, row.addonTransportUrl, row.addonCatalogType)
},
onItemFocus = onMovieFocus,
firstItemFocusRequester = if (index == 0) firstShelfFocusRequester else null,
upFocusRequester = if (index == 0 && catalogState.showHeroSection && heroMovie != null) heroPlayFocusRequester else null,
onNeedMore = {
if (row.canLoadMore && !row.isActionRow) stableLoadMoreCategory(row.categoryId)
}
)
}
}
HomeNavRail(
lang = lang,
onSearchClick = onSearchClick,
onWatchlistClick = onWatchlistClick,
onExploreClick = { onExploreClick("movie", null) },
onProfileClick = onProfileClick,
contentFocusRequester = heroPlayFocusRequester
Box(
modifier = Modifier
.size(1.dp)
.focusRequester(heroFocusAnchor)
.focusable()
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
navigateToFirstShelf()
true
} else false
}
)
if (useTopBar) {
TvHomeTopBar(
lang = lang,
selected = TvNavDestination.Home,
onHomeClick = {},
onSearchClick = onSearchClick,
onWatchlistClick = onWatchlistClick,
onExploreClick = { onExploreClick("movie", null) },
onProfileClick = onProfileClick,
contentFocusRequester = contentFocusRequester,
firstItemFocusRequester = topBarFirstItemFocusRequester
)
} else {
TvHomeNavRail(
lang = lang,
selected = TvNavDestination.Home,
onHomeClick = {},
onSearchClick = onSearchClick,
onWatchlistClick = onWatchlistClick,
onExploreClick = { onExploreClick("movie", null) },
onProfileClick = onProfileClick,
contentFocusRequester = contentFocusRequester
)
}
}
}