diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 1c54fd0d3..221e77620 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -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)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt
new file mode 100644
index 000000000..a0eaa831e
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt
@@ -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()
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 77f6c9e63..e06f8af69 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -601,6 +601,37 @@
Visible
Hide value
Player, subtitles, and auto-play
+ Card Depth Effect
+ Adds a lit top edge and a soft sheen to image cards for a subtle sense of depth.
+ Enable depth effect
+ Edge glow
+ Subtle
+ Balanced
+ Bold
+ Top sheen
+ Off
+ Soft
+ Bright
+ Apply to
+ Fine-tune
+ Fine-tune Depth
+ Drag the dot right for a brighter edge, up for more sheen. Use the slider to extend the glow around the full outline.
+ Edge glow →
+ ↑ Sheen
+ Edge glow
+ Sheen
+ Edge coverage
+ Top only
+ Half
+ Full outline
+ Edge coverage
+ Episode Title
+ S1 · E1 · 45 min
+ Posters
+ Continue Watching
+ Episode cards
+ Cast
+ Trailers
Corner Radius
Poster Card Style
Width
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index c3eff7ba8..68fa9f0af 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
index d1d566b7a..11fe09158 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
@@ -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()),
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt
new file mode 100644
index 000000000..ed9581ee7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt
@@ -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
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt
new file mode 100644
index 000000000..bb73c3059
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt
@@ -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 = _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(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,
+ ),
+ ),
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt
new file mode 100644
index 000000000..055940f23
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt
@@ -0,0 +1,6 @@
+package com.nuvio.app.core.ui
+
+internal expect object CardDepthStyleStorage {
+ fun loadPayload(): String?
+ fun savePayload(payload: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt
index f7b8aa6c4..df5f05de6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt
index 306152a5e..34de39b12 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt
@@ -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,
) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
index a6fdc974b..039e10bd6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
@@ -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),
),
),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt
index e9ef5fa82..5ca4fa96f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
index bb0951e3b..a906d0033 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index cd727a95c..fd76b3fef 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt
index f4f494d27..ccd3fdcfd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt
@@ -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 {
+ 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 {
+ 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)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 95ade90ce..419d5567f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -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)),
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt
new file mode 100644
index 000000000..3a52ff9c0
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt
@@ -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))
+ }
+}