feat: landscape posters

This commit is contained in:
tapframe 2026-04-13 18:48:34 +05:30
parent 1a59fc0a20
commit 2d1ab47919
10 changed files with 212 additions and 35 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@ -98,6 +99,9 @@ fun NuvioPosterCard(
modifier: Modifier = Modifier,
shape: NuvioPosterShape = NuvioPosterShape.Poster,
detailLine: String? = null,
showTitleBelow: Boolean = true,
bottomLeftLogoUrl: String? = null,
bottomLeftText: String? = null,
isWatched: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
@ -105,6 +109,7 @@ fun NuvioPosterCard(
val posterCardStyle = rememberPosterCardStyleUiState()
val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp)
val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)
val catalogLogoOverlaySize = catalogLogoOverlaySize(basePosterWidthDp = posterCardStyle.widthDp)
Column(
modifier = modifier.width(cardWidth),
@ -137,6 +142,35 @@ fun NuvioPosterCard(
overflow = TextOverflow.Ellipsis,
)
}
if (!bottomLeftLogoUrl.isNullOrBlank() || !bottomLeftText.isNullOrBlank()) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 10.dp, vertical = 10.dp),
) {
if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage(
model = bottomLeftLogoUrl,
contentDescription = "$title logo",
modifier = Modifier
.width(catalogLogoOverlaySize.width)
.height(catalogLogoOverlaySize.height),
contentScale = ContentScale.Fit,
)
} else {
Text(
text = bottomLeftText.orEmpty(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = catalogLogoOverlaySize.textMaxWidth),
)
}
}
}
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
modifier = Modifier
@ -144,21 +178,25 @@ fun NuvioPosterCard(
.padding(6.dp),
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!detailLine.isNullOrBlank()) {
if (showTitleBelow) {
Text(
text = detailLine,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!detailLine.isNullOrBlank()) {
Text(
text = detailLine,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Box(modifier = Modifier.height(0.dp))
}
} else {
Box(modifier = Modifier.height(0.dp))
}
@ -255,16 +293,28 @@ private val NuvioPosterShape.aspectRatio: Float
get() = when (this) {
NuvioPosterShape.Poster -> 0.675f
NuvioPosterShape.Square -> 1f
NuvioPosterShape.Landscape -> 1.77f
NuvioPosterShape.Landscape -> PosterLandscapeAspectRatio
}
private const val LandscapeWidthScale = 180f / 110f
private data class CatalogLogoOverlaySize(
val width: Dp,
val height: Dp,
val textMaxWidth: Dp,
)
private fun catalogLogoOverlaySize(basePosterWidthDp: Int): CatalogLogoOverlaySize =
when {
basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp)
basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp)
basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp)
else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp)
}
private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp =
when (this) {
NuvioPosterShape.Poster -> basePosterWidthDp.dp
NuvioPosterShape.Square -> basePosterWidthDp.dp
NuvioPosterShape.Landscape -> (basePosterWidthDp * LandscapeWidthScale).dp
NuvioPosterShape.Landscape -> landscapePosterWidth(basePosterWidthDp)
}
@OptIn(ExperimentalFoundationApi::class)

View file

@ -0,0 +1,13 @@
package com.nuvio.app.core.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal const val PosterLandscapeAspectRatio = 1.77f
private const val PosterLandscapeWidthScale = 180f / 110f
internal fun landscapePosterWidth(basePosterWidthDp: Int): Dp =
(basePosterWidthDp * PosterLandscapeWidthScale).dp
internal fun landscapePosterHeightForWidth(width: Dp): Dp =
(width.value / PosterLandscapeAspectRatio).dp

View file

