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 3def1e2b..7369fed1 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.statusBars @@ -46,6 +48,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioBackButton @@ -306,303 +309,313 @@ fun MetaDetailsScreen( label = "detail_floating_header_progress", ) - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState), - ) { - DetailHero( - meta = meta, - scrollOffset = scrollState.value, - onHeightChanged = { heroHeightPx = it }, - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val isTablet = maxWidth >= 720.dp + val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp + val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + .fillMaxSize() + .verticalScroll(scrollState), ) { - DetailActionButtons( - 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( + DetailHero( 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 - }, + isTablet = isTablet, + contentMaxWidth = contentMaxWidth, + scrollOffset = scrollState.value, + onHeightChanged = { heroHeightPx = it }, ) - if (hasEpisodes && hasAdditionalInfoSection) { - DetailAdditionalInfoSection(meta = meta) - } + Column( + 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) { - DetailAdditionalInfoSection(meta = meta) - } - - if (!hasEpisodes && hasCollectionSection) { - DetailPosterRailSection( - title = meta.collectionName.orEmpty(), - items = meta.collectionItems, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onOpenMeta, + 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, ) - } - if (hasMoreLikeThisSection) { - DetailPosterRailSection( - title = "More Like This", - items = meta.moreLikeThis, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onOpenMeta, - ) - } + DetailMetaInfo(meta = meta) - Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) - } - } - - 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" + if (hasEpisodes && hasProductionSection) { + DetailProductionSection(meta = meta) } - 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 @OptIn(ExperimentalMaterial3Api::class) private fun TraktListPickerDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 271f5344..f1b04a11 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -30,6 +31,7 @@ fun DetailActionButtons( playLabel: String = "Play", saveLabel: String = "Save", isSaved: Boolean = false, + isTablet: Boolean = false, onPlayClick: () -> Unit = {}, onSaveClick: () -> Unit = {}, ) { @@ -38,12 +40,22 @@ fun DetailActionButtons( Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = if (isTablet) { + Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) + } else { + Arrangement.spacedBy(12.dp) + }, ) { Button( onClick = onPlayClick, modifier = Modifier - .weight(1f) + .then( + if (isTablet) { + Modifier.width(220.dp) + } else { + Modifier.weight(1f) + } + ) .height(50.dp), shape = RoundedCornerShape(40.dp), colors = ButtonDefaults.buttonColors( @@ -68,7 +80,13 @@ fun DetailActionButtons( OutlinedButton( onClick = onSaveClick, modifier = Modifier - .weight(1f) + .then( + if (isTablet) { + Modifier.width(220.dp) + } else { + Modifier.weight(1f) + } + ) .height(50.dp), shape = RoundedCornerShape(40.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 7cd0f0bc..a091925f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -2,13 +2,14 @@ package com.nuvio.app.features.details.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.onSizeChanged import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage @@ -27,97 +29,115 @@ import com.nuvio.app.features.details.MetaDetails @Composable fun DetailHero( meta: MetaDetails, + isTablet: Boolean = false, scrollOffset: Int = 0, + contentMaxWidth: Dp = 560.dp, onHeightChanged: (Int) -> Unit = {}, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(0.75f) - .onSizeChanged { onHeightChanged(it.height) } - .graphicsLayer { - clip = true - }, + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), ) { + val heroHeight = detailHeroHeight(maxWidth, isTablet) + Box( modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.BottomCenter, + .fillMaxWidth() + .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( modifier = Modifier - .fillMaxWidth() - .height(260.dp) - .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, + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter, ) { - if (meta.logo != null) { + val imageUrl = meta.background ?: meta.poster + if (imageUrl != null) { AsyncImage( - model = meta.logo, - contentDescription = "${meta.name} logo", + model = imageUrl, + contentDescription = meta.name, modifier = Modifier - .fillMaxWidth(0.6f) - .height(80.dp), - contentScale = ContentScale.Fit, + .fillMaxSize() + .graphicsLayer { + translationY = scrollOffset * 0.5f + scaleX = 1.08f + scaleY = 1.08f + }, + alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, + contentScale = ContentScale.Crop, ) } else { - Text( - text = meta.name, - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), ) } - 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, - ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .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 = 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) + }