mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
refactor: update TvMediaDetailScaffold to use artwork parameters and improve layout
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user