diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 16a89381..bc223c4c 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -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 = { diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index e1be4600..9fdeaad2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -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 = { diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 9c34875f..6f745643 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -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 = { diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt index e384261a..915b16cb 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt @@ -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,27 +72,124 @@ internal fun TvMediaDetailScaffold( modifier = Modifier.fillMaxSize() ) { item { - Box(modifier = Modifier.fillMaxWidth()) { - MediaHero( - imageUrl = heroImageUrl, - backgroundColor = scheme.background, - heightFraction = heroHeightFraction, - modifier = Modifier.fillMaxWidth() - ) - MediaHeroScrimOverlay() - Column( - modifier = contentPadding - .padding(top = 104.dp, bottom = 36.dp) - ) { - heroContent() - } - } + 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 + ) + ) + Column( + modifier = Modifier + .align(Alignment.TopStart) + .padding( + start = MediaDetailHorizontalPadding, + top = MediaDetailHeaderTopPadding, + end = MediaDetailHorizontalPadding, + bottom = MediaDetailHeaderBottomPadding + ) + .widthIn(max = contentMaxWidth) + ) { + heroContent() + } + } + } +} + +@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) + ) + ) + ) + ) + } +} + @Composable internal fun MediaDetailSectionTitle( text: String, diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt deleted file mode 100644 index 8134404a..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt +++ /dev/null @@ -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 - ) - ) - ) - ) - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt deleted file mode 100644 index d8dd296b..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt +++ /dev/null @@ -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 - ) - ) - ) - ) -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt index 85ad8a2a..0b991c6d 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt @@ -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) + ) + } } }