From 62d4297063ca594e998e62024c7bdda293dc47e0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:29:10 +0530 Subject: [PATCH] perf: reduce compose recomposition hotspots --- .gitignore | 1 + .../core/ui/PlatformImageLoader.android.kt | 7 +- .../commonMain/kotlin/com/nuvio/app/App.kt | 263 ++++++++++-- .../nuvio/app/core/ui/PlatformImageLoader.kt | 5 +- .../app/core/ui/PosterCardStyleCompose.kt | 12 +- .../com/nuvio/app/core/ui/ShelfComponents.kt | 15 +- .../app/features/details/MetaDetailsScreen.kt | 94 +++-- .../components/DetailFloatingHeader.kt | 7 +- .../features/details/components/DetailHero.kt | 6 +- .../com/nuvio/app/features/home/HomeScreen.kt | 326 ++++++++------- .../components/HomeCollectionRowSection.kt | 14 +- .../home/components/HomeHeroSection.kt | 380 ++++++++++++------ .../app/features/streams/StreamsScreen.kt | 181 ++++++--- .../app/core/ui/PlatformImageLoader.ios.kt | 11 +- 14 files changed, 914 insertions(+), 408 deletions(-) diff --git a/.gitignore b/.gitignore index 93f75cabd..77f5bac83 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt index cda2e814b..9aeda2152 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt @@ -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()) } - } \ No newline at end of file + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index b42f920b2..b4f05012f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { + val routeActiveState = remember(navController) { + mutableStateOf(navController.currentDestination?.hasRoute() ?: 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() == true + +@Composable +private fun DismissResumePromptOnPlaybackDestination( + navController: NavController, + onPlaybackDestination: () -> Unit, +) { + val currentOnPlaybackDestination by rememberUpdatedState(onPlaybackDestination) + + DisposableEffect(navController) { + fun maybeDismiss(destination: NavDestination?) { + if ( + destination?.hasRoute() == true || + destination?.hasRoute() == 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(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(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() == true || - currentBackStackEntry?.destination?.hasRoute() == 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() == 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, modifier: Modifier = Modifier, searchFocusRequestCount: Int = 0, - rootActionsEnabled: Boolean = true, homeScrollToTopRequests: Flow, searchScrollToTopRequests: Flow, libraryScrollToTopRequests: Flow, settingsRootActionRequests: Flow, - 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, + homeScrollToTopRequests: Flow, + 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, +) { + SearchScreen( + modifier = Modifier.fillMaxSize(), + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, + searchFocusRequestCount = searchFocusRequestCount, + scrollToTopRequests = searchScrollToTopRequests, + ) +} + +@Composable +private fun AppLibraryTabContent( + libraryScrollToTopRequests: Flow, + 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, + rootActionRequests: Flow, + 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, + 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt index 925ce3ba3..e20979046 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt @@ -1,5 +1,8 @@ package com.nuvio.app.core.ui import coil3.ImageLoader +import coil3.PlatformContext -internal expect fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder \ No newline at end of file +internal expect fun ImageLoader.Builder.configurePlatformImageLoader( + context: PlatformContext, +): ImageLoader.Builder diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt index 4d69e732e..8b98eab31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt @@ -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 -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index 00d907518..77eee6e41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -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 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 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 NuvioShelfSection( } } } else { - items(entries) { entry -> + items( + items = entries, + contentType = { "poster" }, + ) { entry -> if (animatePlacement) { Box(modifier = Modifier.animateItem()) { itemContent(entry) } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index f3b9555ef..636a59510 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 89aecce61..73cc9d973 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 32d6dd332..8bd0c7d11 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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 }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 1a185ae95..15c8e59f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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 = 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 = emptySet(), + val currentNextUpSeedByContentId: Map> = emptyMap(), + val visibleContinueWatchingEntries: List = emptyList(), + val watchProgressSeedKey: List> = emptyList(), + val nextUpSuppressedSeriesIds: Set = emptySet(), + val completedSeriesCandidates: List = emptyList(), + val shouldValidateMissingNextUpSeeds: Boolean = false, +) + +private fun homeProgressDerivedStateFlow(): Flow = + 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> = + combine( + HomeRepository.uiState, + HomeCatalogSettingsRepository.uiState, + ) { homeUiState, homeSettingsUiState -> + buildVisibleSeriesPosterTargets( + settingsItems = homeSettingsUiState.items, + sections = homeUiState.sections, + ) + }.flowOn(Dispatchers.Default) + +internal fun buildVisibleSeriesPosterTargets( + settingsItems: List, + sections: List, +): List { + 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, watchedItems: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index da63fe5d1..9cd753a5a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -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() -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 30d73d555..57d172f83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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, + 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, + 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 { + 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 { + 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, + 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() } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index f7176ef77..bb3f2a2c7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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, + val showSourceHeaders: Boolean, +) + +private data class StreamSourceRenderModel( + val sourceKey: String, + val sourceName: String, + val streams: List, +) + +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): List = + 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) } // --------------------------------------------------------------------------- diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt index 3240c6e30..0b0cf034a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt @@ -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 \ No newline at end of file +internal actual fun ImageLoader.Builder.configurePlatformImageLoader( + context: PlatformContext, +): ImageLoader.Builder = + memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + }