mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-17 20:46:04 +00:00
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:
parent
c9fdda4274
commit
876e7bcecb
4 changed files with 221 additions and 65 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue