feat: add dominant colour bg

This commit is contained in:
tapframe 2026-06-27 20:54:33 +05:30
parent d6d541ac0b
commit 8a68d9e923
8 changed files with 231 additions and 31 deletions

View file

@ -427,6 +427,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kmpalette.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.kermit)
implementation(libs.supabase.postgrest)

View file

@ -742,6 +742,14 @@
<string name="settings_meta_cast_description">Principal cast list.</string>
<string name="settings_meta_cinematic_background">Cinematic Background</string>
<string name="settings_meta_cinematic_background_description">Blurred backdrop behind content, similar to stream screen.</string>
<string name="settings_meta_background_mode">Background Mode</string>
<string name="settings_meta_background_mode_description">Choose how artwork appears behind metadata pages.</string>
<string name="settings_meta_background_mode_normal">Normal</string>
<string name="settings_meta_background_mode_normal_description">Use the standard app background.</string>
<string name="settings_meta_background_mode_cinematic">Cinematic</string>
<string name="settings_meta_background_mode_cinematic_description">Show a soft blurred backdrop behind the page.</string>
<string name="settings_meta_background_mode_dominant">Dominant Color</string>
<string name="settings_meta_background_mode_dominant_description">Match the page background to the main color from the backdrop.</string>
<string name="settings_meta_collection">Collection</string>
<string name="settings_meta_collection_description">Related collection or franchise rail.</string>
<string name="settings_meta_comments">Comments</string>

View file

