Tint hero scrims with a color sampled from the backdrop

rememberAmbientHeroColor reuses the existing Palette extraction (LRU
cached) to pull the dark muted tone of the current hero artwork, blended
toward the app background and animated between billboard rotations. TV
bleeds the tint under the shelf area behind the LazyColumn; mobile gets a
glow band above the hero fold.
This commit is contained in:
KhooLy 2026-07-02 00:48:17 +03:00
parent a2e85be74c
commit f2bb2d902e
3 changed files with 509 additions and 0 deletions

View file

@ -6,8 +6,12 @@ import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import android.graphics.Bitmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.palette.graphics.Palette
import coil3.BitmapImage
import coil3.imageLoader
@ -22,6 +26,16 @@ data class ContentColors(
val lightVibrant: Color = FluxaColors.textPrimary
)
@Composable
fun rememberAmbientHeroColor(artworkUrl: String?): Color {
val context = LocalContext.current
val colors by produceState(ContentColors(), artworkUrl) {
value = ThemeHelper.extractColors(context, artworkUrl)
}
val target = lerp(colors.darkMuted, FluxaColors.background, 0.35f)
return animateColorAsState(target, tween(700), label = "ambientHero").value
}
object ThemeHelper {
private val colorCache = android.util.LruCache<String, ContentColors>(80)

View file

@ -0,0 +1,482 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.fluxa.app.R
import com.fluxa.app.data.remote.Meta
@Composable
internal fun HomeHeroBackdrop(
selectedContent: Meta?,
activePreviewUrl: String?,
sharedPlayer: androidx.media3.exoplayer.ExoPlayer?,
seasonPostersOnHero: Boolean = true
) {
val youtubeId = remember(activePreviewUrl) { activePreviewUrl?.extractYoutubeVideoId() }
val ambient = rememberAmbientHeroColor(selectedContent?.homeHeroBackdrop(seasonPostersOnHero))
Box(modifier = Modifier.fillMaxSize()) {
when {
youtubeId != null -> {
YoutubeHeroPreview(
youtubeId = youtubeId,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.72f)
.align(Alignment.TopEnd)
.alpha(0.94f)
)
}
activePreviewUrl?.isDirectVideoPreviewUrl() == true && sharedPlayer != null -> {
AndroidView(
factory = { context ->
androidx.media3.ui.PlayerView(context).apply {
useController = false
player = sharedPlayer
}
},
update = { playerView ->
playerView.player = sharedPlayer
},
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.72f)
.align(Alignment.TopEnd)
.alpha(0.94f)
)
}
activePreviewUrl?.isNotBlank() == true -> {
WebHeroPreview(
url = activePreviewUrl,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.72f)
.align(Alignment.TopEnd)
.alpha(0.94f)
)
}
else -> {
val heroContext = LocalContext.current
val backdropUrl = selectedContent?.homeHeroBackdrop(seasonPostersOnHero)
val backdropRequest = remember(heroContext, backdropUrl) {
backdropUrl?.let {
coil3.request.ImageRequest.Builder(heroContext)
.data(it)
.memoryCacheKey(it)
.diskCacheKey(it)
.precision(coil3.size.Precision.INEXACT)
.build()
}
}
AsyncImage(
model = backdropRequest,
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.72f)
.align(Alignment.TopEnd)
.alpha(0.94f),
contentScale = ContentScale.Crop
)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colors = listOf(
Color(0xFF090B12),
Color(0xFF090B12).copy(alpha = 0.98f),
Color(0xFF090B12).copy(alpha = 0.82f),
Color(0xFF090B12).copy(alpha = 0.34f),
Color.Transparent
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF090B12).copy(alpha = 0.28f),
Color.Transparent,
Color(0xFF090B12).copy(alpha = 0.92f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxHeight()
.width(240.dp)
.background(
Brush.horizontalGradient(
colors = listOf(
Color(0xFF090B12),
Color(0xFF090B12).copy(alpha = 0.88f),
Color.Transparent
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.5f to Color.Transparent,
1f to ambient.copy(alpha = 0.42f)
)
)
)
}
}
@Composable
private fun YoutubeHeroPreview(
youtubeId: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val html = remember(youtubeId) { youtubeEmbedHtml(youtubeId) }
AndroidView(
factory = {
android.webkit.WebView(context).apply {
settings.javaScriptEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.domStorageEnabled = true
settings.loadsImagesAutomatically = true
webChromeClient = android.webkit.WebChromeClient()
tag = youtubeId
loadDataWithBaseURL("https://www.youtube.com", html, "text/html", "utf-8", null)
}
},
update = { webView ->
if (webView.tag != youtubeId) {
webView.tag = youtubeId
webView.loadDataWithBaseURL("https://www.youtube.com", html, "text/html", "utf-8", null)
}
},
modifier = modifier,
onRelease = { webView ->
webView.stopLoading()
webView.loadUrl("about:blank")
webView.webChromeClient = null
webView.destroy()
}
)
}
@Composable
private fun WebHeroPreview(
url: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
AndroidView(
factory = {
android.webkit.WebView(context).apply {
settings.javaScriptEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.domStorageEnabled = true
settings.loadsImagesAutomatically = true
webChromeClient = android.webkit.WebChromeClient()
tag = url
loadUrl(url)
}
},
update = { webView ->
if (webView.tag != url) {
webView.tag = url
webView.loadUrl(url)
}
},
modifier = modifier,
onRelease = { webView ->
webView.stopLoading()
webView.loadUrl("about:blank")
webView.webChromeClient = null
webView.destroy()
}
)
}
private fun youtubeEmbedHtml(youtubeId: String): String {
val cleanId = youtubeId.filter { it.isLetterOrDigit() || it == '_' || it == '-' }
return """
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body, iframe {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<iframe
src="https://www.youtube.com/embed/$cleanId?autoplay=1&mute=1&controls=0&playsinline=1&rel=0&modestbranding=1&loop=1&playlist=$cleanId"
frameborder="0"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen>
</iframe>
</body>
</html>
""".trimIndent()
}
@Composable
internal fun HomeHeroPanel(
movie: Meta,
logoUrl: String?,
lang: String,
playButtonFocusRequester: FocusRequester,
onNavigateDown: (() -> Unit)? = null,
onHeroFocused: (() -> Unit)? = null,
showButtons: Boolean = true,
aboveHeroFocusRequester: FocusRequester? = null,
modifier: Modifier = Modifier,
onPlayClick: () -> Unit,
onInfoClick: () -> Unit
) {
val buttonsAlpha by animateFloatAsState(
targetValue = if (showButtons) 1f else 0f,
animationSpec = tween(220),
label = "heroButtonsAlpha"
)
val logoCandidates = remember(movie.id, movie.logo, logoUrl) {
listOfNotNull(logoUrl, movie.logo).distinct()
}
androidx.compose.foundation.layout.Column(modifier = modifier.widthIn(max = 560.dp)) {
if (logoCandidates.isNotEmpty()) {
AsyncImage(
model = logoCandidates.first(),
contentDescription = movie.name,
modifier = Modifier
.height(94.dp)
.widthIn(max = 360.dp),
contentScale = ContentScale.Fit
)
} else {
Text(
text = movie.name,
color = Color(0xFFFFD94B),
fontSize = 54.sp,
lineHeight = 56.sp,
fontWeight = FontWeight.Black,
style = TextStyle(
shadow = Shadow(
color = Color.Black.copy(alpha = 0.55f),
blurRadius = 20f
)
)
)
}
Spacer(modifier = Modifier.height(18.dp))
HeroMetaLine(movie = movie, lang = lang)
Spacer(modifier = Modifier.height(10.dp))
HeroRatingLine(movie = movie)
Spacer(modifier = Modifier.height(18.dp))
if (!movie.description.isNullOrBlank()) {
Text(
text = movie.description.orEmpty(),
color = Color.White.copy(alpha = 0.88f),
fontSize = 18.sp,
lineHeight = 27.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 500.dp)
)
Spacer(modifier = Modifier.height(22.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.alpha(buttonsAlpha)
) {
Button(
onClick = onPlayClick,
modifier = Modifier
.focusRequester(playButtonFocusRequester)
.then(
if (aboveHeroFocusRequester != null) Modifier.focusProperties { up = aboveHeroFocusRequester }
else Modifier
)
.onFocusChanged { if (it.isFocused) onHeroFocused?.invoke() }
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown && onNavigateDown != null) {
onNavigateDown()
true
} else false
},
colors = ButtonDefaults.colors(
containerColor = Color.White,
contentColor = Color.Black
),
scale = ButtonDefaults.scale(focusedScale = 1.05f)
) {
Icon(
imageVector = FluxaIcons.PlayArrow,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = AppStrings.t(lang, "common.play"), fontWeight = FontWeight.Bold)
}
Button(
onClick = onInfoClick,
modifier = Modifier
.then(
if (aboveHeroFocusRequester != null) Modifier.focusProperties { up = aboveHeroFocusRequester }
else Modifier
)
.onFocusChanged { if (it.isFocused) onHeroFocused?.invoke() }
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown && onNavigateDown != null) {
onNavigateDown()
true
} else false
},
colors = ButtonDefaults.colors(
containerColor = Color.White.copy(alpha = 0.12f),
contentColor = Color.White
),
scale = ButtonDefaults.scale(focusedScale = 1.05f)
) {
Text(text = AppStrings.t(lang, "common.details"), fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
private fun HeroMetaLine(movie: Meta, lang: String) {
val pieces = buildList {
add(
when (movie.type.lowercase()) {
"series" -> AppStrings.t(lang, "auto.series")
"movie" -> AppStrings.t(lang, "auto.movie")
else -> movie.type.replaceFirstChar { it.uppercase() }
}
)
movie.genres?.firstOrNull()?.takeIf { it.isNotBlank() }?.let { add(it) }
movie.releaseInfo?.takeIf { it.isNotBlank() }?.let { add(it.take(4)) }
movie.runtime?.takeIf { it.isNotBlank() }?.let { add(it) }
}
Text(
text = pieces.joinToString(" "),
color = Color.White.copy(alpha = 0.82f),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
@Composable
private fun HeroRatingLine(movie: Meta) {
val rating = movie.imdbRating?.takeIf { it.isNotBlank() } ?: return
ImdbRatingBadge(rating = rating)
}
@Composable
private fun ImdbRatingBadge(rating: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clip(RoundedCornerShape(11.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
) {
Image(
painter = painterResource(id = R.drawable.imdb_logo),
contentDescription = null,
modifier = Modifier.height(18.dp)
)
Text(
text = rating,
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
internal fun HeroBadge(text: String, background: Color, content: Color) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = text,
color = content,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
}

View file

@ -2,6 +2,7 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
@ -208,6 +209,18 @@ private fun MobileHeroBackground(movie: Meta, seasonPostersOnHero: Boolean) {
)
Box(modifier = Modifier.fillMaxSize().background(HERO_GRADIENT))
val ambient = rememberAmbientHeroColor(backgroundUrl)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.55f to Color.Transparent,
0.84f to ambient.copy(alpha = 0.40f),
1f to Color.Transparent
)
)
)
}
}