From 876e7bcecb9bec3db2474fc645d1fa23965eca06 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:01:55 +0300 Subject: [PATCH] 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. --- .../app/ui/catalog/TvHomeShelfComponents.kt | 2 +- .../com/fluxa/app/ui/catalog/TvMovieCard.kt | 6 +- .../com/fluxa/app/ui/catalog/HomeScreen.kt | 4 +- .../com/fluxa/app/ui/catalog/HomeScreen.kt | 274 ++++++++++++++---- 4 files changed, 221 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TvHomeShelfComponents.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TvHomeShelfComponents.kt index 74e19e1..dbb22d5 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TvHomeShelfComponents.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TvHomeShelfComponents.kt @@ -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() diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt b/app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt index 87e08b4..ac96640 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt @@ -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() } diff --git a/app/src/mobile/java/com/fluxa/app/ui/catalog/HomeScreen.kt b/app/src/mobile/java/com/fluxa/app/ui/catalog/HomeScreen.kt index 7b818e3..4b4a387 100644 --- a/app/src/mobile/java/com/fluxa/app/ui/catalog/HomeScreen.kt +++ b/app/src/mobile/java/com/fluxa/app/ui/catalog/HomeScreen.kt @@ -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) { diff --git a/app/src/tv/java/com/fluxa/app/ui/catalog/HomeScreen.kt b/app/src/tv/java/com/fluxa/app/ui/catalog/HomeScreen.kt index 600a385..ad1a28c 100644 --- a/app/src/tv/java/com/fluxa/app/ui/catalog/HomeScreen.kt +++ b/app/src/tv/java/com/fluxa/app/ui/catalog/HomeScreen.kt @@ -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(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 + ) + } } }