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 1e48bb62d..86e2cfa10 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 @@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -111,6 +112,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -189,20 +191,30 @@ fun MetaDetailsScreen( var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } + var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" } - LaunchedEffect(displayedMeta?.id, shouldShowComments) { - if (!shouldShowComments || displayedMeta == null) { + LaunchedEffect(displayedMeta?.id) { + deferredMetaWorkAllowed = false + if (displayedMeta != null) { + delay(250) + deferredMetaWorkAllowed = true + } + } + + LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) { + if (displayedMeta == null || !shouldShowComments) { comments = emptyList() commentsCurrentPage = 0 commentsPageCount = 0 commentsError = null return@LaunchedEffect } + if (!deferredMetaWorkAllowed) return@LaunchedEffect isCommentsLoading = true commentsError = null try { @@ -216,8 +228,9 @@ fun MetaDetailsScreen( isCommentsLoading = false } - LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) { + LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) { val metaForRatings = displayedMeta + if (!deferredMetaWorkAllowed) return@LaunchedEffect if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) { episodeImdbRatings = emptyMap() return@LaunchedEffect @@ -462,7 +475,8 @@ fun MetaDetailsScreen( ) } } - LaunchedEffect(meta.type, debridWarmupTarget) { + LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) { + if (!deferredMetaWorkAllowed) return@LaunchedEffect AddonStreamWarmupRepository.preload( type = meta.type, videoId = debridWarmupTarget.videoId, @@ -509,11 +523,16 @@ fun MetaDetailsScreen( var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle() - LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) { + LaunchedEffect( + heroTrailerPlaybackEnabled, + heroTrailerCandidate?.id, + heroTrailerCandidate?.key, + deferredMetaWorkAllowed, + ) { heroTrailerPlaybackSource = null heroTrailerReady = false heroTrailerFinished = false - if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { + if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { return@LaunchedEffect } val resolvedSource = runCatching { @@ -718,7 +737,7 @@ fun MetaDetailsScreen( savedProgress?.lastPositionMs, ) } - val scrollState = rememberScrollState() + val listState = rememberLazyListState() val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -728,7 +747,20 @@ fun MetaDetailsScreen( } var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f + val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + } + val heroScrollOffset = detailScrollOffsetPx.toInt() + val headerTarget = if ( + heroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) + ) { + 1f + } else { + 0f + } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl ?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails } @@ -737,7 +769,7 @@ fun MetaDetailsScreen( ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && !isLeavingDetails && - (heroHeightPx == 0 || scrollState.value <= thresholdPx) + (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) val headerProgress by animateFloatAsState( targetValue = headerTarget, animationSpec = tween( @@ -751,7 +783,7 @@ fun MetaDetailsScreen( val isTablet = maxWidth >= 720.dp val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) - val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground + val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed Box(modifier = Modifier.fillMaxSize()) { if (cinematicEnabled) { @@ -772,123 +804,120 @@ fun MetaDetailsScreen( ) } } - Column( + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .zIndex(1f) - .verticalScroll(scrollState), + .zIndex(1f), ) { - DetailHero( - meta = meta, - isTablet = isTablet, - contentMaxWidth = contentMaxWidth, - scrollOffset = scrollState.value, - onHeightChanged = { heroHeightPx = it }, - heroTrailerSourceUrl = heroTrailerSourceUrl, - heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, - heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, - heroTrailerMuted = heroTrailerMuted, - onHeroTrailerMuteToggle = { - HeroTrailerAudioState.toggleMuted() - }, - onHeroTrailerReady = { - if (!heroTrailerFinished) { - heroTrailerReady = true - } - }, - onHeroTrailerEnded = { - heroTrailerReady = false - heroTrailerFinished = true - }, - onHeroTrailerError = { - heroTrailerReady = false - heroTrailerFinished = true - }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentHorizontalPadding) - .widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ConfiguredMetaSections( - settings = metaScreenSettingsUiState, + item(key = "detail-hero") { + DetailHero( meta = meta, isTablet = isTablet, - playButtonLabel = playButtonLabel, - isSaved = isSaved, - isWatched = isWatched, - onPrimaryPlayClick = onPrimaryPlayClick, - onPrimaryPlayLongClick = onPrimaryPlayLongClick, - onSaveClick = toggleSaved, - onSaveLongClick = openLibraryListPicker, - onWatchedClick = toggleWatched, - showManualPlayOption = showManualPlayOption, - preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, - preferredEpisodeNumber = seriesAction?.episodeNumber, - hasProductionSection = hasProductionSection, - hasTrailersSection = hasTrailersSection, - hasEpisodes = hasEpisodes, - hasAdditionalInfoSection = hasAdditionalInfoSection, - hasCollectionSection = hasCollectionSection, - hasMoreLikeThisSection = hasMoreLikeThisSection, - shouldShowComments = shouldShowComments, - comments = comments, - isCommentsLoading = isCommentsLoading, - isCommentsLoadingMore = isCommentsLoadingMore, - commentsCurrentPage = commentsCurrentPage, - commentsPageCount = commentsPageCount, - commentsError = commentsError, - episodeImdbRatings = episodeImdbRatings, - onRetryComments = { - detailsScope.launch { - isCommentsLoading = true - commentsError = null - try { - val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) - comments = result.items - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (e: Exception) { - commentsError = e.message ?: getString(Res.string.details_comments_load_failed) - } - isCommentsLoading = false + contentMaxWidth = contentMaxWidth, + scrollOffset = heroScrollOffset, + onHeightChanged = { heroHeightPx = it }, + heroTrailerSourceUrl = heroTrailerSourceUrl, + heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, + heroTrailerReady = heroTrailerReady, + heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerMuted = heroTrailerMuted, + onHeroTrailerMuteToggle = { + HeroTrailerAudioState.toggleMuted() + }, + onHeroTrailerReady = { + if (!heroTrailerFinished) { + heroTrailerReady = true } }, - onLoadMoreComments = { - detailsScope.launch { - isCommentsLoadingMore = true - try { - val nextPage = commentsCurrentPage + 1 - val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) - val existingIds = comments.map { it.id }.toSet() - val newComments = result.items.filter { it.id !in existingIds } - comments = comments + newComments - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (_: Exception) { } - isCommentsLoadingMore = false - } + onHeroTrailerEnded = { + heroTrailerReady = false + heroTrailerFinished = true + }, + onHeroTrailerError = { + heroTrailerReady = false + heroTrailerFinished = true }, - onCommentClick = { review -> selectedComment = review }, - onTrailerClick = resolveTrailer, - progressByVideoId = progressByVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - onEpisodeClick = onEpisodePlayClick, - onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, - onSeasonLongPress = { season -> selectedSeasonForActions = season }, - onOpenMeta = onOpenMeta, - onCastClick = onCastClick, - onCompanyClick = onCompanyClick, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, ) + } + configuredMetaSectionItems( + settings = metaScreenSettingsUiState, + meta = meta, + isTablet = isTablet, + contentHorizontalPadding = contentHorizontalPadding, + contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, + onWatchedClick = toggleWatched, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, + preferredEpisodeNumber = seriesAction?.episodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = { + detailsScope.launch { + isCommentsLoading = true + commentsError = null + try { + val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) + comments = result.items + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (e: Exception) { + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) + } + isCommentsLoading = false + } + }, + onLoadMoreComments = { + detailsScope.launch { + isCommentsLoadingMore = true + try { + val nextPage = commentsCurrentPage + 1 + val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) + val existingIds = comments.map { it.id }.toSet() + val newComments = result.items.filter { it.id !in existingIds } + comments = comments + newComments + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (_: Exception) { } + isCommentsLoadingMore = false + } + }, + onCommentClick = { review -> selectedComment = review }, + onTrailerClick = resolveTrailer, + progressByVideoId = progressByVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + onEpisodeClick = onEpisodePlayClick, + onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + + item(key = "detail-bottom-spacer") { Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp))) } } @@ -901,7 +930,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - scrollState.value + translationY = heroHeightPx.toFloat() - detailScrollOffsetPx } .background( Brush.verticalGradient( @@ -1263,6 +1292,228 @@ private fun MetaDetails.toMetaPreview(): MetaPreview = genres = genres, ) +private fun LazyListScope.configuredMetaSectionItems( + settings: MetaScreenSettingsUiState, + meta: MetaDetails, + isTablet: Boolean, + contentHorizontalPadding: Dp, + contentMaxWidth: Dp, + playButtonLabel: String, + isSaved: Boolean, + isWatched: Boolean, + onPrimaryPlayClick: () -> Unit, + onPrimaryPlayLongClick: (() -> Unit)?, + onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, + onWatchedClick: () -> Unit, + showManualPlayOption: Boolean, + preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + isCommentsLoadingMore: Boolean, + commentsCurrentPage: Int, + commentsPageCount: Int, + commentsError: String?, + episodeImdbRatings: Map, Double>, + onRetryComments: () -> Unit, + onLoadMoreComments: () -> Unit, + onCommentClick: (TraktCommentReview) -> Unit, + onTrailerClick: (MetaTrailer) -> Unit, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + onEpisodeClick: (MetaVideo) -> Unit, + onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, + onOpenMeta: ((MetaPreview) -> Unit)?, + onCastClick: ((MetaPerson, String?) -> Unit)?, + onCompanyClick: ((MetaCompany, String) -> Unit)?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, +) { + val enabledItems = settings.items.filter { it.enabled } + fun sectionHasContent(key: MetaScreenSectionKey): Boolean = + metaSectionHasContent( + key = key, + meta = meta, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + commentsError = commentsError, + ) + + fun addSectionItem( + key: String, + sectionItems: List, + forceTabLayout: Boolean = settings.tabLayout, + ) { + item(key = key) { + DetailSectionContainer( + horizontalPadding = contentHorizontalPadding, + contentMaxWidth = contentMaxWidth, + ) { + ConfiguredMetaSections( + settings = settings.copy( + items = sectionItems, + tabLayout = forceTabLayout, + ), + meta = meta, + isTablet = isTablet, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, + onWatchedClick = onWatchedClick, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber, + preferredEpisodeNumber = preferredEpisodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = onRetryComments, + onLoadMoreComments = onLoadMoreComments, + onCommentClick = onCommentClick, + onTrailerClick = onTrailerClick, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onEpisodeClick = onEpisodeClick, + onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + + if (!settings.tabLayout) { + enabledItems + .filter { sectionHasContent(it.key) } + .forEach { section -> + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = false, + ) + } + return + } + + val processedGroups = mutableSetOf() + enabledItems.forEach { section -> + val groupId = section.tabGroup + if (groupId == null) { + if (sectionHasContent(section.key)) { + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = true, + ) + } + } else if (groupId !in processedGroups) { + processedGroups.add(groupId) + val groupMembers = enabledItems.filter { item -> + item.tabGroup == groupId && sectionHasContent(item.key) + } + if (groupMembers.isNotEmpty()) { + addSectionItem( + key = "detail-section-group-$groupId", + sectionItems = groupMembers, + forceTabLayout = groupMembers.size > 1, + ) + } + } + } +} + +@Composable +private fun DetailSectionContainer( + horizontalPadding: Dp, + contentMaxWidth: Dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(bottom = 20.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentMaxWidth == Dp.Unspecified) { + Modifier + } else { + Modifier.widthIn(max = contentMaxWidth) + }, + ), + ) { + content() + } + } +} + +private fun metaSectionHasContent( + key: MetaScreenSectionKey, + meta: MetaDetails, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + commentsError: String?, +): Boolean = + when (key) { + MetaScreenSectionKey.ACTIONS -> true + MetaScreenSectionKey.OVERVIEW -> true + MetaScreenSectionKey.PRODUCTION -> hasProductionSection + MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty() + MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank()) + MetaScreenSectionKey.TRAILERS -> hasTrailersSection + MetaScreenSectionKey.EPISODES -> hasEpisodes + MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection + MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection + MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection + } + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections(