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,
modifier: Modifier = Modifier,
) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
backFocusRequester.requestFocus()
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id,
modifier = modifier,
heroContent = {

View File

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

View File

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

View File

@@ -2,13 +2,18 @@ package hu.bbara.purefin.common.ui.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.ColumnScope
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.heightIn
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.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -16,20 +21,36 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis
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
internal fun TvMediaDetailScaffold(
heroImageUrl: String,
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any,
modifier: Modifier = Modifier,
heroHeightFraction: Float = 0.48f,
headerHeightFraction: Float = 0.48f,
heroContent: @Composable ColumnScope.() -> Unit,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
) {
@@ -51,24 +72,121 @@ internal fun TvMediaDetailScaffold(
modifier = Modifier.fillMaxSize()
) {
item {
Box(modifier = Modifier.fillMaxWidth()) {
MediaHero(
imageUrl = heroImageUrl,
backgroundColor = scheme.background,
heightFraction = heroHeightFraction,
modifier = Modifier.fillMaxWidth()
TvMediaDetailHeader(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
headerHeightFraction = headerHeightFraction,
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(
modifier = contentPadding
.padding(top = 104.dp, bottom = 36.dp)
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
.widthIn(max = contentMaxWidth)
) {
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
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -43,18 +45,30 @@ fun MediaResumeButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val primaryColor = MaterialTheme.colorScheme.primary
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary
val scheme = MaterialTheme.colorScheme
val primaryColor = scheme.primary
val onPrimaryColor = scheme.onPrimary
val focusShape = RoundedCornerShape(50)
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(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp)
.border(2.5.dp, if (isFocused) onPrimaryColor else Color.Transparent, RoundedCornerShape(50))
.clip(RoundedCornerShape(50))
.onFocusChanged { isFocused = it.isFocused }
.background(focusHaloColor, focusShape)
.border(3.dp, focusBorderColor, focusShape)
.clip(focusShape)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.clickable(onClick = onClick)
) {
// Bottom layer: inverted colors (visible for the remaining %)
@@ -87,6 +101,14 @@ fun MediaResumeButton(
) {
ButtonContent(text = text, color = onPrimaryColor)
}
if (isFocused) {
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, scheme.primary.copy(alpha = 0.95f), focusShape)
)
}
}
}