From a90ee2fcfb54f04d8bc79d10eb254ff815f13639 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:11:25 +0530 Subject: [PATCH] feat: add card depth customization --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../core/ui/CardDepthStyleStorage.android.kt | 26 + .../composeResources/values/strings.xml | 31 ++ .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/core/sync/ProfileSettingsSync.kt | 10 + .../com/nuvio/app/core/ui/CardDepthEffect.kt | 96 ++++ .../app/core/ui/CardDepthStyleRepository.kt | 175 ++++++ .../app/core/ui/CardDepthStyleStorage.kt | 6 + .../com/nuvio/app/core/ui/ShelfComponents.kt | 4 + .../details/components/DetailCastSection.kt | 6 + .../details/components/DetailSeriesContent.kt | 20 +- .../components/DetailTrailersSection.kt | 6 + .../components/HomeContinueWatchingSection.kt | 6 + .../features/profiles/ProfileRepository.kt | 2 + .../PosterCustomizationSettingsPage.kt | 509 ++++++++++++++++++ .../app/features/settings/SettingsSearch.kt | 17 + .../app/core/ui/CardDepthStyleStorage.ios.kt | 15 + 17 files changed, 924 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthEffect.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/CardDepthStyleStorage.ios.kt 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)) + } +}