perf: reduce compose recomposition hotspots

This commit is contained in:
tapframe 2026-07-09 17:29:10 +05:30
parent 3203a98a72
commit 62d4297063
14 changed files with 914 additions and 408 deletions

1
.gitignore vendored
View file

@ -28,6 +28,7 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
PERFORMANCE_FINDINGS.md
# Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs)
.mpvkit-build.sparseimage

View file

@ -2,14 +2,17 @@ package com.nuvio.app.core.ui
import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder =
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder =
components {
if (Build.VERSION.SDK_INT >= 28) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
}

View file

@ -42,11 +42,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
@ -65,6 +67,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@ -381,6 +384,63 @@ private enum class AppGateScreen {
Main,
}
@Composable
private fun rememberTabsRouteActiveState(navController: NavController): State<Boolean> {
val routeActiveState = remember(navController) {
mutableStateOf(navController.currentDestination?.hasRoute<TabsRoute>() ?: true)
}
DisposableEffect(navController) {
fun update(destination: NavDestination?) {
routeActiveState.value = destination.isTabsRoute()
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, destination, _ ->
update(destination)
}
navController.currentDestination?.let(::update)
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
}
}
return routeActiveState
}
private fun NavDestination?.isTabsRoute(): Boolean =
this?.hasRoute<TabsRoute>() == true
@Composable
private fun DismissResumePromptOnPlaybackDestination(
navController: NavController,
onPlaybackDestination: () -> Unit,
) {
val currentOnPlaybackDestination by rememberUpdatedState(onPlaybackDestination)
DisposableEffect(navController) {
fun maybeDismiss(destination: NavDestination?) {
if (
destination?.hasRoute<StreamRoute>() == true ||
destination?.hasRoute<PlayerRoute>() == true
) {
currentOnPlaybackDestination()
}
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, destination, _ ->
maybeDismiss(destination)
}
maybeDismiss(navController.currentDestination)
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
@ -393,7 +453,7 @@ fun App() {
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader()
.configurePlatformImageLoader(context)
.build()
}
val selectedTheme by remember {
@ -649,6 +709,7 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
val tabsRouteActiveState = rememberTabsRouteActiveState(navController)
val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
@ -670,7 +731,6 @@ private fun MainAppContent(
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
var nativeProfileSwitcherVisible by remember { mutableStateOf(false) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
@ -1052,12 +1112,8 @@ private fun MainAppContent(
}
}
LaunchedEffect(currentBackStackEntry?.destination) {
val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute<StreamRoute>() == true ||
currentBackStackEntry?.destination?.hasRoute<PlayerRoute>() == true
if (inPlaybackFlow) {
resumePromptItem = null
}
DismissResumePromptOnPlaybackDestination(navController) {
resumePromptItem = null
}
LaunchedEffect(navController) {
@ -1508,7 +1564,6 @@ private fun MainAppContent(
.calculateBottomPadding()
val nativeProfileTabAnchorBottomPadding =
nativeTabSafeBottomPadding + NuvioTokens.Space.s10
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
nativeProfileSwitcherVisible = false
profileSwitchLoading = true
@ -1569,13 +1624,12 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
tabsRouteActiveState = tabsRouteActiveState,
searchFocusRequestCount = searchFocusRequestCount,
rootActionsEnabled = tabsRouteActive,
homeScrollToTopRequests = homeScrollToTopRequests,
searchScrollToTopRequests = searchScrollToTopRequests,
libraryScrollToTopRequests = libraryScrollToTopRequests,
settingsRootActionRequests = settingsRootActionRequests,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@ -1674,21 +1728,22 @@ private fun MainAppContent(
)
}
if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) {
NativeProfileSwitcherPopup(
visible = nativeProfileSwitcherVisible,
isSwitchingProfile = profileSwitchLoading,
onDismissRequest = { nativeProfileSwitcherVisible = false },
onProfileSelected = onProfileSelected,
onAddProfileRequested = {
nativeProfileSwitcherVisible = false
onSwitchProfile()
},
modifier = Modifier
.fillMaxSize()
.padding(bottom = nativeProfileTabAnchorBottomPadding),
)
}
NativeProfileSwitcherPopupHost(
tabsRouteActiveState = tabsRouteActiveState,
isTabletLayout = isTabletLayout,
useNativeBottomTabs = useNativeBottomTabs,
visible = nativeProfileSwitcherVisible,
isSwitchingProfile = profileSwitchLoading,
onDismissRequest = { nativeProfileSwitcherVisible = false },
onProfileSelected = onProfileSelected,
onAddProfileRequested = {
nativeProfileSwitcherVisible = false
onSwitchProfile()
},
modifier = Modifier
.fillMaxSize()
.padding(bottom = nativeProfileTabAnchorBottomPadding),
)
}
}
}
@ -3051,14 +3106,13 @@ private fun rememberGuardedPopBackStack(
@Composable
private fun AppTabHost(
selectedTab: AppScreenTab,
tabsRouteActiveState: State<Boolean>,
modifier: Modifier = Modifier,
searchFocusRequestCount: Int = 0,
rootActionsEnabled: Boolean = true,
homeScrollToTopRequests: Flow<Unit>,
searchScrollToTopRequests: Flow<Unit>,
libraryScrollToTopRequests: Flow<Unit>,
settingsRootActionRequests: Flow<Unit>,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -3092,34 +3146,31 @@ private fun AppTabHost(
tabStateHolder.SaveableStateProvider(selectedTab.name) {
when (selectedTab) {
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
scrollToTopRequests = homeScrollToTopRequests,
AppHomeTabContent(
tabsRouteActiveState = tabsRouteActiveState,
homeScrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
onInitialHomeContentRendered = onInitialHomeContentRendered,
)
}
AppScreenTab.Search -> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
AppSearchTabContent(
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
searchScrollToTopRequests = searchScrollToTopRequests,
)
}
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
AppLibraryTabContent(
libraryScrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
@ -3129,12 +3180,11 @@ private fun AppTabHost(
}
AppScreenTab.Settings -> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
AppSettingsTabContent(
tabsRouteActiveState = tabsRouteActiveState,
rootActionRequests = settingsRootActionRequests,
requestedPageName = requestedSettingsPageName,
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
rootActionsEnabled = rootActionsEnabled,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
@ -3154,6 +3204,135 @@ private fun AppTabHost(
}
}
@Composable
private fun AppHomeTabContent(
tabsRouteActiveState: State<Boolean>,
homeScrollToTopRequests: Flow<Unit>,
onCatalogClick: ((HomeCatalogSection) -> Unit)?,
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)?,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)?,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
onInitialHomeContentRendered: () -> Unit,
) {
val animateCollectionGifsProvider = remember(tabsRouteActiveState) {
{ tabsRouteActiveState.value }
}
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifsProvider = animateCollectionGifsProvider,
scrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
@Composable
private fun AppSearchTabContent(
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
searchFocusRequestCount: Int,
searchScrollToTopRequests: Flow<Unit>,
) {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
)
}
@Composable
private fun AppLibraryTabContent(
libraryScrollToTopRequests: Flow<Unit>,
onPosterClick: ((LibraryItem) -> Unit)?,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)?,
onConnectCloudClick: (() -> Unit)?,
) {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onSectionViewAllClick = onSectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
@Composable
private fun AppSettingsTabContent(
tabsRouteActiveState: State<Boolean>,
rootActionRequests: Flow<Unit>,
requestedPageName: String?,
onRequestedPageConsumed: () -> Unit,
onSwitchProfile: (() -> Unit)?,
onHomescreenClick: () -> Unit,
onMetaScreenClick: () -> Unit,
onContinueWatchingClick: () -> Unit,
onDownloadsClick: () -> Unit,
onAddonsClick: () -> Unit,
onPluginsClick: () -> Unit,
onAccountClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)?,
onCollectionsClick: () -> Unit,
) {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = rootActionRequests,
requestedPageName = requestedPageName,
onRequestedPageConsumed = onRequestedPageConsumed,
rootActionsEnabled = tabsRouteActiveState.value,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick,
onMetaScreenClick = onMetaScreenClick,
onContinueWatchingClick = onContinueWatchingClick,
onDownloadsClick = onDownloadsClick,
onAddonsClick = onAddonsClick,
onPluginsClick = onPluginsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
}
@Composable
private fun NativeProfileSwitcherPopupHost(
tabsRouteActiveState: State<Boolean>,
isTabletLayout: Boolean,
useNativeBottomTabs: Boolean,
visible: Boolean,
isSwitchingProfile: Boolean,
onDismissRequest: () -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!isTabletLayout && useNativeBottomTabs && tabsRouteActiveState.value) {
NativeProfileSwitcherPopup(
visible = visible,
isSwitchingProfile = isSwitchingProfile,
onDismissRequest = onDismissRequest,
onProfileSelected = onProfileSelected,
onAddProfileRequested = onAddProfileRequested,
modifier = modifier,
)
}
}
@Composable
private fun TabletFloatingTopBar(
selectedTab: AppScreenTab,

View file

@ -1,5 +1,8 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
import coil3.PlatformContext
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder

View file

@ -1,12 +1,16 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@Composable
internal fun rememberPosterCardStyleUiState(): PosterCardStyleUiState {
PosterCardStyleRepository.ensureLoaded()
val uiState by PosterCardStyleRepository.uiState.collectAsState()
val uiStateFlow = remember {
PosterCardStyleRepository.ensureLoaded()
PosterCardStyleRepository.uiState
}
val uiState by uiStateFlow.collectAsState()
return uiState
}
}

View file

@ -25,6 +25,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -68,6 +69,10 @@ fun <T> NuvioShelfSection(
itemContent: @Composable (T) -> Unit,
) {
val tokens = MaterialTheme.nuvio
val duplicateSafeEntries = remember(entries, key) {
key?.let { entries.withDuplicateSafeLazyKeys(it) }
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
@ -85,10 +90,11 @@ fun <T> NuvioShelfSection(
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
) {
if (key != null) {
if (duplicateSafeEntries != null) {
items(
items = entries.withDuplicateSafeLazyKeys(key),
items = duplicateSafeEntries,
key = { entry -> entry.lazyKey },
contentType = { "poster" },
) { keyedEntry ->
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(keyedEntry.value) }
@ -97,7 +103,10 @@ fun <T> NuvioShelfSection(
}
}
} else {
items(entries) { entry ->
items(
items = entries,
contentType = { "poster" },
) { entry ->
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(entry) }
} else {

View file

@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
@ -268,8 +269,6 @@ fun MetaDetailsScreen(
LaunchedEffect(
type,
id,
displayedMeta?.id,
uiState.isLoading,
traktSettingsUiState.moreLikeThisSource,
traktAuthUiState.mode,
tmdbSettingsUiState.enabled,
@ -727,22 +726,43 @@ fun MetaDetailsScreen(
.calculateTopPadding()
.toPx()
}
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else {
heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset
val heroHeightPxState = remember(meta.id) { mutableIntStateOf(0) }
val heroHeightPx = heroHeightPxState.intValue
val detailScrollOffsetProvider = remember(listState, heroHeightPxState) {
{
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else {
heroHeightPxState.intValue.toFloat() + listState.firstVisibleItemScrollOffset
}
}
}
val heroScrollOffset = detailScrollOffsetPx.toInt()
val headerTarget = if (
heroHeightPx > 0 &&
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)
val isScrolledPastHeroHeaderThreshold by remember(
listState,
heroHeightPxState,
safeAreaTopPx,
detailScrollOffsetProvider,
) {
1f
} else {
0f
derivedStateOf {
val measuredHeroHeightPx = heroHeightPxState.intValue
val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
measuredHeroHeightPx > 0 &&
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetProvider() > thresholdPx)
}
}
val isHeroTrailerWithinPlayThreshold by remember(
listState,
heroHeightPxState,
safeAreaTopPx,
detailScrollOffsetProvider,
) {
derivedStateOf {
val measuredHeroHeightPx = heroHeightPxState.intValue
val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
measuredHeroHeightPx == 0 || detailScrollOffsetProvider() <= thresholdPx
}
}
val headerTarget = if (isScrolledPastHeroHeaderThreshold) 1f else 0f
val heroTrailerSourceUrl = heroTrailerPlaybackSource
?.videoUrl
?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails }
@ -751,8 +771,8 @@ fun MetaDetailsScreen(
?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() }
val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null &&
!isLeavingDetails &&
(heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx)
val headerProgress by animateFloatAsState(
isHeroTrailerWithinPlayThreshold
val headerProgressState = animateFloatAsState(
targetValue = headerTarget,
animationSpec = tween(
durationMillis = if (headerTarget > 0f) 150 else 100,
@ -760,6 +780,15 @@ fun MetaDetailsScreen(
),
label = "detail_floating_header_progress",
)
val headerProgressProvider = remember(headerProgressState) {
{ headerProgressState.value }
}
val showHeroBackButton by remember(headerProgressState) {
derivedStateOf { headerProgressState.value <= 0.05f }
}
val headerInteractive by remember(headerProgressState) {
derivedStateOf { headerProgressState.value > 0.05f }
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colorScheme = MaterialTheme.colorScheme
@ -850,13 +879,16 @@ fun MetaDetailsScreen(
.fillMaxSize()
.zIndex(1f),
) {
item(key = "detail-hero") {
item(
key = "detail-hero",
contentType = "detail-hero",
) {
DetailHero(
meta = meta,
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
scrollOffset = heroScrollOffset,
onHeightChanged = { heroHeightPx = it },
scrollOffsetProvider = detailScrollOffsetProvider,
onHeightChanged = { heroHeightPxState.intValue = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
heroTrailerReady = heroTrailerReady,
@ -962,7 +994,10 @@ fun MetaDetailsScreen(
animatedVisibilityScope = animatedVisibilityScope,
)
item(key = "detail-bottom-spacer") {
item(
key = "detail-bottom-spacer",
contentType = "detail-spacer",
) {
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp)))
}
}
@ -976,7 +1011,7 @@ fun MetaDetailsScreen(
.fillMaxWidth()
.height(132.dp)
.graphicsLayer {
translationY = heroHeightPx.toFloat() - detailScrollOffsetPx
translationY = heroHeightPx.toFloat() - detailScrollOffsetProvider()
}
.background(
Brush.verticalGradient(
@ -991,7 +1026,7 @@ fun MetaDetailsScreen(
)
}
if (headerProgress <= 0.05f) {
if (showHeroBackButton) {
NuvioBackButton(
onClick = onBackFromDetails,
modifier = Modifier.padding(
@ -1006,7 +1041,8 @@ fun MetaDetailsScreen(
DetailFloatingHeader(
meta = meta,
isSaved = isSaved,
progress = headerProgress,
progressProvider = headerProgressProvider,
interactive = headerInteractive,
backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled },
onBack = onBackFromDetails,
onToggleSaved = toggleSaved,
@ -1408,7 +1444,15 @@ private fun LazyListScope.configuredMetaSectionItems(
sectionItems: List<MetaScreenSectionItem>,
forceTabLayout: Boolean = settings.tabLayout,
) {
item(key = key) {
val contentType = if (sectionItems.size == 1) {
"detail-section-${sectionItems.first().key.name}"
} else {
"detail-section-tab-group"
}
item(
key = key,
contentType = contentType,
) {
DetailSectionContainer(
horizontalPadding = contentHorizontalPadding,
contentMaxWidth = contentMaxWidth,

View file

@ -48,7 +48,8 @@ import org.jetbrains.compose.resources.stringResource
fun DetailFloatingHeader(
meta: MetaDetails,
isSaved: Boolean,
progress: Float,
progressProvider: () -> Float,
interactive: Boolean,
backgroundColor: Color? = null,
onBack: () -> Unit,
onToggleSaved: () -> Unit,
@ -56,7 +57,6 @@ fun DetailFloatingHeader(
) {
val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f)
val interactive = progress > 0.05f
val surfaceColor = backgroundColor ?: if (isIos) {
MaterialTheme.colorScheme.surface.copy(alpha = 1.0f)
} else {
@ -70,6 +70,7 @@ fun DetailFloatingHeader(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
val progress = progressProvider()
alpha = progress
translationY = lerp((-20).dp, 0.dp, progress).toPx()
shadowElevation = 4.dp.toPx()
@ -89,7 +90,7 @@ fun DetailFloatingHeader(
.fillMaxWidth()
.padding(top = headerTopPadding, start = 16.dp, end = 16.dp)
.height(56.dp)
.graphicsLayer { alpha = progress },
.graphicsLayer { alpha = progressProvider() },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {

View file

@ -56,7 +56,7 @@ import org.jetbrains.compose.resources.stringResource
fun DetailHero(
meta: MetaDetails,
isTablet: Boolean = false,
scrollOffset: Int = 0,
scrollOffsetProvider: () -> Float = { 0f },
contentMaxWidth: Dp = 560.dp,
onHeightChanged: (Int) -> Unit = {},
heroTrailerSourceUrl: String? = null,
@ -113,7 +113,7 @@ fun DetailHero(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationY = scrollOffset * 0.5f
translationY = scrollOffsetProvider() * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},
@ -143,7 +143,7 @@ fun DetailHero(
.fillMaxSize()
.graphicsLayer {
alpha = trailerAlpha
translationY = scrollOffset * 0.5f
translationY = scrollOffsetProvider() * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},

View file

@ -42,10 +42,12 @@ import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomP
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.WatchedUiState
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.CachedInProgressItem
@ -53,6 +55,7 @@ import com.nuvio.app.features.watchprogress.CachedNextUpItem
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId
@ -63,6 +66,7 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
@ -79,7 +83,9 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.yield
@ -94,7 +100,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
animateCollectionGifs: Boolean = true,
animateCollectionGifsProvider: () -> Boolean = { true },
scrollToTopRequests: Flow<Unit> = emptyFlow(),
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
@ -110,6 +116,8 @@ fun HomeScreen(
ContinueWatchingPreferencesRepository.ensureLoaded()
WatchedRepository.ensureLoaded()
WatchProgressRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
@ -120,20 +128,20 @@ fun HomeScreen(
}.collectAsStateWithLifecycle()
val homeListState = rememberLazyListState()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val homeProgressDerivedState by remember {
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
homeProgressDerivedStateFlow()
}.collectAsStateWithLifecycle(HomeProgressDerivedState())
val continueWatchingPreferences = homeProgressDerivedState.continueWatchingPreferences
val watchedUiState = homeProgressDerivedState.watchedUiState
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState = homeProgressDerivedState.watchProgressUiState
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
}.collectAsStateWithLifecycle()
val visibleSeriesPosterTargets by remember {
homeVisibleSeriesPosterTargetsFlow()
}.collectAsStateWithLifecycle(emptyList())
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(scrollToTopRequests) {
@ -163,91 +171,11 @@ fun HomeScreen(
}
}
val isTraktProgressActive = remember(
isTraktAuthenticated,
traktSettingsUiState.watchProgressSource,
) {
shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
}
val effectiveWatchProgressEntries = remember(
watchProgressUiState.entries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
val filtered = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
}
filterEntriesForTraktContinueWatchingWindow(
entries = filtered,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val allNextUpSeedCandidates = remember(
watchProgressUiState.entries,
watchedUiState.items,
isTraktProgressActive,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
val filteredEntries = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
}
val filteredWatchedItems = if (isTraktProgressActive) {
watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) }
} else {
watchedUiState.items
}
buildHomeNextUpSeedCandidates(
progressEntries = filteredEntries,
watchedItems = filteredWatchedItems,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val recentNextUpSeedCandidates = remember(
allNextUpSeedCandidates,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
}
val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
}
val watchProgressSeedKey = remember(watchProgressUiState.entries) {
watchProgressUiState.entries.map { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
}
val isTraktProgressActive = homeProgressDerivedState.isTraktProgressActive
val activeNextUpSeedContentIds = homeProgressDerivedState.activeNextUpSeedContentIds
val currentNextUpSeedByContentId = homeProgressDerivedState.currentNextUpSeedByContentId
val visibleContinueWatchingEntries = homeProgressDerivedState.visibleContinueWatchingEntries
val watchProgressSeedKey = homeProgressDerivedState.watchProgressSeedKey
LaunchedEffect(visibleContinueWatchingEntries) {
if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) {
@ -255,32 +183,8 @@ fun HomeScreen(
}
}
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
}
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
visibleContinueWatchingEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry ->
shouldTreatAsActiveInProgressForNextUpSuppression(
progress = entry,
latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
)
}
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
}
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
}
val nextUpSuppressedSeriesIds = homeProgressDerivedState.nextUpSuppressedSeriesIds
val completedSeriesCandidates = homeProgressDerivedState.completedSeriesCandidates
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle()
@ -297,17 +201,7 @@ fun HomeScreen(
val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) {
ContinueWatchingEnrichmentCache.getSnapshots(activeProfileId)
}
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.isLoaded,
) {
if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
}
val shouldValidateMissingNextUpSeeds = homeProgressDerivedState.shouldValidateMissingNextUpSeeds
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
@ -662,14 +556,6 @@ fun HomeScreen(
val enabledHomeItems = remember(homeSettingsUiState.items) {
homeSettingsUiState.items.filter { it.enabled }
}
val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) {
enabledHomeItems
.filterNot { it.isCollection }
.mapNotNull { settingsItem -> sectionsMap[settingsItem.key] }
.flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT) }
.filter { item -> item.type.isHomeSeriesLikeType() }
.distinctBy { item -> watchedItemKey(item.type, item.id) }
}
LaunchedEffect(
visibleSeriesPosterTargets,
watchedUiState.items,
@ -856,7 +742,7 @@ fun HomeScreen(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
animateGifs = animateCollectionGifs,
animateGifsProvider = animateCollectionGifsProvider,
onFolderClick = onFolderClick,
)
}
@ -899,6 +785,160 @@ private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4
private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY
internal data class HomeProgressDerivedState(
val watchProgressUiState: WatchProgressUiState = WatchProgressUiState(),
val watchedUiState: WatchedUiState = WatchedUiState(),
val continueWatchingPreferences: ContinueWatchingPreferencesUiState = ContinueWatchingPreferencesUiState(),
val isTraktProgressActive: Boolean = false,
val activeNextUpSeedContentIds: Set<String> = emptySet(),
val currentNextUpSeedByContentId: Map<String, Pair<Int, Int>> = emptyMap(),
val visibleContinueWatchingEntries: List<WatchProgressEntry> = emptyList(),
val watchProgressSeedKey: List<Triple<String, Int?, Int?>> = emptyList(),
val nextUpSuppressedSeriesIds: Set<String> = emptySet(),
val completedSeriesCandidates: List<CompletedSeriesCandidate> = emptyList(),
val shouldValidateMissingNextUpSeeds: Boolean = false,
)
private fun homeProgressDerivedStateFlow(): Flow<HomeProgressDerivedState> =
combine(
WatchProgressRepository.uiState,
WatchedRepository.uiState,
ContinueWatchingPreferencesRepository.uiState,
TraktSettingsRepository.uiState,
TraktAuthRepository.isAuthenticated,
) { watchProgressUiState, watchedUiState, continueWatchingPreferences, traktSettingsUiState, isTraktAuthenticated ->
buildHomeProgressDerivedState(
watchProgressUiState = watchProgressUiState,
watchedUiState = watchedUiState,
continueWatchingPreferences = continueWatchingPreferences,
traktSettingsUiState = traktSettingsUiState,
isTraktAuthenticated = isTraktAuthenticated,
)
}.flowOn(Dispatchers.Default)
internal fun buildHomeProgressDerivedState(
watchProgressUiState: WatchProgressUiState,
watchedUiState: WatchedUiState,
continueWatchingPreferences: ContinueWatchingPreferencesUiState,
traktSettingsUiState: TraktSettingsUiState,
isTraktAuthenticated: Boolean,
nowEpochMs: Long = WatchProgressClock.nowEpochMs(),
): HomeProgressDerivedState {
val isTraktProgressActive = shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
val filteredEntries = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { entry ->
!WatchProgressRepository.isDroppedShow(entry.parentMetaId)
}
} else {
watchProgressUiState.entries
}
val filteredWatchedItems = if (isTraktProgressActive) {
watchedUiState.items.filter { item ->
!WatchProgressRepository.isDroppedShow(item.id)
}
} else {
watchedUiState.items
}
val effectiveWatchProgressEntries = filterEntriesForTraktContinueWatchingWindow(
entries = filteredEntries,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = nowEpochMs,
)
val allNextUpSeedCandidates = buildHomeNextUpSeedCandidates(
progressEntries = filteredEntries,
watchedItems = filteredWatchedItems,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = nowEpochMs,
)
val recentNextUpSeedCandidates = filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = nowEpochMs,
)
val activeNextUpSeedContentIds = allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
val currentNextUpSeedByContentId = allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
val visibleContinueWatchingEntries = effectiveWatchProgressEntries
.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
val watchProgressSeedKey = watchProgressUiState.entries.map { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
val latestCompletedAtBySeries = allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) ->
candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE
}
val nextUpSuppressedSeriesIds = visibleContinueWatchingEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry ->
shouldTreatAsActiveInProgressForNextUpSuppression(
progress = entry,
latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
)
}
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
val completedSeriesCandidates = recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
val shouldValidateMissingNextUpSeeds = if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
return HomeProgressDerivedState(
watchProgressUiState = watchProgressUiState,
watchedUiState = watchedUiState,
continueWatchingPreferences = continueWatchingPreferences,
isTraktProgressActive = isTraktProgressActive,
activeNextUpSeedContentIds = activeNextUpSeedContentIds,
currentNextUpSeedByContentId = currentNextUpSeedByContentId,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
watchProgressSeedKey = watchProgressSeedKey,
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
completedSeriesCandidates = completedSeriesCandidates,
shouldValidateMissingNextUpSeeds = shouldValidateMissingNextUpSeeds,
)
}
private fun homeVisibleSeriesPosterTargetsFlow(): Flow<List<MetaPreview>> =
combine(
HomeRepository.uiState,
HomeCatalogSettingsRepository.uiState,
) { homeUiState, homeSettingsUiState ->
buildVisibleSeriesPosterTargets(
settingsItems = homeSettingsUiState.items,
sections = homeUiState.sections,
)
}.flowOn(Dispatchers.Default)
internal fun buildVisibleSeriesPosterTargets(
settingsItems: List<HomeCatalogSettingsItem>,
sections: List<HomeCatalogSection>,
): List<MetaPreview> {
val sectionsMap = sections.associateBy(HomeCatalogSection::key)
return settingsItems
.asSequence()
.filter { settingsItem -> settingsItem.enabled && !settingsItem.isCollection }
.mapNotNull { settingsItem -> sectionsMap[settingsItem.key] }
.flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT).asSequence() }
.filter { item -> item.type.isHomeSeriesLikeType() }
.distinctBy { item -> watchedItemKey(item.type, item.id) }
.toList()
}
private suspend fun reconcileVisibleSeriesPosterBadges(
items: List<MetaPreview>,
watchedItems: List<WatchedItem>,

View file

@ -41,7 +41,7 @@ fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
animateGifs: Boolean = true,
animateGifsProvider: () -> Boolean = { true },
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
@ -51,7 +51,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
animateGifs = animateGifs,
animateGifsProvider = animateGifsProvider,
onFolderClick = onFolderClick,
)
} else {
@ -60,7 +60,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
animateGifs = animateGifs,
animateGifsProvider = animateGifsProvider,
onFolderClick = onFolderClick,
)
}
@ -72,7 +72,7 @@ private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
animateGifs: Boolean,
animateGifsProvider: () -> Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
val homeCatalogSettings by remember {
@ -91,7 +91,7 @@ private fun HomeCollectionRowSectionContent(
) { folder ->
CollectionFolderCard(
folder = folder,
animateGifs = animateGifs,
animateGifsProvider = animateGifsProvider,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
@ -101,7 +101,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard(
folder: CollectionFolder,
modifier: Modifier = Modifier,
animateGifs: Boolean = true,
animateGifsProvider: () -> Boolean = { true },
onClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
@ -154,7 +154,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
animateIfPossible = animateGifsProvider() && isAnimatedCollectionFolderImage(folder, imageUrl),
)
}
!folder.coverEmoji.isNullOrBlank() -> {

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.HorizontalPager
@ -29,7 +28,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -43,6 +41,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -57,6 +56,7 @@ import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
import kotlin.math.roundToInt
private const val HERO_BACKGROUND_PARALLAX = 0.055f
private const val HERO_BACKGROUND_SCALE = 1.14f
@ -95,6 +95,7 @@ fun HomeHeroSection(
val pagerState = rememberPagerState(pageCount = { items.size })
val coroutineScope = rememberCoroutineScope()
var pagerDragActive by remember { mutableStateOf(false) }
BoxWithConstraints(
modifier = modifier
@ -103,6 +104,7 @@ fun HomeHeroSection(
pagerState = pagerState,
itemCount = items.size,
coroutineScope = coroutineScope,
onDragActiveChange = { pagerDragActive = it },
)
.clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)),
) {
@ -113,42 +115,6 @@ fun HomeHeroSection(
)
val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() }
val scrollOffsetPx by remember(listState, heroHeightPx) {
derivedStateOf {
when {
listState == null -> 0f
listState.firstVisibleItemIndex > 0 -> heroHeightPx
else -> listState.firstVisibleItemScrollOffset.toFloat()
}
}
}
val heroScrollScale = heroBackgroundScrollScale(scrollOffsetPx)
val heroScrollTranslationY = heroBackgroundScrollTranslationY(scrollOffsetPx)
val currentPage = pagerState.currentPage.coerceIn(items.indices)
val visiblePages = listOf(
currentPage,
(currentPage - 1).coerceIn(items.indices),
(currentPage + 1).coerceIn(items.indices),
).distinct()
.mapNotNull { index ->
val pageOffset = heroPageOffset(pagerState, index)
val visibility = (1f - abs(pageOffset)).coerceIn(0f, 1f)
if (visibility <= 0f) {
null
} else {
HeroPageLayer(
page = index,
visibility = visibility,
offset = pageOffset,
)
}
}
.sortedBy(HeroPageLayer::visibility)
val currentItem = visiblePages
.lastOrNull()
?.page
?.let(items::get)
?: items[currentPage]
Box(
modifier = Modifier
@ -168,23 +134,15 @@ fun HomeHeroSection(
Box(
modifier = Modifier.fillMaxSize(),
) {
visiblePages.forEach { layer ->
AsyncImage(
model = items[layer.page].banner ?: items[layer.page].poster,
contentDescription = items[layer.page].name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroScrollTranslationY
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
}
HeroBackgroundLayers(
items = items,
pagerState = pagerState,
listState = listState,
layout = layout,
heroWidthPx = heroWidthPx,
heroHeightPx = heroHeightPx,
includePagerNeighbors = pagerDragActive,
)
Box(
modifier = Modifier
@ -232,20 +190,14 @@ fun HomeHeroSection(
.widthIn(max = layout.contentMaxWidth),
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
) {
visiblePages.forEach { layer ->
Box(
modifier = Modifier.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
},
) {
HeroContentBlock(
item = items[layer.page],
layout = layout,
onItemClick = onItemClick,
)
}
}
HeroContentLayers(
items = items,
pagerState = pagerState,
layout = layout,
heroWidthPx = heroWidthPx,
onItemClick = onItemClick,
includePagerNeighbors = pagerDragActive,
)
}
if (!layout.isTablet) {
@ -253,7 +205,7 @@ fun HomeHeroSection(
Surface(
modifier = Modifier
.clickable(enabled = onItemClick != null) {
onItemClick?.invoke(currentItem)
onItemClick?.invoke(currentHeroItem(items, pagerState))
},
color = MaterialTheme.colorScheme.onBackground,
contentColor = MaterialTheme.colorScheme.background,
@ -275,21 +227,14 @@ fun HomeHeroSection(
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, _ ->
val activeFraction = heroPageVisibility(pagerState, index)
Box(
modifier = Modifier
.clickable {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
HeroPageIndicatorDot(
pagerState = pagerState,
page = index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground)
.graphicsLayer {
alpha = 0.35f + (0.57f * activeFraction)
}
.width(8.dp + (24.dp * activeFraction))
.height(8.dp),
},
)
}
}
@ -300,11 +245,111 @@ fun HomeHeroSection(
}
}
private data class HeroPageLayer(
val page: Int,
val visibility: Float,
val offset: Float,
)
@Composable
private fun HeroBackgroundLayers(
items: List<MetaPreview>,
pagerState: PagerState,
listState: LazyListState?,
layout: HomeHeroLayout,
heroWidthPx: Float,
heroHeightPx: Float,
includePagerNeighbors: Boolean,
) {
val layerPages = rememberHeroLayerPages(
pagerState = pagerState,
itemCount = items.size,
includePagerNeighbors = includePagerNeighbors,
)
layerPages.forEach { page ->
val item = items[page]
AsyncImage(
model = item.banner ?: item.poster,
contentDescription = item.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val pageOffset = heroPageOffset(pagerState, page)
val scrollOffsetPx = heroScrollOffsetPx(listState, heroHeightPx)
val scrollScale = heroBackgroundScrollScale(scrollOffsetPx)
alpha = heroPageVisibility(pageOffset)
translationX = -pageOffset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroBackgroundScrollTranslationY(scrollOffsetPx)
scaleX = HERO_BACKGROUND_SCALE * scrollScale
scaleY = HERO_BACKGROUND_SCALE * scrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
}
}
@Composable
private fun HeroContentLayers(
items: List<MetaPreview>,
pagerState: PagerState,
layout: HomeHeroLayout,
heroWidthPx: Float,
onItemClick: ((MetaPreview) -> Unit)?,
includePagerNeighbors: Boolean,
) {
val layerPages = rememberHeroLayerPages(
pagerState = pagerState,
itemCount = items.size,
includePagerNeighbors = includePagerNeighbors,
)
layerPages.forEach { page ->
Box(
modifier = Modifier.graphicsLayer {
val pageOffset = heroPageOffset(pagerState, page)
alpha = heroPageVisibility(pageOffset)
translationX = -pageOffset * heroWidthPx * HERO_CONTENT_PARALLAX
},
) {
HeroContentBlock(
item = items[page],
layout = layout,
onItemClick = onItemClick,
)
}
}
}
@Composable
private fun rememberHeroLayerPages(
pagerState: PagerState,
itemCount: Int,
includePagerNeighbors: Boolean,
): List<Int> {
if (itemCount <= 0) return emptyList()
val currentPage = pagerState.currentPage.coerceIn(0, itemCount - 1)
val includeNeighbors = includePagerNeighbors || pagerState.isScrollInProgress
return remember(currentPage, includeNeighbors, itemCount) {
heroLayerPages(
currentPage = currentPage,
itemCount = itemCount,
includeNeighbors = includeNeighbors,
)
}
}
private fun heroLayerPages(
currentPage: Int,
itemCount: Int,
includeNeighbors: Boolean,
): List<Int> {
if (!includeNeighbors || itemCount == 1) return listOf(currentPage)
val neighbors = listOf(currentPage - 1, currentPage + 1)
.map { page -> page.coerceIn(0, itemCount - 1) }
.filter { page -> page != currentPage }
.distinct()
return neighbors + currentPage
}
private fun heroPageOffset(
pagerState: PagerState,
@ -314,8 +359,66 @@ private fun heroPageOffset(
private fun heroPageVisibility(
pagerState: PagerState,
page: Int,
): Float {
return (1f - abs(heroPageOffset(pagerState, page))).coerceIn(0f, 1f)
): Float = heroPageVisibility(heroPageOffset(pagerState, page))
private fun heroPageVisibility(pageOffset: Float): Float = (1f - abs(pageOffset)).coerceIn(0f, 1f)
private fun currentHeroItem(
items: List<MetaPreview>,
pagerState: PagerState,
): MetaPreview {
val currentPage = pagerState.currentPage.coerceIn(0, items.lastIndex)
val currentVisiblePages = heroLayerPages(
currentPage = currentPage,
itemCount = items.size,
includeNeighbors = true,
)
val selectedPage = currentVisiblePages.maxBy { page ->
heroPageVisibility(pagerState, page)
}
return items[selectedPage]
}
@Composable
private fun HeroPageIndicatorDot(
pagerState: PagerState,
page: Int,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground)
.graphicsLayer {
val activeFraction = heroPageVisibility(pagerState, page)
alpha = 0.35f + (0.57f * activeFraction)
}
.heroPageIndicatorSize(pagerState = pagerState, page = page),
)
}
private fun Modifier.heroPageIndicatorSize(
pagerState: PagerState,
page: Int,
): Modifier = layout { measurable, constraints ->
val activeFraction = heroPageVisibility(pagerState, page)
val widthPx = (8.dp.toPx() + (24.dp.toPx() * activeFraction)).roundToInt()
val heightPx = 8.dp.roundToPx()
val constrainedWidth = widthPx.coerceIn(constraints.minWidth, constraints.maxWidth)
val constrainedHeight = heightPx.coerceIn(constraints.minHeight, constraints.maxHeight)
val placeable = measurable.measure(
constraints.copy(
minWidth = constrainedWidth,
maxWidth = constrainedWidth,
minHeight = constrainedHeight,
maxHeight = constrainedHeight,
),
)
layout(constrainedWidth, constrainedHeight) {
placeable.place(0, 0)
}
}
@Composable
@ -519,6 +622,15 @@ private fun HeroMetaDot() {
)
}
private fun heroScrollOffsetPx(
listState: LazyListState?,
heroHeightPx: Float,
): Float = when {
listState == null -> 0f
listState.firstVisibleItemIndex > 0 -> heroHeightPx
else -> listState.firstVisibleItemScrollOffset.toFloat()
}
private fun heroBackgroundScrollScale(scrollOffsetPx: Float): Float {
val scaleIncrease = if (scrollOffsetPx < 0f) {
abs(scrollOffsetPx) * HERO_SCROLL_UP_SCALE_MULTIPLIER
@ -536,6 +648,7 @@ private fun Modifier.homeHeroPagerGesture(
pagerState: PagerState,
itemCount: Int,
coroutineScope: CoroutineScope,
onDragActiveChange: (Boolean) -> Unit,
): Modifier {
if (itemCount <= 1) return this
@ -550,47 +663,62 @@ private fun Modifier.homeHeroPagerGesture(
var totalDx = 0f
var totalDy = 0f
var dragging = false
var settleAnimationStarted = false
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
velocityTracker.addPosition(change.uptimeMillis, change.position)
try {
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!change.pressed) {
if (dragging) {
val targetPage = resolveHeroTargetPage(
startPage = startPage,
itemCount = itemCount,
totalDx = totalDx,
velocityX = velocityTracker.calculateVelocity().x,
widthPx = widthPx,
)
coroutineScope.launch {
pagerState.animateScrollToPage(targetPage)
if (!change.pressed) {
if (dragging) {
val targetPage = resolveHeroTargetPage(
startPage = startPage,
itemCount = itemCount,
totalDx = totalDx,
velocityX = velocityTracker.calculateVelocity().x,
widthPx = widthPx,
)
settleAnimationStarted = true
coroutineScope.launch {
try {
pagerState.animateScrollToPage(targetPage)
} finally {
onDragActiveChange(false)
}
}
}
break
}
val delta = change.position - change.previousPosition
totalDx += delta.x
totalDy += delta.y
if (!dragging) {
val horizontalDrag =
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
val verticalDrag =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
when {
verticalDrag -> break
horizontalDrag -> {
dragging = true
onDragActiveChange(true)
}
else -> continue
}
}
break
pagerState.dispatchRawDelta(-delta.x)
change.consume()
}
val delta = change.position - change.previousPosition
totalDx += delta.x
totalDy += delta.y
if (!dragging) {
val horizontalDrag =
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
val verticalDrag =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
when {
verticalDrag -> break
horizontalDrag -> dragging = true
else -> continue
}
} finally {
if (dragging && !settleAnimationStarted) {
onDragActiveChange(false)
}
pagerState.dispatchRawDelta(-delta.x)
change.consume()
}
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -78,6 +77,7 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.downloads.DownloadsRepository
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.material3.ExperimentalMaterial3Api
@ -842,6 +842,32 @@ private fun FilterChip(
// Stream List
// ---------------------------------------------------------------------------
private const val STREAM_CONTENT_TYPE_LOADING = "streams_loading"
private const val STREAM_CONTENT_TYPE_EMPTY = "streams_empty"
private const val STREAM_CONTENT_TYPE_SECTION_HEADER = "streams_section_header"
private const val STREAM_CONTENT_TYPE_SOURCE_HEADER = "streams_source_header"
private const val STREAM_CONTENT_TYPE_STREAM = "streams_stream"
private const val STREAM_CONTENT_TYPE_FOOTER_LOADING = "streams_footer_loading"
private const val STREAM_CONTENT_TYPE_BOTTOM_SPACER = "streams_bottom_spacer"
private data class StreamSectionRenderModel(
val sectionKey: String,
val group: AddonStreamGroup,
val sources: List<StreamSourceRenderModel>,
val showSourceHeaders: Boolean,
)
private data class StreamSourceRenderModel(
val sourceKey: String,
val sourceName: String,
val streams: List<StreamCardRenderModel>,
)
private data class StreamCardRenderModel(
val lazyKey: String,
val stream: StreamItem,
)
@Composable
internal fun StreamList(
uiState: StreamsUiState,
@ -857,6 +883,9 @@ internal fun StreamList(
val hasGroups = filteredGroups.isNotEmpty()
val hasAnyStreams = filteredGroups.any { it.streams.isNotEmpty() }
val anyLoading = filteredGroups.any { it.isLoading }
val streamSections = remember(filteredGroups) {
buildStreamSectionRenderModels(filteredGroups)
}
val torrentNotSupportedText = stringResource(Res.string.streams_torrent_not_supported)
val streamBadgeSettings by remember {
StreamBadgeSettingsRepository.ensureLoaded()
@ -873,22 +902,27 @@ internal fun StreamList(
) {
when {
hasGroups && anyLoading && !hasAnyStreams -> {
item {
item(
key = "streams_loading",
contentType = STREAM_CONTENT_TYPE_LOADING,
) {
LoadingStateBlock()
}
}
!hasAnyStreams && !uiState.isAnyLoading -> {
item {
item(
key = "streams_empty",
contentType = STREAM_CONTENT_TYPE_EMPTY,
) {
EmptyStateBlock(reason = uiState.emptyStateReason)
}
}
else -> {
filteredGroups.forEachIndexed { groupIndex, group ->
streamSections.forEach { section ->
streamSection(
sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group),
group = group,
section = section,
showHeader = uiState.selectedFilter == null,
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
@ -903,11 +937,17 @@ internal fun StreamList(
)
}
if (anyLoading) {
item {
item(
key = "streams_footer_loading",
contentType = STREAM_CONTENT_TYPE_FOOTER_LOADING,
) {
FooterLoadingBlock()
}
}
item {
item(
key = "streams_bottom_spacer",
contentType = STREAM_CONTENT_TYPE_BOTTOM_SPACER,
) {
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(80.dp)))
}
}
@ -915,9 +955,45 @@ internal fun StreamList(
}
}
private fun buildStreamSectionRenderModels(groups: List<AddonStreamGroup>): List<StreamSectionRenderModel> =
groups
.withDuplicateSafeLazyKeys { group -> streamSectionRenderKey(group) }
.map { keyedGroup ->
val group = keyedGroup.value
val sectionKey = keyedGroup.lazyKey.toString()
val streamsBySource = group.streams.groupBy(::streamSourceName)
val sortedSources = streamsBySource.keys.sortedBy { it.lowercase() }
StreamSectionRenderModel(
sectionKey = sectionKey,
group = group,
sources = sortedSources.map { sourceName ->
StreamSourceRenderModel(
sourceKey = streamSourceRenderKey(sectionKey = sectionKey, sourceName = sourceName),
sourceName = sourceName,
streams = streamsBySource[sourceName]
.orEmpty()
.withDuplicateSafeLazyKeys { stream ->
streamCardRenderKey(
sectionKey = sectionKey,
sourceName = sourceName,
stream = stream,
)
}
.map { keyedStream ->
StreamCardRenderModel(
lazyKey = keyedStream.lazyKey.toString(),
stream = keyedStream.value,
)
},
)
},
showSourceHeaders = sortedSources.size > 1,
)
}
private fun LazyListScope.streamSection(
sectionKey: String,
group: AddonStreamGroup,
section: StreamSectionRenderModel,
showHeader: Boolean,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
@ -930,10 +1006,14 @@ private fun LazyListScope.streamSection(
resumePositionMs: Long?,
resumeProgressFraction: Float?,
) {
val group = section.group
if (group.streams.isEmpty() && !group.isLoading) return
if (showHeader) {
item(key = "header_$sectionKey") {
item(
key = "stream_section_header_${section.sectionKey}",
contentType = STREAM_CONTENT_TYPE_SECTION_HEADER,
) {
StreamSectionHeader(
addonName = group.addonName,
isLoading = group.isLoading,
@ -941,31 +1021,22 @@ private fun LazyListScope.streamSection(
}
}
val streamsBySource = group.streams.groupBy { stream ->
stream.sourceName?.takeIf { it.isNotBlank() } ?: stream.addonName
}
val sortedSources = streamsBySource.keys.sortedBy { it.lowercase() }
val showSourceHeaders = sortedSources.size > 1
sortedSources.forEachIndexed { sourceIndex, sourceName ->
val sourceStreams = streamsBySource[sourceName].orEmpty()
if (showSourceHeaders) {
item(key = "source_${sectionKey}_$sourceIndex") {
StreamSourceHeader(sourceName = sourceName)
section.sources.forEach { source ->
if (section.showSourceHeaders) {
item(
key = source.sourceKey,
contentType = STREAM_CONTENT_TYPE_SOURCE_HEADER,
) {
StreamSourceHeader(sourceName = source.sourceName)
}
}
itemsIndexed(
items = sourceStreams,
key = { index, stream ->
streamCardRenderKey(
sectionKey = sectionKey,
sourceIndex = sourceIndex,
itemIndex = index,
stream = stream,
)
},
) { _, stream ->
items(
items = source.streams,
key = { renderItem -> renderItem.lazyKey },
contentType = { STREAM_CONTENT_TYPE_STREAM },
) { renderItem ->
val stream = renderItem.stream
val isSelectable = stream.isSelectableForPlayback(debridEnabled)
val isUnsupportedTorrentStream =
stream.needsLocalDebridResolve &&
@ -996,28 +1067,42 @@ private fun LazyListScope.streamSection(
}
}
internal fun streamSectionRenderKey(
groupIndex: Int,
group: AddonStreamGroup,
): String = "$groupIndex:${group.addonId}"
internal fun streamSectionRenderKey(group: AddonStreamGroup): String = buildString {
append("stream_section")
appendLazyKeyPart(group.addonId.takeIf { it.isNotBlank() } ?: group.addonName)
}
private fun streamSourceName(stream: StreamItem): String =
stream.sourceName?.takeIf { it.isNotBlank() } ?: stream.addonName
private fun streamSourceRenderKey(
sectionKey: String,
sourceName: String,
): String = buildString {
append("stream_source")
appendLazyKeyPart(sectionKey)
appendLazyKeyPart(sourceName)
}
internal fun streamCardRenderKey(
sectionKey: String,
sourceIndex: Int,
itemIndex: Int,
sourceName: String,
stream: StreamItem,
): String = buildString {
append(sectionKey)
append("stream_card")
appendLazyKeyPart(sectionKey)
appendLazyKeyPart(sourceName)
appendLazyKeyPart(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
appendLazyKeyPart(stream.fileIdx)
appendLazyKeyPart(stream.externalUrl)
}
private fun StringBuilder.appendLazyKeyPart(value: Any?) {
val text = value?.toString()?.trim().orEmpty()
append(':')
append(sourceIndex)
append(text.length)
append(':')
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
stream.externalUrl?.let {
append(':')
append(it)
}
append(text)
}
// ---------------------------------------------------------------------------

View file

@ -1,5 +1,14 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.memory.MemoryCache
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder =
memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}