@ -17,12 +17,14 @@ private data class StoredPosterCardStylePreferences(
val widthDp: Int = DefaultPosterCardWidthDp,
val heightDp: Int = DefaultPosterCardHeightDp,
val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp,
val catalogLandscapeModeEnabled: Boolean = false,
)
data class PosterCardStyleUiState(
val widthDp: Int = DefaultPosterCardWidthDp,
val heightDp: Int = DefaultPosterCardHeightDp,
val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp,
val catalogLandscapeModeEnabled: Boolean = false,
)
object PosterCardStyleRepository {
@ -69,6 +71,13 @@ object PosterCardStyleRepository {
persist()
}
fun setCatalogLandscapeModeEnabled(enabled: Boolean) {
ensureLoaded()
if (_uiState.value.catalogLandscapeModeEnabled == enabled) return
_uiState.value = _uiState.value.copy(catalogLandscapeModeEnabled = enabled)
persist()
}
fun resetToDefaults() {
ensureLoaded()
if (_uiState.value == PosterCardStyleUiState()) return
@ -97,6 +106,7 @@ object PosterCardStyleRepository {
widthDp = widthDp,
heightDp = heightDp,
cornerRadiusDp = cornerRadiusDp,
catalogLandscapeModeEnabled = stored.catalogLandscapeModeEnabled,
)
} else {
PosterCardStyleUiState()
@ -110,6 +120,7 @@ object PosterCardStyleRepository {
widthDp = _uiState.value.widthDp,
heightDp = _uiState.value.heightDp,
cornerRadiusDp = _uiState.value.cornerRadiusDp,
catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled,
),
),
)

View file

@ -56,6 +56,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
@ -157,6 +159,17 @@ private fun PersonDetailContent(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled
val skeletonPosterWidth = if (isLandscapeShelfMode) {
landscapePosterWidth(posterCardStyle.widthDp)
} else {
posterCardStyle.widthDp.dp
}
val skeletonPosterHeight = if (isLandscapeShelfMode) {
landscapePosterHeightForWidth(skeletonPosterWidth)
} else {
posterCardStyle.heightDp.dp
}
val accentColor = MaterialTheme.colorScheme.primary
val allCredits = remember(person.movieCredits, person.tvCredits) {
@ -448,6 +461,17 @@ private fun PersonDetailSkeleton(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled
val skeletonPosterWidth = if (isLandscapeShelfMode) {
landscapePosterWidth(posterCardStyle.widthDp)
} else {
posterCardStyle.widthDp.dp
}
val skeletonPosterHeight = if (isLandscapeShelfMode) {
landscapePosterHeightForWidth(skeletonPosterWidth)
} else {
posterCardStyle.heightDp.dp
}
val accentColor = MaterialTheme.colorScheme.primary
val avatarCacheKey = avatarTransitionKey
val platformContext = LocalPlatformContext.current
@ -602,24 +626,26 @@ private fun PersonDetailSkeleton(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
repeat(4) {
Column(modifier = Modifier.width(110.dp)) {
Column(modifier = Modifier.width(skeletonPosterWidth)) {
Box(
modifier = Modifier
.width(110.dp)
.height(163.dp)
.width(skeletonPosterWidth)
.height(skeletonPosterHeight)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Spacer(modifier = Modifier.height(6.dp))
SkeletonLine(
widthFraction = 1f,
height = 16.dp,
)
Spacer(modifier = Modifier.height(4.dp))
SkeletonLine(
widthFraction = 0.56f,
height = 12.dp,
)
if (!isLandscapeShelfMode) {
Spacer(modifier = Modifier.height(6.dp))
SkeletonLine(
widthFraction = 1f,
height = 16.dp,
)
Spacer(modifier = Modifier.height(4.dp))
SkeletonLine(
widthFraction = 0.56f,
height = 12.dp,
)
}
}
}
}

View file

@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
@ -299,6 +301,17 @@ private fun EntityHeroSection(
@Composable
private fun EntityBrowseSkeleton() {
val posterCardStyle = rememberPosterCardStyleUiState()
val isLandscapeShelfMode = posterCardStyle.catalogLandscapeModeEnabled
val skeletonPosterWidth = if (isLandscapeShelfMode) {
landscapePosterWidth(posterCardStyle.widthDp)
} else {
posterCardStyle.widthDp.dp
}
val skeletonPosterHeight = if (isLandscapeShelfMode) {
landscapePosterHeightForWidth(skeletonPosterWidth)
} else {
posterCardStyle.heightDp.dp
}
Column(
modifier = Modifier
@ -355,8 +368,8 @@ private fun EntityBrowseSkeleton() {
repeat(4) {
Box(
modifier = Modifier
.width(110.dp)
.height(163.dp)
.width(skeletonPosterWidth)
.height(skeletonPosterHeight)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)

View file

@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
@ -62,6 +63,8 @@ private fun HomeCatalogRowSectionContent(
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
NuvioShelfSection(
title = section.title,
entries = entries,
@ -74,6 +77,7 @@ private fun HomeCatalogRowSectionContent(
) { item ->
HomePosterCard(
item = item,
useLandscapeBackdropMode = posterCardStyle.catalogLandscapeModeEnabled,
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.collection.Collection
@ -99,7 +100,7 @@ private fun CollectionFolderCard(
}
PosterShape.Landscape -> {
cardWidth = 180.dp
aspectRatio = 1.77f
aspectRatio = PosterLandscapeAspectRatio
}
PosterShape.Square -> {
cardWidth = 120.dp

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
@ -12,16 +13,23 @@ import com.nuvio.app.features.home.PosterShape
fun HomePosterCard(
item: MetaPreview,
modifier: Modifier = Modifier,
useLandscapeBackdropMode: Boolean = false,
isWatched: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val isLandscapeMode = useLandscapeBackdropMode || posterCardStyle.catalogLandscapeModeEnabled
NuvioPosterCard(
title = item.name,
imageUrl = item.poster,
imageUrl = if (isLandscapeMode) (item.banner ?: item.poster) else item.poster,
modifier = modifier,
shape = item.posterShape.toNuvioPosterShape(),
detailLine = item.releaseInfo?.let { formatReleaseDateForDisplay(it) },
shape = if (isLandscapeMode) NuvioPosterShape.Landscape else item.posterShape.toNuvioPosterShape(),
detailLine = if (isLandscapeMode) null else item.releaseInfo?.let { formatReleaseDateForDisplay(it) },
showTitleBelow = !isLandscapeMode,
bottomLeftLogoUrl = if (isLandscapeMode) item.logo else null,
bottomLeftText = if (isLandscapeMode && item.logo.isNullOrBlank()) item.name else null,
isWatched = isWatched,
onClick = onClick,
onLongClick = onLongClick,

View file

@ -32,6 +32,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
import com.nuvio.app.core.ui.landscapePosterWidth
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
@Composable
@ -182,6 +184,16 @@ fun HomeSkeletonHero(
fun HomeSkeletonRow(modifier: Modifier = Modifier) {
val brush = rememberHomeSkeletonBrush()
val posterCardStyle = rememberPosterCardStyleUiState()
val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) {
landscapePosterWidth(posterCardStyle.widthDp)
} else {
posterCardStyle.widthDp.dp
}
val skeletonHeight = if (posterCardStyle.catalogLandscapeModeEnabled) {
landscapePosterHeightForWidth(skeletonWidth)
} else {
posterCardStyle.heightDp.dp
}
Column(
modifier = modifier.fillMaxWidth(),
@ -211,8 +223,8 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) {
repeat(4) {
Box(
modifier = Modifier
.width(posterCardStyle.widthDp.dp)
.height(posterCardStyle.heightDp.dp)
.width(skeletonWidth)
.height(skeletonHeight)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(brush),
)

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -51,8 +53,10 @@ internal fun LazyListScope.posterCustomizationSettingsContent(
isTablet = isTablet,
widthDp = uiState.widthDp,
cornerRadiusDp = uiState.cornerRadiusDp,
catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled,
onWidthSelected = PosterCardStyleRepository::setWidthDp,
onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp,
onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled,
)
}
}
@ -65,8 +69,10 @@ private fun PosterCardStyleControls(
isTablet: Boolean,
widthDp: Int,
cornerRadiusDp: Int,
catalogLandscapeModeEnabled: Boolean,
onWidthSelected: (Int) -> Unit,
onCornerRadiusSelected: (Int) -> Unit,
onCatalogLandscapeModeChange: (Boolean) -> Unit,
) {
val widthOptions = listOf(
PresetOption("Compact", 104),
@ -111,6 +117,39 @@ private fun PosterCardStyleControls(
options = radiusOptions,
onSelected = onCornerRadiusSelected,
)
PosterLandscapeModeToggleRow(
checked = catalogLandscapeModeEnabled,
onCheckedChange = onCatalogLandscapeModeChange,
)
}
}
@Composable
private fun PosterLandscapeModeToggleRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Landscape mode for shelf posters",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
}