mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
perf: reduce compose recomposition hotspots
This commit is contained in:
parent
3203a98a72
commit
62d4297063
14 changed files with 914 additions and 408 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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() -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue