refactor: update TvMediaDetailScaffold to use artwork parameters and improve layout

This commit is contained in:
2026-04-06 14:19:59 +02:00
parent 6dc3442089
commit 2937ee836b
7 changed files with 180 additions and 125 deletions

View File

@@ -77,15 +77,16 @@ internal fun EpisodeScreenContent(
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) { LaunchedEffect(episode.id) {
backFocusRequester.requestFocus() playFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY), artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id, resetScrollKey = episode.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {

View File

@@ -53,15 +53,16 @@ internal fun MovieScreenContent(
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) { LaunchedEffect(movie.id) {
backFocusRequester.requestFocus() playFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY), artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id, resetScrollKey = movie.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {

View File

@@ -70,16 +70,21 @@ internal fun SeriesScreenContent(
season.episodes.firstOrNull { !it.watched } season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull() } ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
} }
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() } val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id) { LaunchedEffect(series.id, nextUpEpisode?.id) {
backFocusRequester.requestFocus() if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY), artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id, resetScrollKey = series.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {

View File

@@ -2,13 +2,18 @@ package hu.bbara.purefin.common.ui.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.ColumnScope import androidx.compose.foundation.layout.ColumnScope
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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -16,20 +21,36 @@ 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
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.MediaSynopsis
internal val MediaDetailHorizontalPadding = 48.dp internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailCornerArtworkTopPadding = 40.dp
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
private val MediaDetailMinimumContentWidth = 280.dp
private val MediaDetailContentArtworkGap = 32.dp
@Composable @Composable
internal fun TvMediaDetailScaffold( internal fun TvMediaDetailScaffold(
heroImageUrl: String, artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any, resetScrollKey: Any,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heroHeightFraction: Float = 0.48f, headerHeightFraction: Float = 0.48f,
heroContent: @Composable ColumnScope.() -> Unit, heroContent: @Composable ColumnScope.() -> Unit,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> } bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
) { ) {
@@ -51,24 +72,121 @@ internal fun TvMediaDetailScaffold(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
item { item {
Box(modifier = Modifier.fillMaxWidth()) { TvMediaDetailHeader(
MediaHero( artworkImageUrl = artworkImageUrl,
imageUrl = heroImageUrl, artworkWidth = artworkWidth,
backgroundColor = scheme.background, artworkAspectRatio = artworkAspectRatio,
heightFraction = heroHeightFraction, headerHeightFraction = headerHeightFraction,
modifier = Modifier.fillMaxWidth() heroContent = heroContent
)
}
bodyContent(contentPadding)
}
}
}
@Composable
private fun TvMediaDetailHeader(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
headerHeightFraction: Float,
heroContent: @Composable ColumnScope.() -> Unit
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val headerHeight = screenHeight * headerHeightFraction
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = headerHeight)
) {
val contentMaxWidth = (
maxWidth -
(MediaDetailHorizontalPadding * 2) -
artworkWidth -
MediaDetailContentArtworkGap
).coerceAtLeast(MediaDetailMinimumContentWidth)
Box(modifier = Modifier.fillMaxWidth()) {
TvMediaDetailCornerArtwork(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(
top = MediaDetailCornerArtworkTopPadding,
end = MediaDetailHorizontalPadding
)
) )
MediaHeroScrimOverlay()
Column( Column(
modifier = contentPadding modifier = Modifier
.padding(top = 104.dp, bottom = 36.dp) .align(Alignment.TopStart)
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
.widthIn(max = contentMaxWidth)
) { ) {
heroContent() heroContent()
} }
} }
} }
bodyContent(contentPadding)
} }
@Composable
private fun TvMediaDetailCornerArtwork(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.width(artworkWidth)
.aspectRatio(artworkAspectRatio)
.clip(MediaDetailCornerArtworkShape)
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
) {
PurefinAsyncImage(
model = artworkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.52f },
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.2f),
1.0f to scheme.background.copy(alpha = 0.56f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.06f),
0.65f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.74f)
)
)
)
)
} }
} }

View File

@@ -1,50 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
@Composable
fun MediaHero(
imageUrl: String,
backgroundColor: Color,
heightFraction: Float = 0.4f,
modifier: Modifier = Modifier,
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val heroHeight = screenHeight * heightFraction
Box(
modifier = modifier
.height(heroHeight)
.background(backgroundColor)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
backgroundColor.copy(alpha = 0.5f),
backgroundColor
)
)
)
)
}
}

View File

@@ -1,42 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@Composable
internal fun MediaHeroScrimOverlay(modifier: Modifier = Modifier) {
val background = MaterialTheme.colorScheme.background
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colors = listOf(
background,
background.copy(alpha = 0.95f),
background.copy(alpha = 0.7f),
background.copy(alpha = 0.15f)
)
)
)
)
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
background.copy(alpha = 0.05f),
background.copy(alpha = 0.2f),
background
)
)
)
)
}

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
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.BoxWithConstraints
@@ -43,18 +45,30 @@ fun MediaResumeButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val primaryColor = MaterialTheme.colorScheme.primary val scheme = MaterialTheme.colorScheme
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary val primaryColor = scheme.primary
val onPrimaryColor = scheme.onPrimary
val focusShape = RoundedCornerShape(50)
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.05f else 1.0f, label = "scale") val scale by animateFloatAsState(targetValue = if (isFocused) 1.08f else 1.0f, label = "scale")
val focusBorderColor by animateColorAsState(
targetValue = if (isFocused) scheme.onBackground else Color.Transparent,
label = "focus-border"
)
val focusHaloColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary.copy(alpha = 0.22f) else Color.Transparent,
label = "focus-halo"
)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp) .height(52.dp)
.border(2.5.dp, if (isFocused) onPrimaryColor else Color.Transparent, RoundedCornerShape(50)) .background(focusHaloColor, focusShape)
.clip(RoundedCornerShape(50)) .border(3.dp, focusBorderColor, focusShape)
.onFocusChanged { isFocused = it.isFocused } .clip(focusShape)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
// Bottom layer: inverted colors (visible for the remaining %) // Bottom layer: inverted colors (visible for the remaining %)
@@ -87,6 +101,14 @@ fun MediaResumeButton(
) { ) {
ButtonContent(text = text, color = onPrimaryColor) ButtonContent(text = text, color = onPrimaryColor)
} }
if (isFocused) {
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, scheme.primary.copy(alpha = 0.95f), focusShape)
)
}
} }
} }