refactor(MetaDetailsScreen): replace vertical scroll with lazy column

This commit is contained in:
tapframe 2026-06-10 20:05:39 +05:30
parent ed2d29ffc5
commit 83e475d23d

View file

@ -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<String?>(null) }
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, 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<TraktCommentReview>,
isCommentsLoading: Boolean,
isCommentsLoadingMore: Boolean,
commentsCurrentPage: Int,
commentsPageCount: Int,
commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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<MetaScreenSectionItem>,
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<Int>()
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<TraktCommentReview>,
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(