@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@ -52,6 +53,7 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
@ -111,6 +113,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import com.kmpalette.extensions.painter.rememberPainterDominantColorState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
@ -755,15 +758,46 @@ fun MetaDetailsScreen(
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colorScheme = MaterialTheme.colorScheme
val isTablet = maxWidth >= 720.dp
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed
val backdropUrl = meta.background ?: meta.poster
val backgroundMode = metaScreenSettingsUiState.backgroundMode
val dominantColorEnabled = backgroundMode == MetaScreenBackgroundMode.DominantColor &&
deferredMetaWorkAllowed &&
!backdropUrl.isNullOrBlank()
var dominantBackdropPainter by remember(meta.id, backdropUrl) {
mutableStateOf<Painter?>(null)
}
val dominantColorState = rememberPainterDominantColorState(
defaultColor = colorScheme.background,
defaultOnColor = colorScheme.onBackground,
)
LaunchedEffect(dominantColorEnabled, dominantBackdropPainter) {
val painter = dominantBackdropPainter
if (dominantColorEnabled && painter != null) {
runCatching { dominantColorState.updateFrom(painter) }
}
}
val dominantBackdropTargetColor = if (dominantColorEnabled) {
dominantBackdropBlendColor(dominantColorState.color, colorScheme.background)
} else {
colorScheme.background
}
val dominantBackdropColor by animateColorAsState(
targetValue = dominantBackdropTargetColor,
animationSpec = tween(
durationMillis = 320,
easing = LinearOutSlowInEasing,
),
label = "detail_dominant_backdrop_color",
)
Box(modifier = Modifier.fillMaxSize()) {
if (cinematicEnabled) {
val backdropUrl = meta.background ?: meta.poster
if (backdropUrl != null) {
when (backgroundMode) {
MetaScreenBackgroundMode.Normal -> Unit
MetaScreenBackgroundMode.Cinematic -> if (deferredMetaWorkAllowed && backdropUrl != null) {
AsyncImage(
model = backdropUrl,
contentDescription = null,
@ -775,7 +809,14 @@ fun MetaDetailsScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.92f)),
.background(colorScheme.background.copy(alpha = 0.92f)),
)
}
MetaScreenBackgroundMode.DominantColor -> if (deferredMetaWorkAllowed) {
Box(
modifier = Modifier
.fillMaxSize()
.background(dominantBackdropColor),
)
}
}
@ -797,6 +838,12 @@ fun MetaDetailsScreen(
heroTrailerReady = heroTrailerReady,
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
heroTrailerMuted = heroTrailerMuted,
heroGradientColor = dominantBackdropColor.takeIf { dominantColorEnabled },
onBackdropLoaded = { painter ->
if (dominantColorEnabled) {
dominantBackdropPainter = painter
}
},
onHeroTrailerMuteToggle = {
HeroTrailerAudioState.toggleMuted()
},
@ -897,8 +944,9 @@ fun MetaDetailsScreen(
}
}
if (cinematicEnabled && heroHeightPx > 0) {
val blendColor = MaterialTheme.colorScheme.background
if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx > 0) {
val blendColor = dominantBackdropColor.takeIf { dominantColorEnabled }
?: colorScheme.background
Box(
modifier = Modifier
.zIndex(0.5f)
@ -1794,3 +1842,16 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
private fun dominantBackdropBlendColor(dominantColor: Color, backgroundColor: Color): Color =
backgroundColor.blendTowards(dominantColor, fraction = 0.42f)
private fun Color.blendTowards(target: Color, fraction: Float): Color {
val clamped = fraction.coerceIn(0f, 1f)
return Color(
red = red + (target.red - red) * clamped,
green = green + (target.green - green) * clamped,
blue = blue + (target.blue - blue) * clamped,
alpha = alpha + (target.alpha - alpha) * clamped,
)
}

View file

@ -42,6 +42,7 @@ data class MetaScreenSectionItem(
data class MetaScreenSettingsUiState(
val items: List<MetaScreenSectionItem> = emptyList(),
val backgroundMode: MetaScreenBackgroundMode = MetaScreenBackgroundMode.Normal,
val cinematicBackground: Boolean = false,
val heroTrailerPlayback: Boolean = false,
val tabLayout: Boolean = false,
@ -49,6 +50,34 @@ data class MetaScreenSettingsUiState(
val blurUnwatchedEpisodes: Boolean = false,
)
enum class MetaScreenBackgroundMode {
Normal,
Cinematic,
DominantColor,
;
val usesBackdropBackground: Boolean
get() = this != Normal
companion object {
fun parse(raw: String?): MetaScreenBackgroundMode? = when (raw?.lowercase()) {
"normal" -> Normal
"cinematic" -> Cinematic
"dominant_color" -> DominantColor
else -> null
}
fun persist(mode: MetaScreenBackgroundMode): String = when (mode) {
Normal -> "normal"
Cinematic -> "cinematic"
DominantColor -> "dominant_color"
}
fun fromLegacyCinematic(enabled: Boolean): MetaScreenBackgroundMode =
if (enabled) Cinematic else Normal
}
}
enum class MetaEpisodeCardStyle {
Horizontal,
List,
@ -79,6 +108,8 @@ private data class StoredMetaScreenSectionPreference(
@Serializable
private data class StoredMetaScreenSettingsPayload(
val items: List<StoredMetaScreenSectionPreference> = emptyList(),
@SerialName("background_mode")
val backgroundMode: String? = null,
val cinematicBackground: Boolean = false,
@SerialName("hero_trailer_playback")
val heroTrailerPlayback: Boolean = false,
@ -159,7 +190,7 @@ object MetaScreenSettingsRepository {
private var hasLoaded = false
private var preferences: MutableMap<MetaScreenSectionKey, StoredMetaScreenSectionPreference> = mutableMapOf()
private var cinematicBackground: Boolean = false
private var backgroundMode: MetaScreenBackgroundMode = MetaScreenBackgroundMode.Normal
private var heroTrailerPlayback: Boolean = false
private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
@ -176,7 +207,8 @@ object MetaScreenSettingsRepository {
json.decodeFromString<StoredMetaScreenSettingsPayload>(payload)
}.getOrNull()
if (parsed != null) {
cinematicBackground = parsed.cinematicBackground
backgroundMode = MetaScreenBackgroundMode.parse(parsed.backgroundMode)
?: MetaScreenBackgroundMode.fromLegacyCinematic(parsed.cinematicBackground)
heroTrailerPlayback = parsed.heroTrailerPlayback
tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
@ -197,7 +229,7 @@ object MetaScreenSettingsRepository {
fun onProfileChanged() {
hasLoaded = false
preferences.clear()
cinematicBackground = false
backgroundMode = MetaScreenBackgroundMode.Normal
heroTrailerPlayback = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
@ -207,8 +239,12 @@ object MetaScreenSettingsRepository {
}
fun setCinematicBackground(enabled: Boolean) {
setBackgroundMode(MetaScreenBackgroundMode.fromLegacyCinematic(enabled))
}
fun setBackgroundMode(mode: MetaScreenBackgroundMode) {
ensureLoaded()
cinematicBackground = enabled
backgroundMode = mode
publish()
persist()
}
@ -257,7 +293,7 @@ object MetaScreenSettingsRepository {
fun clearLocalState() {
hasLoaded = false
preferences.clear()
cinematicBackground = false
backgroundMode = MetaScreenBackgroundMode.Normal
heroTrailerPlayback = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
@ -272,9 +308,10 @@ object MetaScreenSettingsRepository {
tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
blurUnwatchedEpisodes: Boolean = false,
backgroundMode: MetaScreenBackgroundMode? = null,
) {
ensureLoaded()
this.cinematicBackground = cinematicBackground
this.backgroundMode = backgroundMode ?: MetaScreenBackgroundMode.fromLegacyCinematic(cinematicBackground)
this.heroTrailerPlayback = heroTrailerPlayback
this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle
@ -301,7 +338,7 @@ object MetaScreenSettingsRepository {
fun resetToDefaults() {
ensureLoaded()
preferences.clear()
cinematicBackground = false
backgroundMode = MetaScreenBackgroundMode.Normal
heroTrailerPlayback = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
@ -369,7 +406,8 @@ object MetaScreenSettingsRepository {
tabGroup = preference?.tabGroup,
)
},
cinematicBackground = cinematicBackground,
backgroundMode = backgroundMode,
cinematicBackground = backgroundMode.usesBackdropBackground,
heroTrailerPlayback = heroTrailerPlayback,
tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle,
@ -382,7 +420,8 @@ object MetaScreenSettingsRepository {
json.encodeToString(
StoredMetaScreenSettingsPayload(
items = preferences.values.sortedBy { it.order },
cinematicBackground = cinematicBackground,
backgroundMode = MetaScreenBackgroundMode.persist(backgroundMode),
cinematicBackground = backgroundMode.usesBackdropBackground,
heroTrailerPlayback = heroTrailerPlayback,
tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -62,6 +63,8 @@ fun DetailHero(
heroTrailerReady: Boolean = false,
heroTrailerPlayWhenReady: Boolean = false,
heroTrailerMuted: Boolean = true,
heroGradientColor: Color? = null,
onBackdropLoaded: (Painter) -> Unit = {},
onHeroTrailerMuteToggle: () -> Unit = {},
onHeroTrailerReady: () -> Unit = {},
onHeroTrailerEnded: () -> Unit = {},
@ -81,6 +84,7 @@ fun DetailHero(
val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +
8.dp +
((40.dp - muteIconSize) / 2)
val bottomGradientColor = heroGradientColor ?: MaterialTheme.colorScheme.background
var logoLoadError by remember(meta.id, meta.logo) {
mutableStateOf(false)
}
@ -111,9 +115,10 @@ fun DetailHero(
translationY = scrollOffset * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},
},
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
onSuccess = { state -> onBackdropLoaded(state.painter) },
)
} else {
Box(
@ -190,12 +195,12 @@ fun DetailHero(
Brush.verticalGradient(
colorStops = arrayOf(
0.00f to Color.Transparent,
0.16f to MaterialTheme.colorScheme.background.copy(alpha = 0.04f),
0.32f to MaterialTheme.colorScheme.background.copy(alpha = 0.14f),
0.50f to MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
0.68f to MaterialTheme.colorScheme.background.copy(alpha = 0.62f),
0.84f to MaterialTheme.colorScheme.background.copy(alpha = 0.84f),
1.00f to MaterialTheme.colorScheme.background,
0.16f to bottomGradientColor.copy(alpha = 0.04f),
0.32f to bottomGradientColor.copy(alpha = 0.14f),
0.50f to bottomGradientColor.copy(alpha = 0.34f),
0.68f to bottomGradientColor.copy(alpha = 0.62f),
0.84f to bottomGradientColor.copy(alpha = 0.84f),
1.00f to bottomGradientColor,
),
),
),

View file

@ -51,6 +51,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.TrailerPlaybackMode
import com.nuvio.app.core.ui.NuvioActionLabel
import com.nuvio.app.features.details.MetaEpisodeCardStyle
import com.nuvio.app.features.details.MetaScreenBackgroundMode
import com.nuvio.app.features.details.MetaScreenSectionItem
import com.nuvio.app.features.details.MetaScreenSectionKey
import com.nuvio.app.features.details.MetaScreenSettingsRepository
@ -64,8 +65,6 @@ import nuvio.composeapp.generated.resources.settings_meta_actions
import nuvio.composeapp.generated.resources.settings_meta_actions_description
import nuvio.composeapp.generated.resources.settings_meta_cast
import nuvio.composeapp.generated.resources.settings_meta_cast_description
import nuvio.composeapp.generated.resources.settings_meta_cinematic_background
import nuvio.composeapp.generated.resources.settings_meta_cinematic_background_description
import nuvio.composeapp.generated.resources.settings_meta_collection
import nuvio.composeapp.generated.resources.settings_meta_collection_description
import nuvio.composeapp.generated.resources.settings_meta_comments
@ -84,6 +83,14 @@ import nuvio.composeapp.generated.resources.settings_meta_episodes
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_background_mode
import nuvio.composeapp.generated.resources.settings_meta_background_mode_cinematic
import nuvio.composeapp.generated.resources.settings_meta_background_mode_cinematic_description
import nuvio.composeapp.generated.resources.settings_meta_background_mode_description
import nuvio.composeapp.generated.resources.settings_meta_background_mode_dominant
import nuvio.composeapp.generated.resources.settings_meta_background_mode_dominant_description
import nuvio.composeapp.generated.resources.settings_meta_background_mode_normal
import nuvio.composeapp.generated.resources.settings_meta_background_mode_normal_description
import nuvio.composeapp.generated.resources.settings_meta_group_label
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
@ -117,12 +124,10 @@ internal fun LazyListScope.metaScreenSettingsContent(
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_meta_cinematic_background),
description = stringResource(Res.string.settings_meta_cinematic_background_description),
checked = uiState.cinematicBackground,
MetaBackgroundModeSelector(
isTablet = isTablet,
onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) },
selectedMode = uiState.backgroundMode,
onModeSelected = MetaScreenSettingsRepository::setBackgroundMode,
)
if (showHeroTrailerPlaybackSetting) {
SettingsGroupDivider(isTablet = isTablet)
@ -181,6 +186,71 @@ internal fun LazyListScope.metaScreenSettingsContent(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun MetaBackgroundModeSelector(
isTablet: Boolean,
selectedMode: MetaScreenBackgroundMode,
onModeSelected: (MetaScreenBackgroundMode) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = if (isTablet) 20.dp else 16.dp,
vertical = if (isTablet) 18.dp else 14.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(Res.string.settings_meta_background_mode),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(Res.string.settings_meta_background_mode_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MetaScreenBackgroundMode.entries.forEach { mode ->
FilterChip(
selected = selectedMode == mode,
onClick = { onModeSelected(mode) },
label = {
Text(
text = stringResource(mode.labelRes),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selectedMode == mode,
borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f),
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
}
Text(
text = stringResource(selectedMode.descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun MetaSectionReorderableList(
items: List<MetaScreenSectionItem>,
@ -394,6 +464,20 @@ private fun TabGroupChip(
)
}
private val MetaScreenBackgroundMode.labelRes: StringResource
get() = when (this) {
MetaScreenBackgroundMode.Normal -> Res.string.settings_meta_background_mode_normal
MetaScreenBackgroundMode.Cinematic -> Res.string.settings_meta_background_mode_cinematic
MetaScreenBackgroundMode.DominantColor -> Res.string.settings_meta_background_mode_dominant
}
private val MetaScreenBackgroundMode.descriptionRes: StringResource
get() = when (this) {
MetaScreenBackgroundMode.Normal -> Res.string.settings_meta_background_mode_normal_description
MetaScreenBackgroundMode.Cinematic -> Res.string.settings_meta_background_mode_cinematic_description
MetaScreenBackgroundMode.DominantColor -> Res.string.settings_meta_background_mode_dominant_description
}
@Composable
private fun MetaEpisodeCardStyleSelector(
isTablet: Boolean,

View file

@ -719,7 +719,7 @@ internal fun settingsSearchEntries(
val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance)
listOf(
PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)),
PlaybackSearchRow("meta-background-mode", stringResource(Res.string.settings_meta_background_mode), stringResource(Res.string.settings_meta_background_mode_description)),
PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)),
PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)),
PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)),

View file

@ -20,6 +20,7 @@ junit = "4.13.2"
kotlin = "2.3.0"
kotlinx-serialization = "1.8.1"
atomicfu = "0.32.1"
kmpalette = "4.0.0-beta02"
ktor = "3.4.1"
material3 = "1.11.0-alpha07"
androidx-media3 = "1.8.0"
@ -57,6 +58,7 @@ coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.r
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" }
kmpalette-core = { module = "com.materialkolor.palette:core", version.ref = "kmpalette" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }