feat: add card depth customization

This commit is contained in:
tapframe 2026-07-11 12:11:25 +05:30
parent f19d3d1a08
commit a90ee2fcfb
17 changed files with 924 additions and 9 deletions

View file

@ -48,6 +48,7 @@ import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.CardDepthStyleStorage
import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage
@ -90,6 +91,7 @@ class MainActivity : AppCompatActivity() {
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
CardDepthStyleStorage.initialize(applicationContext)
DebridSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)

View file

@ -0,0 +1,26 @@
package com.nuvio.app.core.ui
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object CardDepthStyleStorage {
private const val preferencesName = "nuvio_card_depth_style"
private const val payloadKey = "card_depth_style_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -601,6 +601,37 @@
<string name="settings_homescreen_visible">Visible</string>
<string name="settings_hide_secret">Hide value</string>
<string name="settings_playback_subtitle">Player, subtitles, and auto-play</string>
<string name="settings_card_depth_title">Card Depth Effect</string>
<string name="settings_card_depth_description">Adds a lit top edge and a soft sheen to image cards for a subtle sense of depth.</string>
<string name="settings_card_depth_enabled">Enable depth effect</string>
<string name="settings_card_depth_edge">Edge glow</string>
<string name="settings_card_depth_edge_subtle">Subtle</string>
<string name="settings_card_depth_edge_balanced">Balanced</string>
<string name="settings_card_depth_edge_bold">Bold</string>
<string name="settings_card_depth_sheen">Top sheen</string>
<string name="settings_card_depth_sheen_off">Off</string>
<string name="settings_card_depth_sheen_soft">Soft</string>
<string name="settings_card_depth_sheen_bright">Bright</string>
<string name="settings_card_depth_apply_to">Apply to</string>
<string name="settings_card_depth_fine_tune">Fine-tune</string>
<string name="settings_card_depth_fine_tune_title">Fine-tune Depth</string>
<string name="settings_card_depth_fine_tune_hint">Drag the dot right for a brighter edge, up for more sheen. Use the slider to extend the glow around the full outline.</string>
<string name="settings_card_depth_pad_edge_axis">Edge glow →</string>
<string name="settings_card_depth_pad_sheen_axis">↑ Sheen</string>
<string name="settings_card_depth_edge_value">Edge glow</string>
<string name="settings_card_depth_sheen_value">Sheen</string>
<string name="settings_card_depth_edge_coverage">Edge coverage</string>
<string name="settings_card_depth_coverage_top">Top only</string>
<string name="settings_card_depth_coverage_half">Half</string>
<string name="settings_card_depth_coverage_full">Full outline</string>
<string name="settings_card_depth_coverage_value">Edge coverage</string>
<string name="settings_card_depth_preview_title">Episode Title</string>
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 min</string>
<string name="settings_card_depth_surface_posters">Posters</string>
<string name="settings_card_depth_surface_continue_watching">Continue Watching</string>
<string name="settings_card_depth_surface_episodes">Episode cards</string>
<string name="settings_card_depth_surface_cast">Cast</string>
<string name="settings_card_depth_surface_trailers">Trailers</string>
<string name="settings_poster_card_radius">Corner Radius</string>
<string name="settings_poster_card_style">Poster Card Style</string>
<string name="settings_poster_card_width">Width</string>

View file

@ -27,6 +27,7 @@ import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
@ -61,6 +62,7 @@ internal object LocalAccountDataCleaner {
CollectionRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
CardDepthStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()

View file

@ -17,6 +17,8 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepositor
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.CardDepthStyleStorage
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.settings.ThemeSettingsStorage
@ -289,6 +291,7 @@ object ProfileSettingsSync {
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
CardDepthStyleRepository.uiState.map { "card_depth_style" },
PlayerSettingsRepository.uiState.map { "player" },
StreamBadgeSettingsRepository.uiState.map { "stream_badges" },
DebridSettingsRepository.uiState.map { "debrid" },
@ -462,6 +465,7 @@ object ProfileSettingsSync {
features = MobileProfileSettingsFeatures(
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
cardDepthStyleSettingsPayload = CardDepthStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(),
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
@ -486,6 +490,9 @@ object ProfileSettingsSync {
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
PosterCardStyleRepository.onProfileChanged()
CardDepthStyleStorage.savePayload(blob.features.cardDepthStyleSettingsPayload)
CardDepthStyleRepository.onProfileChanged()
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
@ -523,6 +530,7 @@ object ProfileSettingsSync {
private fun ensureRepositoriesLoaded() {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
CardDepthStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded()
StreamBadgeSettingsRepository.ensureLoaded()
DebridSettingsRepository.ensureLoaded()
@ -547,6 +555,7 @@ object ProfileSettingsSync {
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"card_depth_style=${CardDepthStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"stream_badges=${StreamBadgeSettingsRepository.uiState.value}",
"debrid=${DebridSettingsRepository.uiState.value}",
@ -579,6 +588,7 @@ private data class MobileProfileSettingsBlob(
private data class MobileProfileSettingsFeatures(
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
@SerialName("card_depth_style_settings_payload") val cardDepthStyleSettingsPayload: String = "",
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("stream_badge_settings") val streamBadgeSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),

View file

@ -0,0 +1,96 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.border
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun rememberCardDepthStyleUiState(): CardDepthStyleUiState {
CardDepthStyleRepository.ensureLoaded()
val uiState by CardDepthStyleRepository.uiState.collectAsState()
return uiState
}
@Composable
fun Modifier.nuvioCardDepth(
shape: Shape,
surface: NuvioCardDepthSurface,
fallbackBorderAlpha: Float = 0f,
): Modifier {
val state = rememberCardDepthStyleUiState()
if (!state.isEnabledFor(surface)) {
return if (fallbackBorderAlpha > 0f) {
border(
width = 1.dp,
color = Color.White.copy(alpha = fallbackBorderAlpha),
shape = shape,
)
} else {
this
}
}
return cardDepthVisual(
shape = shape,
edgeStrength = state.edgeStrength.toFloat(),
sheenStrength = state.sheenStrength.toFloat(),
edgeCoverage = state.edgeCoverage.toFloat(),
)
}
fun Modifier.cardDepthVisual(
shape: Shape,
edgeStrength: Float,
sheenStrength: Float,
edgeCoverage: Float = DefaultCardDepthEdgeCoverage.toFloat(),
): Modifier {
val edgeTop = edgeStrength.coerceIn(0f, 100f) / 100f
val sheen = sheenStrength.coerceIn(0f, 100f) / 100f
val coverage = edgeCoverage.coerceIn(0f, 100f) / 100f
val withEdge = if (edgeTop > 0f) {
border(
width = 1.dp,
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = edgeTop),
Color.White.copy(alpha = edgeTop * (0.33f + 0.67f * coverage)),
Color.White.copy(alpha = edgeTop * coverage),
),
),
shape = shape,
)
} else {
this
}
return if (sheen > 0f) {
withEdge.drawWithContent {
drawContent()
val sheenHeight = size.height * 0.22f
if (sheenHeight > 0f) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = sheen),
Color.Transparent,
),
startY = 0f,
endY = sheenHeight,
),
size = Size(size.width, sheenHeight),
)
}
}
} else {
withEdge
}
}

View file

@ -0,0 +1,175 @@
package com.nuvio.app.core.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
internal const val DefaultCardDepthEdgeStrength = 28
internal const val DefaultCardDepthSheenStrength = 10
internal const val DefaultCardDepthEdgeCoverage = 0
enum class NuvioCardDepthSurface {
Posters,
ContinueWatching,
EpisodeCards,
Cast,
Trailers,
}
@Serializable
private data class StoredCardDepthStylePreferences(
val enabled: Boolean = false,
val edgeStrength: Int = DefaultCardDepthEdgeStrength,
val sheenStrength: Int = DefaultCardDepthSheenStrength,
val edgeCoverage: Int = DefaultCardDepthEdgeCoverage,
val postersEnabled: Boolean = true,
val continueWatchingEnabled: Boolean = true,
val episodeCardsEnabled: Boolean = true,
val castEnabled: Boolean = true,
val trailersEnabled: Boolean = true,
)
data class CardDepthStyleUiState(
val enabled: Boolean = false,
val edgeStrength: Int = DefaultCardDepthEdgeStrength,
val sheenStrength: Int = DefaultCardDepthSheenStrength,
val edgeCoverage: Int = DefaultCardDepthEdgeCoverage,
val postersEnabled: Boolean = true,
val continueWatchingEnabled: Boolean = true,
val episodeCardsEnabled: Boolean = true,
val castEnabled: Boolean = true,
val trailersEnabled: Boolean = true,
) {
fun isEnabledFor(surface: NuvioCardDepthSurface): Boolean =
enabled && isSurfaceEnabled(surface)
fun isSurfaceEnabled(surface: NuvioCardDepthSurface): Boolean =
when (surface) {
NuvioCardDepthSurface.Posters -> postersEnabled
NuvioCardDepthSurface.ContinueWatching -> continueWatchingEnabled
NuvioCardDepthSurface.EpisodeCards -> episodeCardsEnabled
NuvioCardDepthSurface.Cast -> castEnabled
NuvioCardDepthSurface.Trailers -> trailersEnabled
}
}
object CardDepthStyleRepository {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(CardDepthStyleUiState())
val uiState: StateFlow<CardDepthStyleUiState> = _uiState.asStateFlow()
private var hasLoaded = false
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun clearLocalState() {
hasLoaded = false
_uiState.value = CardDepthStyleUiState()
}
fun setEnabled(enabled: Boolean) {
update { it.copy(enabled = enabled) }
}
fun setEdgeStrength(strength: Int) {
update { it.copy(edgeStrength = strength.coerceIn(0, 100)) }
}
fun setSheenStrength(strength: Int) {
update { it.copy(sheenStrength = strength.coerceIn(0, 100)) }
}
fun setEdgeCoverage(coverage: Int) {
update { it.copy(edgeCoverage = coverage.coerceIn(0, 100)) }
}
fun setSurfaceEnabled(surface: NuvioCardDepthSurface, enabled: Boolean) {
update {
when (surface) {
NuvioCardDepthSurface.Posters -> it.copy(postersEnabled = enabled)
NuvioCardDepthSurface.ContinueWatching -> it.copy(continueWatchingEnabled = enabled)
NuvioCardDepthSurface.EpisodeCards -> it.copy(episodeCardsEnabled = enabled)
NuvioCardDepthSurface.Cast -> it.copy(castEnabled = enabled)
NuvioCardDepthSurface.Trailers -> it.copy(trailersEnabled = enabled)
}
}
}
fun resetToDefaults() {
ensureLoaded()
if (_uiState.value == CardDepthStyleUiState()) return
_uiState.value = CardDepthStyleUiState()
persist()
}
private fun update(transform: (CardDepthStyleUiState) -> CardDepthStyleUiState) {
ensureLoaded()
val next = transform(_uiState.value)
if (_uiState.value == next) return
_uiState.value = next
persist()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = CardDepthStyleStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = CardDepthStyleUiState()
return
}
val stored = runCatching {
json.decodeFromString<StoredCardDepthStylePreferences>(payload)
}.getOrNull()
_uiState.value = if (stored != null) {
CardDepthStyleUiState(
enabled = stored.enabled,
edgeStrength = stored.edgeStrength.coerceIn(0, 100),
sheenStrength = stored.sheenStrength.coerceIn(0, 100),
edgeCoverage = stored.edgeCoverage.coerceIn(0, 100),
postersEnabled = stored.postersEnabled,
continueWatchingEnabled = stored.continueWatchingEnabled,
episodeCardsEnabled = stored.episodeCardsEnabled,
castEnabled = stored.castEnabled,
trailersEnabled = stored.trailersEnabled,
)
} else {
CardDepthStyleUiState()
}
}
private fun persist() {
CardDepthStyleStorage.savePayload(
json.encodeToString(
StoredCardDepthStylePreferences(
enabled = _uiState.value.enabled,
edgeStrength = _uiState.value.edgeStrength,
sheenStrength = _uiState.value.sheenStrength,
edgeCoverage = _uiState.value.edgeCoverage,
postersEnabled = _uiState.value.postersEnabled,
continueWatchingEnabled = _uiState.value.continueWatchingEnabled,
episodeCardsEnabled = _uiState.value.episodeCardsEnabled,
castEnabled = _uiState.value.castEnabled,
trailersEnabled = _uiState.value.trailersEnabled,
),
),
)
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.core.ui
internal expect object CardDepthStyleStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -148,6 +148,10 @@ fun NuvioPosterCard(
.aspectRatio(shape.aspectRatio)
.clip(cardShape)
.background(tokens.colors.surface)
.nuvioCardDepth(
shape = cardShape,
surface = NuvioCardDepthSurface.Posters,
)
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,

View file

@ -32,6 +32,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.nuvioCardDepth
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
import nuvio.composeapp.generated.resources.*
@ -143,6 +145,10 @@ private fun CastItem(
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape,
)
.nuvioCardDepth(
shape = CircleShape,
surface = NuvioCardDepthSurface.Cast,
),
contentAlignment = Alignment.Center,
) {

View file

@ -64,7 +64,9 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.nuvioCardDepth
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaEpisodeCardStyle
@ -670,10 +672,10 @@ private fun EpisodeHorizontalCard(
.height(metrics.cardHeight)
.clip(cardShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.12f),
.nuvioCardDepth(
shape = cardShape,
surface = NuvioCardDepthSurface.EpisodeCards,
fallbackBorderAlpha = 0.12f,
)
.posterCardClickable(
onClick = onClick,
@ -699,12 +701,12 @@ private fun EpisodeHorizontalCard(
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.10f),
Color.Black.copy(alpha = 0.42f),
Color.Black.copy(alpha = 0.78f),
),
0f to Color.Transparent,
0.42f to Color.Transparent,
0.56f to Color.Black.copy(alpha = 0.20f),
0.70f to Color.Black.copy(alpha = 0.45f),
0.84f to Color.Black.copy(alpha = 0.68f),
1f to Color.Black.copy(alpha = 0.92f),
),
),
)

View file

@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.nuvioCardDepth
import com.nuvio.app.features.details.MetaTrailer
import nuvio.composeapp.generated.resources.*
import nuvio.composeapp.generated.resources.detail_tab_trailer
@ -193,6 +195,10 @@ private fun TrailerCard(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(cornerRadius))
.nuvioCardDepth(
shape = RoundedCornerShape(cornerRadius),
surface = NuvioCardDepthSurface.Trailers,
)
.clickable(onClick = onClick),
) {
AsyncImage(

View file

@ -48,7 +48,9 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.DisintegratingContainer
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.nuvioCardDepth
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
@ -703,6 +705,10 @@ private fun ContinueWatchingCard(
.aspectRatio(PosterLandscapeAspectRatio)
.clip(RoundedCornerShape(cardMetrics.cornerRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.nuvioCardDepth(
shape = RoundedCornerShape(cardMetrics.cornerRadius),
surface = NuvioCardDepthSurface.ContinueWatching,
)
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,

View file

@ -13,6 +13,7 @@ import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeRepository
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
@ -162,6 +163,7 @@ object ProfileRepository {
}
ThemeSettingsRepository.onProfileChanged()
PosterCardStyleRepository.onProfileChanged()
CardDepthStyleRepository.onProfileChanged()
PlayerSettingsRepository.onProfileChanged()
StreamBadgeSettingsRepository.onProfileChanged()
P2pSettingsRepository.onProfileChanged()

View file

@ -2,8 +2,12 @@ package com.nuvio.app.features.settings
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -11,6 +15,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
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.padding
@ -18,23 +24,81 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import com.nuvio.app.core.ui.CardDepthStyleRepository
import com.nuvio.app.core.ui.CardDepthStyleUiState
import com.nuvio.app.core.ui.DefaultCardDepthEdgeCoverage
import com.nuvio.app.core.ui.DefaultCardDepthEdgeStrength
import com.nuvio.app.core.ui.DefaultCardDepthSheenStrength
import com.nuvio.app.core.ui.NuvioActionLabel
import com.nuvio.app.core.ui.NuvioCardDepthSurface
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
import com.nuvio.app.core.ui.cardDepthVisual
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.settings_card_depth_apply_to
import nuvio.composeapp.generated.resources.settings_card_depth_description
import nuvio.composeapp.generated.resources.settings_card_depth_edge
import nuvio.composeapp.generated.resources.settings_card_depth_edge_balanced
import nuvio.composeapp.generated.resources.settings_card_depth_edge_bold
import nuvio.composeapp.generated.resources.settings_card_depth_edge_subtle
import nuvio.composeapp.generated.resources.settings_card_depth_edge_value
import nuvio.composeapp.generated.resources.settings_card_depth_edge_coverage
import nuvio.composeapp.generated.resources.settings_card_depth_coverage_full
import nuvio.composeapp.generated.resources.settings_card_depth_coverage_half
import nuvio.composeapp.generated.resources.settings_card_depth_coverage_top
import nuvio.composeapp.generated.resources.settings_card_depth_coverage_value
import nuvio.composeapp.generated.resources.settings_card_depth_enabled
import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune
import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune_hint
import nuvio.composeapp.generated.resources.settings_card_depth_fine_tune_title
import nuvio.composeapp.generated.resources.settings_card_depth_pad_edge_axis
import nuvio.composeapp.generated.resources.settings_card_depth_pad_sheen_axis
import nuvio.composeapp.generated.resources.settings_card_depth_preview_meta
import nuvio.composeapp.generated.resources.settings_card_depth_preview_title
import nuvio.composeapp.generated.resources.settings_card_depth_sheen
import nuvio.composeapp.generated.resources.settings_card_depth_sheen_value
import nuvio.composeapp.generated.resources.settings_card_depth_sheen_bright
import nuvio.composeapp.generated.resources.settings_card_depth_sheen_off
import nuvio.composeapp.generated.resources.settings_card_depth_sheen_soft
import nuvio.composeapp.generated.resources.settings_card_depth_surface_cast
import nuvio.composeapp.generated.resources.settings_card_depth_surface_continue_watching
import nuvio.composeapp.generated.resources.settings_card_depth_surface_episodes
import nuvio.composeapp.generated.resources.settings_card_depth_surface_posters
import nuvio.composeapp.generated.resources.settings_card_depth_surface_trailers
import nuvio.composeapp.generated.resources.settings_card_depth_title
import nuvio.composeapp.generated.resources.settings_poster_card_radius
import nuvio.composeapp.generated.resources.settings_poster_card_style
import nuvio.composeapp.generated.resources.settings_poster_card_width
@ -90,6 +154,451 @@ internal fun LazyListScope.posterCustomizationSettingsContent(
}
}
}
item {
CardDepthStyleRepository.ensureLoaded()
val cardDepthState by CardDepthStyleRepository.uiState.collectAsState()
SettingsSection(
title = stringResource(Res.string.settings_card_depth_title),
isTablet = isTablet,
actions = {
NuvioActionLabel(
text = stringResource(Res.string.action_reset),
onClick = CardDepthStyleRepository::resetToDefaults,
)
},
) {
SettingsGroup(isTablet = isTablet) {
CardDepthStyleControls(
isTablet = isTablet,
uiState = cardDepthState,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CardDepthStyleControls(
isTablet: Boolean,
uiState: CardDepthStyleUiState,
) {
var showFineTune by remember { mutableStateOf(false) }
val edgeOptions = listOf(
PresetOption(stringResource(Res.string.settings_card_depth_edge_subtle), 28),
PresetOption(stringResource(Res.string.settings_card_depth_edge_balanced), 42),
PresetOption(stringResource(Res.string.settings_card_depth_edge_bold), 56),
)
val sheenOptions = listOf(
PresetOption(stringResource(Res.string.settings_card_depth_sheen_off), 0),
PresetOption(stringResource(Res.string.settings_card_depth_sheen_soft), 10),
PresetOption(stringResource(Res.string.settings_card_depth_sheen_bright), 16),
)
val coverageOptions = listOf(
PresetOption(stringResource(Res.string.settings_card_depth_coverage_top), 0),
PresetOption(stringResource(Res.string.settings_card_depth_coverage_half), 50),
PresetOption(stringResource(Res.string.settings_card_depth_coverage_full), 100),
)
val surfaceRows = listOf(
stringResource(Res.string.settings_card_depth_surface_posters) to NuvioCardDepthSurface.Posters,
stringResource(Res.string.settings_card_depth_surface_continue_watching) to NuvioCardDepthSurface.ContinueWatching,
stringResource(Res.string.settings_card_depth_surface_episodes) to NuvioCardDepthSurface.EpisodeCards,
stringResource(Res.string.settings_card_depth_surface_cast) to NuvioCardDepthSurface.Cast,
stringResource(Res.string.settings_card_depth_surface_trailers) to NuvioCardDepthSurface.Trailers,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 18.dp else 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(Res.string.settings_card_depth_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PosterToggleRow(
title = stringResource(Res.string.settings_card_depth_enabled),
checked = uiState.enabled,
onCheckedChange = CardDepthStyleRepository::setEnabled,
)
if (uiState.enabled) {
PosterStyleOptionRow(
title = stringResource(Res.string.settings_card_depth_edge),
selectedValue = uiState.edgeStrength,
options = edgeOptions,
onSelected = CardDepthStyleRepository::setEdgeStrength,
)
PosterStyleOptionRow(
title = stringResource(Res.string.settings_card_depth_sheen),
selectedValue = uiState.sheenStrength,
options = sheenOptions,
onSelected = CardDepthStyleRepository::setSheenStrength,
)
PosterStyleOptionRow(
title = stringResource(Res.string.settings_card_depth_edge_coverage),
selectedValue = uiState.edgeCoverage,
options = coverageOptions,
onSelected = CardDepthStyleRepository::setEdgeCoverage,
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable { showFineTune = true },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.settings_card_depth_fine_tune),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Icon(
imageVector = Icons.Rounded.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = stringResource(Res.string.settings_card_depth_apply_to),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
surfaceRows.forEach { (title, surface) ->
PosterToggleRow(
title = title,
checked = uiState.isSurfaceEnabled(surface),
onCheckedChange = { enabled ->
CardDepthStyleRepository.setSurfaceEnabled(surface, enabled)
},
)
}
}
}
if (showFineTune) {
CardDepthFineTuneSheet(
initialEdgeStrength = uiState.edgeStrength,
initialSheenStrength = uiState.sheenStrength,
initialEdgeCoverage = uiState.edgeCoverage,
onDismiss = { showFineTune = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CardDepthFineTuneSheet(
initialEdgeStrength: Int,
initialSheenStrength: Int,
initialEdgeCoverage: Int,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var draftEdge by remember { mutableFloatStateOf(initialEdgeStrength.toFloat()) }
var draftSheen by remember { mutableFloatStateOf(initialSheenStrength.toFloat()) }
var draftCoverage by remember { mutableFloatStateOf(initialEdgeCoverage.toFloat()) }
fun commitDraft() {
CardDepthStyleRepository.setEdgeStrength(draftEdge.roundToInt())
CardDepthStyleRepository.setSheenStrength(draftSheen.roundToInt())
CardDepthStyleRepository.setEdgeCoverage(draftCoverage.roundToInt())
}
NuvioModalBottomSheet(
onDismissRequest = {
commitDraft()
onDismiss()
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.settings_card_depth_fine_tune_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
NuvioActionLabel(
text = stringResource(Res.string.action_reset),
onClick = {
draftEdge = DefaultCardDepthEdgeStrength.toFloat()
draftSheen = DefaultCardDepthSheenStrength.toFloat()
draftCoverage = DefaultCardDepthEdgeCoverage.toFloat()
commitDraft()
},
)
}
Text(
text = stringResource(Res.string.settings_card_depth_fine_tune_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
CardDepthPreviewCard(
edgeStrength = draftEdge,
sheenStrength = draftSheen,
edgeCoverage = draftCoverage,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${stringResource(Res.string.settings_card_depth_edge_value)}: ${formatDepthPercentage(draftEdge)}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "${stringResource(Res.string.settings_card_depth_sheen_value)}: ${formatDepthPercentage(draftSheen)}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
CardDepthTuningPad(
edgeStrength = draftEdge,
sheenStrength = draftSheen,
onChange = { edge, sheen ->
draftEdge = edge
draftSheen = sheen
},
onCommit = { edge, sheen ->
CardDepthStyleRepository.setEdgeStrength(edge.roundToInt())
CardDepthStyleRepository.setSheenStrength(sheen.roundToInt())
},
)
Text(
text = "${stringResource(Res.string.settings_card_depth_coverage_value)}: ${formatDepthPercentage(draftCoverage)}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Slider(
value = draftCoverage,
onValueChange = { draftCoverage = it },
onValueChangeFinished = {
CardDepthStyleRepository.setEdgeCoverage(draftCoverage.roundToInt())
},
valueRange = 0f..100f,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
private fun formatDepthPercentage(value: Float): String {
val tenths = (value.coerceIn(0f, 100f) * 10f).roundToInt()
return "${tenths / 10}.${tenths % 10}%"
}
@Composable
private fun CardDepthPreviewCard(
edgeStrength: Float,
sheenStrength: Float,
edgeCoverage: Float,
) {
val shape = RoundedCornerShape(14.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(shape)
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF33415C),
Color(0xFF232D42),
Color(0xFF141A28),
),
),
)
.cardDepthVisual(
shape = shape,
edgeStrength = edgeStrength,
sheenStrength = sheenStrength,
edgeCoverage = edgeCoverage,
),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.42f to Color.Transparent,
0.56f to Color.Black.copy(alpha = 0.20f),
0.70f to Color.Black.copy(alpha = 0.45f),
0.84f to Color.Black.copy(alpha = 0.68f),
1f to Color.Black.copy(alpha = 0.92f),
),
),
)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(Res.string.settings_card_depth_preview_meta),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.78f),
)
Text(
text = stringResource(Res.string.settings_card_depth_preview_title),
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
)
}
}
}
@Composable
private fun CardDepthTuningPad(
edgeStrength: Float,
sheenStrength: Float,
onChange: (Float, Float) -> Unit,
onCommit: (Float, Float) -> Unit,
) {
val maxEdge = 70f
val maxSheen = 25f
val shape = RoundedCornerShape(16.dp)
val thumbColor = MaterialTheme.colorScheme.primary
val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
val currentEdge by rememberUpdatedState(edgeStrength)
val currentSheen by rememberUpdatedState(sheenStrength)
val currentOnChange by rememberUpdatedState(onChange)
val currentOnCommit by rememberUpdatedState(onCommit)
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
shape = shape,
)
.pointerInput(Unit) {
fun valuesAt(position: Offset): Pair<Float, Float> {
val x = (position.x / size.width).coerceIn(0f, 1f)
val y = 1f - (position.y / size.height).coerceIn(0f, 1f)
return (x * maxEdge) to (y * maxSheen)
}
detectDragGestures(
onDragStart = { position ->
val (edge, sheen) = valuesAt(position)
currentOnChange(edge, sheen)
},
onDrag = { change, _ ->
change.consume()
val (edge, sheen) = valuesAt(change.position)
currentOnChange(edge, sheen)
},
onDragEnd = {
currentOnCommit(currentEdge, currentSheen)
},
onDragCancel = {
currentOnCommit(currentEdge, currentSheen)
},
)
}
.pointerInput(Unit) {
fun valuesAt(position: Offset): Pair<Float, Float> {
val x = (position.x / size.width).coerceIn(0f, 1f)
val y = 1f - (position.y / size.height).coerceIn(0f, 1f)
return (x * maxEdge) to (y * maxSheen)
}
detectTapGestures { position ->
val (edge, sheen) = valuesAt(position)
currentOnChange(edge, sheen)
currentOnCommit(edge, sheen)
}
},
) {
Canvas(modifier = Modifier.fillMaxSize()) {
for (step in 1..3) {
val x = size.width * step / 4f
val y = size.height * step / 4f
drawLine(
color = gridColor,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = 1.dp.toPx(),
)
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1.dp.toPx(),
)
}
val thumbX = size.width * (edgeStrength / maxEdge).coerceIn(0f, 1f)
val thumbY = size.height * (1f - (sheenStrength / maxSheen).coerceIn(0f, 1f))
drawLine(
color = thumbColor.copy(alpha = 0.35f),
start = Offset(thumbX, 0f),
end = Offset(thumbX, size.height),
strokeWidth = 1.dp.toPx(),
)
drawLine(
color = thumbColor.copy(alpha = 0.35f),
start = Offset(0f, thumbY),
end = Offset(size.width, thumbY),
strokeWidth = 1.dp.toPx(),
)
drawCircle(
color = thumbColor.copy(alpha = 0.25f),
radius = 16.dp.toPx(),
center = Offset(thumbX, thumbY),
)
drawCircle(
color = thumbColor,
radius = 9.dp.toPx(),
center = Offset(thumbX, thumbY),
)
drawCircle(
color = Color.White,
radius = 4.dp.toPx(),
center = Offset(thumbX, thumbY),
)
}
Text(
text = stringResource(Res.string.settings_card_depth_pad_sheen_axis),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopStart)
.padding(10.dp),
)
Text(
text = stringResource(Res.string.settings_card_depth_pad_edge_axis),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(10.dp),
)
}
}
@OptIn(ExperimentalLayoutApi::class)

View file

@ -710,6 +710,23 @@ internal fun settingsSearchEntries(
)
}
val cardDepthSection = stringResource(Res.string.settings_card_depth_title)
listOf(
PlaybackSearchRow("card-depth-effect", cardDepthSection, stringResource(Res.string.settings_card_depth_description)),
PlaybackSearchRow("card-depth-edge", stringResource(Res.string.settings_card_depth_edge)),
PlaybackSearchRow("card-depth-sheen", stringResource(Res.string.settings_card_depth_sheen)),
).forEach { row ->
addRow(
page = SettingsPage.PosterCustomization,
key = "poster-${row.key}",
title = row.title,
description = row.description,
pageLabel = posterStylePage,
section = cardDepthSection,
icon = Icons.Rounded.Tune,
)
}
val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero)
listOf(
PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),

View file

@ -0,0 +1,15 @@
package com.nuvio.app.core.ui
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
internal actual object CardDepthStyleStorage {
private const val payloadKey = "card_depth_style_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
}