meta hero layout changes

This commit is contained in:
tapframe 2026-04-01 18:31:41 +05:30
parent 3a54098907
commit b8f7ebdc4b
3 changed files with 417 additions and 359 deletions

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
@ -46,6 +48,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBackButton
@ -306,303 +309,313 @@ fun MetaDetailsScreen(
label = "detail_floating_header_progress", label = "detail_floating_header_progress",
) )
Box(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Column( val isTablet = maxWidth >= 720.dp
modifier = Modifier val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
.fillMaxSize() val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
.verticalScroll(scrollState),
) {
DetailHero(
meta = meta,
scrollOffset = scrollState.value,
onHeightChanged = { heroHeightPx = it },
)
Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 18.dp), .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
DetailActionButtons( DetailHero(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
isSaved = isSaved,
onPlayClick = {
when {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
onPlay?.invoke(
meta.type,
seriesAction.videoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
seriesAction.seasonNumber,
seriesAction.episodeNumber,
seriesAction.episodeTitle,
seriesAction.episodeThumbnail,
seriesPauseDescription,
seriesAction.resumePositionMs,
)
}
else -> {
onPlay?.invoke(
meta.type,
meta.id,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
null,
null,
null,
null,
meta.description,
movieProgress?.lastPositionMs,
)
}
}
},
onSaveClick = toggleSaved,
)
DetailMetaInfo(meta = meta)
if (hasEpisodes && hasProductionSection) {
DetailProductionSection(meta = meta)
}
DetailCastSection(cast = meta.cast)
if (hasTrailersSection) {
DetailTrailersSection(
trailers = meta.trailers,
onTrailerClick = resolveTrailer,
)
}
if (!hasEpisodes && hasProductionSection) {
DetailProductionSection(meta = meta)
}
DetailSeriesContent(
meta = meta, meta = meta,
progressByVideoId = watchProgressUiState.byVideoId, isTablet = isTablet,
watchedKeys = watchedUiState.watchedKeys, contentMaxWidth = contentMaxWidth,
onEpisodeClick = { video -> scrollOffset = scrollState.value,
val season = video.season onHeightChanged = { heroHeightPx = it },
val episode = video.episode
val playbackVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = season,
episodeNumber = episode,
fallbackVideoId = video.id,
)
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
?.takeUnless { it.isCompleted }
onPlay?.invoke(
meta.type,
playbackVideoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
season,
episode,
video.title,
video.thumbnail,
video.overview,
savedProgress?.lastPositionMs,
)
},
onEpisodeLongPress = { video ->
selectedEpisodeForActions = video
},
) )
if (hasEpisodes && hasAdditionalInfoSection) { Column(
DetailAdditionalInfoSection(meta = meta) modifier = Modifier
} .fillMaxWidth()
.padding(horizontal = contentHorizontalPadding)
.widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DetailActionButtons(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
isSaved = isSaved,
isTablet = isTablet,
onPlayClick = {
when {
(meta.type == "series" || hasEpisodes) && seriesAction != null -> {
onPlay?.invoke(
meta.type,
seriesAction.videoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
seriesAction.seasonNumber,
seriesAction.episodeNumber,
seriesAction.episodeTitle,
seriesAction.episodeThumbnail,
seriesPauseDescription,
seriesAction.resumePositionMs,
)
}
if (!hasEpisodes && hasAdditionalInfoSection) { else -> {
DetailAdditionalInfoSection(meta = meta) onPlay?.invoke(
} meta.type,
meta.id,
if (!hasEpisodes && hasCollectionSection) { meta.id,
DetailPosterRailSection( meta.type,
title = meta.collectionName.orEmpty(), meta.name,
items = meta.collectionItems, meta.logo,
watchedKeys = watchedUiState.watchedKeys, meta.poster,
onPosterClick = onOpenMeta, meta.background,
null,
null,
null,
null,
meta.description,
movieProgress?.lastPositionMs,
)
}
}
},
onSaveClick = toggleSaved,
) )
}
if (hasMoreLikeThisSection) { DetailMetaInfo(meta = meta)
DetailPosterRailSection(
title = "More Like This",
items = meta.moreLikeThis,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onOpenMeta,
)
}
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) if (hasEpisodes && hasProductionSection) {
} DetailProductionSection(meta = meta)
}
if (headerProgress <= 0.05f) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier.padding(
start = 12.dp,
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
DetailFloatingHeader(
meta = meta,
isSaved = isSaved,
progress = headerProgress,
onBack = onBack,
onToggleSaved = toggleSaved,
)
selectedEpisodeForActions?.let { selectedEpisode ->
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
WatchingState.isEpisodeWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = selectedEpisode,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.previousReleasedEpisodesBefore(
target = selectedEpisode,
todayIsoDate = todayIsoDate,
)
}
val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.releasedEpisodesForSeason(
seasonNumber = selectedEpisode.season,
todayIsoDate = todayIsoDate,
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = previousEpisodes,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = seasonEpisodes,
)
}
EpisodeWatchedActionSheet(
episode = selectedEpisode,
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
isEpisodeWatched = isSelectedEpisodeWatched,
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
isSeasonWatched = isSeasonWatched,
onDismiss = { selectedEpisodeForActions = null },
onToggleWatched = {
WatchingActions.toggleEpisodeWatched(
meta = meta,
episode = selectedEpisode,
isCurrentlyWatched = isSelectedEpisodeWatched,
)
},
onTogglePreviousWatched = {
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousEpisodes,
areCurrentlyWatched = arePreviousEpisodesWatched,
)
},
onToggleSeasonWatched = {
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
)
}
TrailerPlayerPopup(
visible = selectedTrailer != null,
trailerTitle = selectedTrailer?.displayName ?: selectedTrailer?.name.orEmpty(),
trailerType = selectedTrailer?.type.orEmpty(),
contentTitle = meta.name,
playbackSource = trailerPlaybackSource,
isLoading = trailerLoading,
errorMessage = trailerErrorMessage,
onDismiss = {
trailerRequestToken += 1
trailerLoading = false
trailerPlaybackSource = null
trailerErrorMessage = null
selectedTrailer = null
},
onRetry = selectedTrailer?.let { trailer ->
{ resolveTrailer(trailer) }
},
)
TraktListPickerDialog(
visible = showLibraryListPicker,
title = meta.name,
tabs = pickerTabs,
membership = pickerMembership,
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
},
onDismiss = {
if (!pickerPending) {
showLibraryListPicker = false
}
},
onSave = {
detailsScope.launch {
pickerPending = true
pickerError = null
runCatching {
LibraryRepository.applyMembershipChanges(
item = meta.toLibraryItem(savedAtEpochMs = 0L),
desiredMembership = pickerMembership,
)
}.onSuccess {
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists"
} }
pickerPending = false
DetailCastSection(cast = meta.cast)
if (hasTrailersSection) {
DetailTrailersSection(
trailers = meta.trailers,
onTrailerClick = resolveTrailer,
)
}
if (!hasEpisodes && hasProductionSection) {
DetailProductionSection(meta = meta)
}
DetailSeriesContent(
meta = meta,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
onEpisodeClick = { video ->
val season = video.season
val episode = video.episode
val playbackVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = season,
episodeNumber = episode,
fallbackVideoId = video.id,
)
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
?.takeUnless { it.isCompleted }
onPlay?.invoke(
meta.type,
playbackVideoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
season,
episode,
video.title,
video.thumbnail,
video.overview,
savedProgress?.lastPositionMs,
)
},
onEpisodeLongPress = { video ->
selectedEpisodeForActions = video
},
)
if (hasEpisodes && hasAdditionalInfoSection) {
DetailAdditionalInfoSection(meta = meta)
}
if (!hasEpisodes && hasAdditionalInfoSection) {
DetailAdditionalInfoSection(meta = meta)
}
if (!hasEpisodes && hasCollectionSection) {
DetailPosterRailSection(
title = meta.collectionName.orEmpty(),
items = meta.collectionItems,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onOpenMeta,
)
}
if (hasMoreLikeThisSection) {
DetailPosterRailSection(
title = "More Like This",
items = meta.moreLikeThis,
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onOpenMeta,
)
}
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
} }
Unit }
},
) if (headerProgress <= 0.05f) {
NuvioBackButton(
onClick = onBack,
modifier = Modifier.padding(
start = 12.dp,
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
),
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
DetailFloatingHeader(
meta = meta,
isSaved = isSaved,
progress = headerProgress,
onBack = onBack,
onToggleSaved = toggleSaved,
)
selectedEpisodeForActions?.let { selectedEpisode ->
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
WatchingState.isEpisodeWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = selectedEpisode,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.previousReleasedEpisodesBefore(
target = selectedEpisode,
todayIsoDate = todayIsoDate,
)
}
val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.releasedEpisodesForSeason(
seasonNumber = selectedEpisode.season,
todayIsoDate = todayIsoDate,
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = previousEpisodes,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = seasonEpisodes,
)
}
EpisodeWatchedActionSheet(
episode = selectedEpisode,
seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials",
isEpisodeWatched = isSelectedEpisodeWatched,
canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(),
arePreviousEpisodesWatched = arePreviousEpisodesWatched,
isSeasonWatched = isSeasonWatched,
onDismiss = { selectedEpisodeForActions = null },
onToggleWatched = {
WatchingActions.toggleEpisodeWatched(
meta = meta,
episode = selectedEpisode,
isCurrentlyWatched = isSelectedEpisodeWatched,
)
},
onTogglePreviousWatched = {
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousEpisodes,
areCurrentlyWatched = arePreviousEpisodesWatched,
)
},
onToggleSeasonWatched = {
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
)
}
TrailerPlayerPopup(
visible = selectedTrailer != null,
trailerTitle = selectedTrailer?.displayName ?: selectedTrailer?.name.orEmpty(),
trailerType = selectedTrailer?.type.orEmpty(),
contentTitle = meta.name,
playbackSource = trailerPlaybackSource,
isLoading = trailerLoading,
errorMessage = trailerErrorMessage,
onDismiss = {
trailerRequestToken += 1
trailerLoading = false
trailerPlaybackSource = null
trailerErrorMessage = null
selectedTrailer = null
},
onRetry = selectedTrailer?.let { trailer ->
{ resolveTrailer(trailer) }
},
)
TraktListPickerDialog(
visible = showLibraryListPicker,
title = meta.name,
tabs = pickerTabs,
membership = pickerMembership,
isPending = pickerPending,
errorMessage = pickerError,
onToggle = { listKey ->
pickerMembership = pickerMembership.toMutableMap().apply {
this[listKey] = !(this[listKey] == true)
}
},
onDismiss = {
if (!pickerPending) {
showLibraryListPicker = false
}
},
onSave = {
detailsScope.launch {
pickerPending = true
pickerError = null
runCatching {
LibraryRepository.applyMembershipChanges(
item = meta.toLibraryItem(savedAtEpochMs = 0L),
desiredMembership = pickerMembership,
)
}.onSuccess {
showLibraryListPicker = false
}.onFailure { error ->
pickerError = error.message ?: "Failed to update Trakt lists"
}
pickerPending = false
}
},
)
}
} }
} }
} }
@ -621,6 +634,13 @@ fun MetaDetailsScreen(
} }
} }
private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
if (!isTablet) {
maxWidth
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun TraktListPickerDialog( private fun TraktListPickerDialog(

View file

@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -30,6 +31,7 @@ fun DetailActionButtons(
playLabel: String = "Play", playLabel: String = "Play",
saveLabel: String = "Save", saveLabel: String = "Save",
isSaved: Boolean = false, isSaved: Boolean = false,
isTablet: Boolean = false,
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
) { ) {
@ -38,12 +40,22 @@ fun DetailActionButtons(
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = if (isTablet) {
Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
} else {
Arrangement.spacedBy(12.dp)
},
) { ) {
Button( Button(
onClick = onPlayClick, onClick = onPlayClick,
modifier = Modifier modifier = Modifier
.weight(1f) .then(
if (isTablet) {
Modifier.width(220.dp)
} else {
Modifier.weight(1f)
}
)
.height(50.dp), .height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@ -68,7 +80,13 @@ fun DetailActionButtons(
OutlinedButton( OutlinedButton(
onClick = onSaveClick, onClick = onSaveClick,
modifier = Modifier modifier = Modifier
.weight(1f) .then(
if (isTablet) {
Modifier.width(220.dp)
} else {
Modifier.weight(1f)
}
)
.height(50.dp), .height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),

View file

@ -2,13 +2,14 @@ package com.nuvio.app.features.details.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -19,6 +20,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -27,97 +29,115 @@ import com.nuvio.app.features.details.MetaDetails
@Composable @Composable
fun DetailHero( fun DetailHero(
meta: MetaDetails, meta: MetaDetails,
isTablet: Boolean = false,
scrollOffset: Int = 0, scrollOffset: Int = 0,
contentMaxWidth: Dp = 560.dp,
onHeightChanged: (Int) -> Unit = {}, onHeightChanged: (Int) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( BoxWithConstraints(
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth()
.aspectRatio(0.75f)
.onSizeChanged { onHeightChanged(it.height) }
.graphicsLayer {
clip = true
},
) { ) {
val heroHeight = detailHeroHeight(maxWidth, isTablet)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxWidth()
contentAlignment = Alignment.BottomCenter, .height(heroHeight)
.onSizeChanged { onHeightChanged(it.height) }
.graphicsLayer {
clip = true
},
) { ) {
val imageUrl = meta.background ?: meta.poster
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = meta.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationY = scrollOffset * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize(),
.height(260.dp) contentAlignment = Alignment.BottomCenter,
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
MaterialTheme.colorScheme.background,
),
),
),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (meta.logo != null) { val imageUrl = meta.background ?: meta.poster
if (imageUrl != null) {
AsyncImage( AsyncImage(
model = meta.logo, model = imageUrl,
contentDescription = "${meta.name} logo", contentDescription = meta.name,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.6f) .fillMaxSize()
.height(80.dp), .graphicsLayer {
contentScale = ContentScale.Fit, translationY = scrollOffset * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
) )
} else { } else {
Text( Box(
text = meta.name, modifier = Modifier
style = MaterialTheme.typography.displayLarge, .fillMaxSize()
color = MaterialTheme.colorScheme.onBackground, .background(MaterialTheme.colorScheme.surface),
textAlign = TextAlign.Center,
) )
} }
if (meta.genres.isNotEmpty()) { Box(
Spacer(modifier = Modifier.height(8.dp)) modifier = Modifier
Text( .fillMaxWidth()
text = meta.genres.take(3).joinToString(" \u2022 "), .height(260.dp)
style = MaterialTheme.typography.bodyMedium, .align(Alignment.BottomCenter)
color = MaterialTheme.colorScheme.onSurfaceVariant, .background(
textAlign = TextAlign.Center, Brush.verticalGradient(
) colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
MaterialTheme.colorScheme.background,
),
),
),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 32.dp else 18.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (meta.logo != null) {
AsyncImage(
model = meta.logo,
contentDescription = "${meta.name} logo",
modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
.widthIn(max = contentMaxWidth)
.height(if (isTablet) 72.dp else 80.dp),
alignment = Alignment.Center,
contentScale = ContentScale.Fit,
)
} else {
Text(
text = meta.name,
style = if (isTablet) MaterialTheme.typography.displaySmall else MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
}
if (meta.genres.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = meta.genres.take(3).joinToString(" \u2022 "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} }
} }
} }
} }
} }
private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp =
if (!isTablet) {
(maxWidth * 1.33f).coerceIn(420.dp, 760.dp)
} else {
(maxWidth * 0.42f).coerceIn(300.dp, 420.dp)
}