diff --git a/app/src/main/java/me/ash/reader/App.kt b/app/src/main/java/me/ash/reader/App.kt index c7c083d..9cd6687 100644 --- a/app/src/main/java/me/ash/reader/App.kt +++ b/app/src/main/java/me/ash/reader/App.kt @@ -68,9 +68,9 @@ class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() CrashHandler(this) + dataStoreInit() applicationScope.launch(dispatcherDefault) { accountInit() - dataStoreInit() workerInit() } } diff --git a/app/src/main/java/me/ash/reader/CrashHandler.kt b/app/src/main/java/me/ash/reader/CrashHandler.kt index 7e4fd5b..3c99cdf 100644 --- a/app/src/main/java/me/ash/reader/CrashHandler.kt +++ b/app/src/main/java/me/ash/reader/CrashHandler.kt @@ -1,6 +1,7 @@ package me.ash.reader import android.content.Context +import android.util.Log import android.widget.Toast import java.lang.Thread.UncaughtExceptionHandler import kotlin.system.exitProcess @@ -13,6 +14,7 @@ class CrashHandler(private val context: Context) : UncaughtExceptionHandler { override fun uncaughtException(p0: Thread, p1: Throwable) { Toast.makeText(context, p1.message, Toast.LENGTH_LONG).show() p1.printStackTrace() + Log.e("RLog", "uncaughtException: ${p1.message}" ) android.os.Process.killProcess(android.os.Process.myPid()); exitProcess(1) } diff --git a/app/src/main/java/me/ash/reader/ui/component/Banner.kt b/app/src/main/java/me/ash/reader/ui/component/Banner.kt index 99b362e..20e9572 100644 --- a/app/src/main/java/me/ash/reader/ui/component/Banner.kt +++ b/app/src/main/java/me/ash/reader/ui/component/Banner.kt @@ -19,10 +19,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import me.ash.reader.ui.theme.palette.onDark @Composable fun Banner( @@ -33,20 +35,18 @@ fun Banner( action: (@Composable () -> Unit)? = null, onClick: () -> Unit = {}, ) { - val lightThemeColors = MaterialTheme.colorScheme - val lightPrimaryContainer = lightThemeColors.primaryContainer - val lightOnSurface = lightThemeColors.onSurface - Surface( - modifier = modifier.fillMaxWidth().height(88.dp), - color = MaterialTheme.colorScheme.surface, + modifier = modifier + .fillMaxWidth() + .height(88.dp), + color = Color.Unspecified, ) { Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp) .clip(RoundedCornerShape(32.dp)) - .background(lightPrimaryContainer) + .background(MaterialTheme.colorScheme.primaryContainer onDark MaterialTheme.colorScheme.onPrimaryContainer) .clickable { onClick() } .padding(16.dp, 20.dp), verticalAlignment = Alignment.CenterVertically @@ -57,26 +57,29 @@ fun Banner( imageVector = it, contentDescription = null, modifier = Modifier.padding(end = 16.dp), - tint = lightOnSurface, + tint = MaterialTheme.colorScheme.onSurface onDark MaterialTheme.colorScheme.surface, ) } } Column( - modifier = Modifier.weight(1f).fillMaxHeight(), + modifier = Modifier + .weight(1f) + .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween, ) { Text( text = title, maxLines = if (desc == null) 2 else 1, style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp), - color = lightOnSurface, + color = MaterialTheme.colorScheme.onSurface onDark MaterialTheme.colorScheme.surface, overflow = TextOverflow.Ellipsis, ) desc?.let { Text( text = it, style = MaterialTheme.typography.bodyMedium, - color = lightOnSurface.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + onDark MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -84,9 +87,12 @@ fun Banner( } action?.let { Box(Modifier.padding(start = 16.dp)) { - CompositionLocalProvider(LocalContentColor provides lightOnSurface) { - it() - } + CompositionLocalProvider( + LocalContentColor provides ( + MaterialTheme.colorScheme.onSurface + onDark MaterialTheme.colorScheme.surface + ) + ) { it() } } } } diff --git a/app/src/main/java/me/ash/reader/ui/component/BlockButton.kt b/app/src/main/java/me/ash/reader/ui/component/BlockButton.kt new file mode 100644 index 0000000..458710f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/BlockButton.kt @@ -0,0 +1,45 @@ +package me.ash.reader.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import me.ash.reader.ui.theme.palette.onDark + +@Composable +fun BlockButton( + modifier: Modifier = Modifier, + text: String = "", + selected: Boolean = false, + containerColor: Color = MaterialTheme.colorScheme.surface.copy(0.7f) onDark MaterialTheme.colorScheme.inverseOnSurface, + selectedContainerColor: Color = MaterialTheme.colorScheme.primaryContainer onDark MaterialTheme.colorScheme.onPrimaryContainer, + contentColor: Color = MaterialTheme.colorScheme.inverseSurface, + selectedContentColor: Color = MaterialTheme.colorScheme.onSurface onDark MaterialTheme.colorScheme.surface, + onClick: () -> Unit = {}, +) { + Column( + modifier = modifier + .height(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) selectedContainerColor else containerColor) + .clickable(onClick = onClick), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (selected) selectedContentColor else contentColor, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/BlockRadioButton.kt b/app/src/main/java/me/ash/reader/ui/component/BlockRadioButton.kt new file mode 100644 index 0000000..1f011dc --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/BlockRadioButton.kt @@ -0,0 +1,45 @@ +package me.ash.reader.ui.component + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun BlockButtonRadios( + modifier: Modifier = Modifier, + selected: Int = 0, + onSelected: (Int) -> Unit, + items: List = listOf(), +) { + + Column { + Row( + modifier = modifier.padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, item -> + BlockButton( + modifier = Modifier + .weight(1f) + .padding(end = if (item == items.last()) 0.dp else 8.dp), + text = item.text, + selected = selected == index, + ) { + onSelected(index) + item.onClick() + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + items[selected].content() + } +} + +data class BlockButtonRadiosItem( + val text: String, + val onClick: () -> Unit = {}, + val content: @Composable () -> Unit, +) \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/Switch.kt b/app/src/main/java/me/ash/reader/ui/component/Switch.kt new file mode 100644 index 0000000..a1e7247 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/component/Switch.kt @@ -0,0 +1,106 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + * @modifier Ashinch + */ + +package me.ash.reader.ui.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.ash.reader.ui.theme.palette.onDark +import me.ash.reader.ui.theme.palette.tonalPalettes + +// TODO: ripple & swipe +@Composable +fun Switch( + modifier: Modifier = Modifier, + activated: Boolean, + enable: Boolean = true, + onClick: (() -> Unit)? = null +) { + val tonalPalettes = MaterialTheme.colorScheme.tonalPalettes() + + Surface( + modifier = modifier.size(56.dp, 28.dp).alpha(if (enable) 1f else 0.5f), + shape = CircleShape, + color = animateColorAsState( + if (activated) (tonalPalettes primary 40) onDark (tonalPalettes neutralVariant 50) + else (tonalPalettes neutralVariant 50) onDark (tonalPalettes neutral 60) + ).value + ) { + Box( + modifier = Modifier.fillMaxSize() + then if (onClick != null) Modifier.clickable { onClick() } else Modifier + ) { + Surface( + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart) + .offset(x = animateDpAsState(if (activated) 32.dp else 4.dp).value), + shape = CircleShape, + color = animateColorAsState( + if (activated) tonalPalettes primary 90 + else (tonalPalettes neutralVariant 70) onDark (tonalPalettes neutral 30) + ).value + ) {} + } + } +} + +// TODO: inactivated colors +@Composable +fun SwitchHeadline( + activated: Boolean, + onClick: () -> Unit, + title: String, + modifier: Modifier = Modifier +) { + val tonalPalettes = MaterialTheme.colorScheme.tonalPalettes() + + Surface( + modifier = modifier, + color = Color.Unspecified, + contentColor = tonalPalettes neutral 10, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(24.dp)) + .background(tonalPalettes primary 90) + .clickable { onClick() } + .padding(20.dp, 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + maxLines = 2, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp) + ) + } + Box(Modifier.padding(start = 20.dp)) { + Switch(activated = activated) + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 036c897..d77ad9a 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -18,6 +18,10 @@ val Context.currentAccountId: Int get() = this.dataStore.get(DataStoreKeys.CurrentAccountId)!! val Context.currentAccountType: Int get() = this.dataStore.get(DataStoreKeys.CurrentAccountType)!! +val Context.themeIndex: Int + get() = this.dataStore.get(DataStoreKeys.ThemeIndex) ?: 0 +val Context.customPrimaryColor: String + get() = this.dataStore.get(DataStoreKeys.CustomPrimaryColor) ?: "" suspend fun DataStore.put(dataStoreKeys: DataStoreKeys, value: T) { this.edit { @@ -25,6 +29,14 @@ suspend fun DataStore.put(dataStoreKeys: DataStoreKeys, valu } } +fun DataStore.putBlocking(dataStoreKeys: DataStoreKeys, value: T) { + runBlocking { + this@putBlocking.edit { + it[dataStoreKeys.key] = value + } + } +} + @Suppress("UNCHECKED_CAST") fun DataStore.get(dataStoreKeys: DataStoreKeys): T? { return runBlocking { @@ -59,4 +71,14 @@ sealed class DataStoreKeys { override val key: Preferences.Key get() = intPreferencesKey("currentAccountType") } + + object ThemeIndex : DataStoreKeys() { + override val key: Preferences.Key + get() = intPreferencesKey("themeIndex") + } + + object CustomPrimaryColor : DataStoreKeys() { + override val key: Preferences.Key + get() = stringPreferencesKey("customPrimaryColor") + } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/ext/Navigation.kt b/app/src/main/java/me/ash/reader/ui/ext/NavGraphBuilderExt.kt similarity index 100% rename from app/src/main/java/me/ash/reader/ui/ext/Navigation.kt rename to app/src/main/java/me/ash/reader/ui/ext/NavGraphBuilderExt.kt diff --git a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt index 8760800..a7bc389 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/HomeEntry.kt @@ -14,13 +14,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsHeight -import com.google.accompanist.insets.statusBarsPadding import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.systemuicontroller.rememberSystemUiController import me.ash.reader.ui.ext.animatedComposable import me.ash.reader.ui.ext.isFirstLaunch import me.ash.reader.ui.page.home.HomePage +import me.ash.reader.ui.page.settings.ColorAndStyle import me.ash.reader.ui.page.settings.SettingsPage import me.ash.reader.ui.page.startup.StartupPage import me.ash.reader.ui.theme.AppTheme @@ -38,11 +38,11 @@ fun HomeEntry() { setSystemBarsColor(Color.Transparent, !isSystemInDarkTheme()) setNavigationBarColor(MaterialTheme.colorScheme.surface, !isSystemInDarkTheme()) } - Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { + Column { Row( modifier = Modifier .weight(1f) - .statusBarsPadding() + .background(MaterialTheme.colorScheme.surface), ) { AnimatedNavHost( navController = navController, @@ -57,12 +57,16 @@ fun HomeEntry() { animatedComposable(route = RouteName.SETTINGS) { SettingsPage(navController) } + animatedComposable(route = RouteName.COLOR_AND_STYLE) { + ColorAndStyle(navController) + } } } Spacer( modifier = Modifier .navigationBarsHeight() .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt index 96bf4b3..ac27792 100644 --- a/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt +++ b/app/src/main/java/me/ash/reader/ui/page/common/RouteName.kt @@ -7,4 +7,5 @@ object RouteName { const val ARTICLE = "article" const val READ = "read" const val SETTINGS = "settings" + const val COLOR_AND_STYLE = "color_and_style" } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/FilterBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/FilterBar.kt index 86a070d..c6a4aca 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/FilterBar.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/FilterBar.kt @@ -54,13 +54,13 @@ fun FilterBar( view.playSoundEffect(SoundEffectConstants.CLICK) filterOnClick(item) }, -// colors = NavigationBarItemDefaults.colors( + colors = NavigationBarItemDefaults.colors( // selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, // unselectedIconColor = MaterialTheme.colorScheme.outline, // selectedTextColor = MaterialTheme.colorScheme.onSurface, // unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, -// indicatorColor = MaterialTheme.colorScheme.secondaryContainer, -// ) + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + ) ) } Spacer(modifier = Modifier.width(60.dp)) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt b/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt index f0a9765..2df204e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/HomePage.kt @@ -1,13 +1,16 @@ package me.ash.reader.ui.page.home import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController +import com.google.accompanist.insets.statusBarsPadding import com.google.accompanist.pager.ExperimentalPagerApi import kotlinx.coroutines.launch import me.ash.reader.ui.component.ViewPager @@ -91,7 +94,11 @@ fun HomePage( ) } - Column { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .statusBarsPadding(), + ) { ViewPager( modifier = Modifier.weight(1f), state = viewState.pagerState, diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/ColorAndStyle.kt b/app/src/main/java/me/ash/reader/ui/page/settings/ColorAndStyle.kt new file mode 100644 index 0000000..3da67a9 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/ColorAndStyle.kt @@ -0,0 +1,340 @@ +package me.ash.reader.ui.page.settings + +import android.annotation.SuppressLint +import android.os.Build +import androidx.compose.animation.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.google.accompanist.insets.statusBarsPadding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import me.ash.reader.R +import me.ash.reader.ui.component.* +import me.ash.reader.ui.ext.* +import me.ash.reader.ui.theme.palette.* +import me.ash.reader.ui.theme.palette.TonalPalettes.Companion.toTonalPalettes +import me.ash.reader.ui.theme.palette.dynamic.extractTonalPalettesFromUserWallpaper + +@SuppressLint("FlowOperatorInvokedInComposition") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ColorAndStyle( + navController: NavHostController, +) { + val context = LocalContext.current + val wallpaperTonalPalettes = extractTonalPalettesFromUserWallpaper() + var radioButtonSelected by remember { mutableStateOf(if (context.themeIndex > 4) 0 else 1) } + + Scaffold( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface) + .statusBarsPadding(), + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, + topBar = { + SmallTopAppBar( + colors = TopAppBarDefaults.smallTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface + ), + title = {}, + navigationIcon = { + FeedbackIconButton( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) { + navController.popBackStack() + } + }, + actions = {} + ) + }, + content = { + LazyColumn { + item { + DisplayText(text = stringResource(R.string.color_and_style), desc = "") + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .aspectRatio(1.38f) + .clip(RoundedCornerShape(24.dp)) + .background( + MaterialTheme.colorScheme.inverseOnSurface + onLight MaterialTheme.colorScheme.surface.copy(0.7f) + ) + .clickable { }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.padding(60.dp), + painter = painterResource(id = R.drawable.palettie), + contentDescription = stringResource(R.string.welcome), + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } + item { + BlockButtonRadios( + selected = radioButtonSelected, + onSelected = { radioButtonSelected = it }, + items = listOf( + BlockButtonRadiosItem( + text = stringResource(R.string.wallpaper_colors), + onClick = {}, + ) { + Palettes( + palettes = wallpaperTonalPalettes.run { + if (this.size > 5) { + this.subList(5, wallpaperTonalPalettes.size) + } else { + emptyList() + } + }, + themeIndexPrefix = 5, + ) + }, + BlockButtonRadiosItem( + text = stringResource(R.string.basic_colors), + onClick = {}, + ) { + Palettes( + palettes = wallpaperTonalPalettes.subList(0, 5) + ) + }, + ), + ) + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.style) + ) + SettingItem( + title = stringResource(R.string.dark_theme), + desc = stringResource(R.string.use_device_theme), + enable = false, + separatedActions = true, + onClick = {}, + ) { + Switch(activated = isSystemInDarkTheme(), enable = false) + } + SettingItem( + title = stringResource(R.string.tonal_elevation), + enable = false, + onClick = {}, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Subtitle( + modifier = Modifier.padding(horizontal = 24.dp), + text = stringResource(R.string.fonts) + ) + SettingItem( + title = stringResource(R.string.basic_fonts), + desc = "Google Sans", + enable = false, + onClick = {}, + ) {} + SettingItem( + title = stringResource(R.string.reading_fonts), + desc = "Google Sans", + enable = false, + onClick = {}, + ) {} + SettingItem( + title = stringResource(R.string.reading_fonts_size), + desc = "16sp", + enable = false, + onClick = {}, + ) {} + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + ) +} + +@SuppressLint("FlowOperatorInvokedInComposition") +@Composable +fun Palettes( + modifier: Modifier = Modifier, + palettes: List, + themeIndexPrefix: Int = 0, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val themeIndex = context.dataStore.data + .map { it[DataStoreKeys.ThemeIndex.key] ?: 0 } + .collectAsState(initial = 0).value + val customPrimaryColor = context.dataStore.data + .map { it[DataStoreKeys.CustomPrimaryColor.key] ?: "" } + .collectAsState(initial = "").value + val tonalPalettes = customPrimaryColor.safeHexToColor().toTonalPalettes() + var addDialogVisible by remember { mutableStateOf(false) } + var customColorValue by remember { mutableStateOf(context.customPrimaryColor) } + + if (palettes.isEmpty()) { + Row( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .height(80.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + MaterialTheme.colorScheme.inverseOnSurface + onLight MaterialTheme.colorScheme.surface.copy(0.7f), + ) + .clickable {}, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) + stringResource(R.string.no_palettes) + else stringResource(R.string.only_android_8_1_plus), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.inverseSurface, + ) + } + } else { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + palettes.forEachIndexed { index, palette -> + val isCustom = index == palettes.lastIndex && themeIndexPrefix == 0 + val i = themeIndex - themeIndexPrefix + SelectableMiniPalette( + selected = if (i >= palettes.size) i == 0 else i == index, + isCustom = isCustom, + onClick = { + if (isCustom) { + addDialogVisible = true + } else { + scope.launch(Dispatchers.IO) { + context.dataStore.put( + DataStoreKeys.ThemeIndex, + themeIndexPrefix + index + ) + } + } + }, + palette = if (isCustom) tonalPalettes else palette + ) + } + } + } + + TextFieldDialog( + visible = addDialogVisible, + title = "强调色", + icon = Icons.Outlined.Palette, + value = customColorValue, + placeholder = "#123456", + onValueChange = { + customColorValue = it + }, + onDismissRequest = { + addDialogVisible = false + customColorValue = context.customPrimaryColor + }, + onConfirm = { + it.checkColorHex()?.let { + scope.launch(Dispatchers.IO) { + context.dataStore.put(DataStoreKeys.CustomPrimaryColor, it) + context.dataStore.put(DataStoreKeys.ThemeIndex, 4) + } + addDialogVisible = false + customColorValue = it + } + } + ) +} + +@Composable +fun SelectableMiniPalette( + modifier: Modifier = Modifier, + selected: Boolean, + isCustom: Boolean = false, + onClick: () -> Unit, + palette: TonalPalettes, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = if (isCustom) { + MaterialTheme.colorScheme.primaryContainer + .copy(0.5f) onDark MaterialTheme.colorScheme.onPrimaryContainer.copy(0.3f) + } else { + MaterialTheme.colorScheme + .inverseOnSurface onLight MaterialTheme.colorScheme.surface.copy(0.7f) + }, + ) { + Surface( + modifier = Modifier + .clickable { onClick() } + .padding(16.dp) + .size(48.dp), + shape = CircleShape, + color = palette primary 90, + ) { + Box { + Surface( + modifier = Modifier + .size(48.dp) + .offset((-24).dp, 24.dp), + color = palette tertiary 90, + ) {} + Surface( + modifier = Modifier + .size(48.dp) + .offset(24.dp, 24.dp), + color = palette secondary 50, + ) {} + AnimatedVisibility( + visible = selected, + modifier = Modifier + .align(Alignment.Center) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Checked", + modifier = Modifier + .padding(8.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.surface + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/component/SelectableSettingGroupItem.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SelectableSettingGroupItem.kt similarity index 92% rename from app/src/main/java/me/ash/reader/ui/component/SelectableSettingGroupItem.kt rename to app/src/main/java/me/ash/reader/ui/page/settings/SelectableSettingGroupItem.kt index 8285091..b78ba98 100644 --- a/app/src/main/java/me/ash/reader/ui/component/SelectableSettingGroupItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SelectableSettingGroupItem.kt @@ -6,7 +6,7 @@ * @modifier Ashinch */ -package me.ash.reader.ui.component +package me.ash.reader.ui.page.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @@ -30,6 +31,7 @@ import androidx.compose.ui.unit.sp @Composable fun SelectableSettingGroupItem( modifier: Modifier = Modifier, + enable: Boolean = true, selected: Boolean = false, title: String, desc: String? = null, @@ -37,7 +39,7 @@ fun SelectableSettingGroupItem( onClick: () -> Unit, ) { Surface( - modifier = modifier.clickable { onClick() }, + modifier = modifier.clickable { onClick() }.alpha(if (enable) 1f else 0.5f), color = Color.Unspecified, ) { Row( @@ -45,7 +47,7 @@ fun SelectableSettingGroupItem( .fillMaxWidth() .padding(horizontal = 16.dp) .background( - color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surface, + color = if (selected) MaterialTheme.colorScheme.onSurface else Color.Unspecified, shape = RoundedCornerShape(24.dp) ) .padding(8.dp, 16.dp), diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt new file mode 100644 index 0000000..e402670 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SettingItem.kt @@ -0,0 +1,81 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + * @modifier Ashinch + */ + +package me.ash.reader.ui.page.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun SettingItem( + modifier: Modifier = Modifier, + enable: Boolean = true, + title: String, + desc: String? = null, + icon: ImageVector? = null, + separatedActions: Boolean = false, + onClick: () -> Unit, + action: (@Composable () -> Unit)? = null +) { + Surface( + modifier = modifier.clickable { onClick() }.alpha(if (enable) 1f else 0.5f), + color = Color.Unspecified + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp, 16.dp, 16.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = null, + modifier = Modifier.padding(end = 24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + maxLines = if (desc == null) 2 else 1, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp) + ) + desc?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + action?.let { + if (separatedActions) { + Divider( + modifier = Modifier + .padding(start = 16.dp) + .size(1.dp, 32.dp) + ) + } + Box(Modifier.padding(start = 16.dp)) { + it() + } + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt index 770b7cf..23fb912 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/SettingsPage.kt @@ -14,12 +14,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.google.accompanist.insets.statusBarsPadding import me.ash.reader.R import me.ash.reader.ui.component.Banner import me.ash.reader.ui.component.DisplayText import me.ash.reader.ui.component.FeedbackIconButton -import me.ash.reader.ui.component.SelectableSettingGroupItem import me.ash.reader.ui.page.common.RouteName +import me.ash.reader.ui.theme.palette.onLight @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -27,9 +28,13 @@ fun SettingsPage( navController: NavHostController, ) { Scaffold( - modifier = Modifier.background(MaterialTheme.colorScheme.surface), + modifier = Modifier + .background(MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface) + .statusBarsPadding(), + containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface, topBar = { SmallTopAppBar( + colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface), title = {}, navigationIcon = { FeedbackIconButton( @@ -67,6 +72,7 @@ fun SettingsPage( title = stringResource(R.string.accounts), desc = stringResource(R.string.accounts_desc), icon = Icons.Outlined.AccountCircle, + enable = false, ) {} } item { @@ -74,13 +80,16 @@ fun SettingsPage( title = stringResource(R.string.color_and_style), desc = stringResource(R.string.color_and_style_desc), icon = Icons.Outlined.Palette, - ) {} + ) { + navController.navigate(RouteName.COLOR_AND_STYLE) + } } item { SelectableSettingGroupItem( title = stringResource(R.string.interaction), desc = stringResource(R.string.interaction_desc), icon = Icons.Outlined.TouchApp, + enable = false, ) {} } item { @@ -88,6 +97,7 @@ fun SettingsPage( title = stringResource(R.string.languages), desc = stringResource(R.string.languages_desc), icon = Icons.Outlined.Language, + enable = false, ) {} } item { @@ -95,6 +105,7 @@ fun SettingsPage( title = stringResource(R.string.tips_and_support), desc = stringResource(R.string.tips_and_support_desc), icon = Icons.Outlined.TipsAndUpdates, + enable = false, ) {} } } diff --git a/app/src/main/java/me/ash/reader/ui/page/startup/StartupPage.kt b/app/src/main/java/me/ash/reader/ui/page/startup/StartupPage.kt index db92d1e..ef7e126 100644 --- a/app/src/main/java/me/ash/reader/ui/page/startup/StartupPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/startup/StartupPage.kt @@ -12,13 +12,13 @@ import androidx.compose.material.icons.rounded.CheckCircleOutline import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.google.accompanist.insets.statusBarsPadding import com.ireward.htmlcompose.HtmlText import kotlinx.coroutines.launch import me.ash.reader.R @@ -37,7 +37,7 @@ fun StartupPage( val scope = rememberCoroutineScope() Scaffold( - modifier = Modifier.background(MaterialTheme.colorScheme.surface), + modifier = Modifier.statusBarsPadding().background(MaterialTheme.colorScheme.surface), topBar = {}, content = { LazyColumn { @@ -87,29 +87,31 @@ fun StartupPage( } }, bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - ExtendedFloatingActionButton( - onClick = { - navController.navigate(route = RouteName.HOME) - scope.launch { - context.dataStore.put(DataStoreKeys.IsFirstLaunch, false) - } - }, - icon = { - Icon( - Icons.Rounded.CheckCircleOutline, - stringResource(R.string.agree_and_continue) - ) - }, - text = { Text(text = stringResource(R.string.agree_and_continue)) }, - ) - } +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(24.dp), +// horizontalArrangement = Arrangement.End, +// verticalAlignment = Alignment.CenterVertically, +// ) { +// } + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + navController.navigate(route = RouteName.HOME) + scope.launch { + context.dataStore.put(DataStoreKeys.IsFirstLaunch, false) + } + }, + icon = { + Icon( + Icons.Rounded.CheckCircleOutline, + stringResource(R.string.agree_and_continue) + ) + }, + text = { Text(text = stringResource(R.string.agree_and_continue)) }, + ) } ) } diff --git a/app/src/main/java/me/ash/reader/ui/theme/Theme.kt b/app/src/main/java/me/ash/reader/ui/theme/Theme.kt index 0190f21..03d57b4 100644 --- a/app/src/main/java/me/ash/reader/ui/theme/Theme.kt +++ b/app/src/main/java/me/ash/reader/ui/theme/Theme.kt @@ -1,51 +1,54 @@ package me.ash.reader.ui.theme -import android.os.Build +import android.annotation.SuppressLint import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.LocalContext -import me.ash.reader.ui.theme.color.PurpleColor - -private val LightThemeColors = PurpleColor.lightColorScheme -private val DarkThemeColors = PurpleColor.darkColorScheme - -val LocalLightThemeColors = staticCompositionLocalOf { LightThemeColors } -val LocalDarkThemeColors = staticCompositionLocalOf { DarkThemeColors } +import kotlinx.coroutines.flow.map +import me.ash.reader.ui.ext.DataStoreKeys +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.theme.palette.LocalTonalPalettes +import me.ash.reader.ui.theme.palette.TonalPalettes +import me.ash.reader.ui.theme.palette.core.ProvideZcamViewingConditions +import me.ash.reader.ui.theme.palette.dynamic.extractTonalPalettesFromUserWallpaper +import me.ash.reader.ui.theme.palette.dynamicDarkColorScheme +import me.ash.reader.ui.theme.palette.dynamicLightColorScheme +@SuppressLint("FlowOperatorInvokedInComposition") @Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), + wallpaperPalettes: List = extractTonalPalettesFromUserWallpaper(), content: @Composable () -> Unit ) { - // Dynamic color is available on Android 12+ - val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current + val themeIndex = context.dataStore.data.map { it[DataStoreKeys.ThemeIndex.key] ?: 0 } + .collectAsState(initial = 0).value - val light = when { - dynamicColor -> dynamicLightColorScheme(LocalContext.current) - else -> LightThemeColors - } - val dark = when { - dynamicColor -> dynamicDarkColorScheme(LocalContext.current) - else -> DarkThemeColors - } - val colorScheme = when { - useDarkTheme -> dark - else -> light - } - - CompositionLocalProvider( - LocalLightThemeColors provides light, - LocalDarkThemeColors provides dark, - ) { - MaterialTheme( - colorScheme = colorScheme, - typography = AppTypography, - content = content - ) + ProvideZcamViewingConditions { + CompositionLocalProvider( + LocalTonalPalettes provides wallpaperPalettes[ + if (themeIndex >= wallpaperPalettes.size) { + when { + wallpaperPalettes.size == 5 -> 0 + wallpaperPalettes.size > 5 -> 5 + else -> 0 + } + } else { + themeIndex + } + ] + ) { + MaterialTheme( + colorScheme = + if (useDarkTheme) dynamicDarkColorScheme() + else dynamicLightColorScheme(), + typography = AppTypography, + content = content + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/DynamicTonalPalette.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/DynamicTonalPalette.kt new file mode 100644 index 0000000..80170e5 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/DynamicTonalPalette.kt @@ -0,0 +1,94 @@ +package me.ash.reader.ui.theme.palette + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun dynamicLightColorScheme(): ColorScheme { + val palettes = LocalTonalPalettes.current + return lightColorScheme( + primary = palettes primary 40, + onPrimary = palettes primary 100, + primaryContainer = palettes primary 90, + onPrimaryContainer = palettes primary 10, + inversePrimary = palettes primary 80, + secondary = palettes secondary 40, + onSecondary = palettes secondary 100, + secondaryContainer = palettes secondary 90, + onSecondaryContainer = palettes secondary 10, + tertiary = palettes tertiary 40, + onTertiary = palettes tertiary 100, + tertiaryContainer = palettes tertiary 90, + onTertiaryContainer = palettes tertiary 10, + background = palettes neutral 99, + onBackground = palettes neutral 10, + surface = palettes neutral 99, + onSurface = palettes neutral 10, + surfaceVariant = palettes neutralVariant 90, + onSurfaceVariant = palettes neutralVariant 30, + inverseSurface = palettes neutral 20, + inverseOnSurface = palettes neutral 95, + outline = palettes neutralVariant 50, + ) +} + +@Composable +fun dynamicDarkColorScheme(): ColorScheme { + val palettes = LocalTonalPalettes.current + return darkColorScheme( + primary = palettes primary 80, + onPrimary = palettes primary 20, + primaryContainer = palettes primary 30, + onPrimaryContainer = palettes primary 90, + inversePrimary = palettes primary 40, + secondary = palettes secondary 80, + onSecondary = palettes secondary 20, + secondaryContainer = palettes secondary 30, + onSecondaryContainer = palettes secondary 90, + tertiary = palettes tertiary 80, + onTertiary = palettes tertiary 20, + tertiaryContainer = palettes tertiary 30, + onTertiaryContainer = palettes tertiary 90, + background = palettes neutral 10, + onBackground = palettes neutral 90, + surface = palettes neutral 10, + onSurface = palettes neutral 90, + surfaceVariant = palettes neutralVariant 30, + onSurfaceVariant = palettes neutralVariant 80, + inverseSurface = palettes neutral 90, + inverseOnSurface = palettes neutral 20, + outline = palettes neutralVariant 60, + ) +} + +@Composable +fun ColorScheme.tonalPalettes() = LocalTonalPalettes.current + +@Suppress("NOTHING_TO_INLINE") +@Composable +inline infix fun Color.onLight(lightColor: Color): Color = + if (!isSystemInDarkTheme()) lightColor else this + +@Suppress("NOTHING_TO_INLINE") +@Composable +inline infix fun Color.onDark(darkColor: Color): Color = + if (isSystemInDarkTheme()) darkColor else this + +fun String.checkColorHex(): String? { + var s = this.trim() + if (s.length > 6) { + s = s.substring(s.length - 6) + } + return "[0-9a-fA-F]{6}".toRegex().find(s)?.value +} + +fun String.safeHexToColor(): Color = + try { + Color(java.lang.Long.parseLong(this, 16)) + } catch (e: Exception) { + Color.Transparent + } diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/MaterialYouStandard.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/MaterialYouStandard.kt new file mode 100644 index 0000000..47dc9d2 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/MaterialYouStandard.kt @@ -0,0 +1,116 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette + +object MaterialYouStandard { + // TODO: support RGB color spaces + // calculated with error = 0.001, chroma = 100, hue in 0 until 360, hue step = 1 + val sRGBLightnessChromaMap = mapOf( + 0 to 7.62939453125E-4, + 1 to 3.5961087544759116, + 2 to 4.780750274658203, + 3 to 5.583519405788845, + 4 to 6.202197604709202, + 5 to 6.709673139784071, + 6 to 7.141865624321832, + 7 to 7.5197558932834205, + 8 to 7.855508592393663, + 9 to 8.170108795166016, + 10 to 8.478755950927734, + 11 to 8.78151363796658, + 12 to 9.078428480360243, + 13 to 9.368563758002388, + 14 to 9.65309566921658, + 15 to 9.932153489854601, + 16 to 10.205938551161024, + 17 to 10.474622514512804, + 18 to 10.737465752495659, + 19 to 10.994769202338325, + 20 to 11.247151692708334, + 21 to 11.494865417480469, + 22 to 11.737997266981337, + 23 to 11.97671890258789, + 24 to 12.211305830213758, + 25 to 12.440338134765625, + 26 to 12.664968702528212, + 27 to 12.885534498426649, + 28 to 13.102118174235025, + 29 to 13.314840528700087, + 30 to 13.523866865370008, + 31 to 13.729370964898003, + 32 to 13.93154568142361, + 33 to 14.129519992404514, + 34 to 14.323438008626303, + 35 to 14.513916439480251, + 36 to 14.701114230685764, + 37 to 14.885203043619791, + 38 to 15.066161685519749, + 39 to 15.244210561116537, + 40 to 15.419385698106554, + 41 to 15.591801537407768, + 42 to 15.761661529541016, + 43 to 15.928798251681858, + 44 to 16.091954973008896, + 45 to 16.25248803032769, + 46 to 16.410503387451172, + 47 to 16.566007402208115, + 48 to 16.71794679429796, + 49 to 16.8612183464898, + 50 to 16.995349460177952, + 51 to 17.11943096584744, + 52 to 17.233810424804688, + 53 to 17.34021716647678, + 54 to 17.43742412990994, + 55 to 17.525511847601997, + 56 to 17.586015065511067, + 57 to 17.615225050184463, + 58 to 17.616180843777126, + 59 to 17.59176466200087, + 60 to 17.543212042914497, + 61 to 17.472801208496094, + 62 to 17.381820678710938, + 63 to 17.271283467610676, + 64 to 17.14224073621962, + 65 to 16.99536853366428, + 66 to 16.831576029459637, + 67 to 16.651276482476128, + 68 to 16.45496368408203, + 69 to 16.24362309773763, + 70 to 16.024080912272137, + 71 to 15.799829694959852, + 72 to 15.570498572455513, + 73 to 15.336426628960503, + 74 to 15.097156100802952, + 75 to 14.85240724351671, + 76 to 14.601781633165148, + 77 to 14.345453050401476, + 78 to 14.08291286892361, + 79 to 13.814093271891275, + 80 to 13.538227081298828, + 81 to 13.255418141682943, + 82 to 12.964892917209202, + 83 to 12.657786475287544, + 84 to 12.30661392211914, + 85 to 11.908126407199436, + 86 to 11.460111406114367, + 87 to 10.960358513726128, + 88 to 10.406063927544487, + 89 to 9.794313642713758, + 90 to 9.12274890475803, + 91 to 8.394618564181858, + 92 to 7.635277642144097, + 93 to 6.844251420762804, + 94 to 6.025793287489149, + 95 to 5.196952819824219, + 96 to 4.350443945990668, + 97 to 3.472722371419271, + 98 to 2.5394566853841147, + 99 to 1.4974000718858507, + 100 to 7.62939453125E-4, + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/TonalPalettes.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/TonalPalettes.kt new file mode 100644 index 0000000..ae0d066 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/TonalPalettes.kt @@ -0,0 +1,105 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import me.ash.reader.ui.theme.palette.colorspace.cielab.CieLab +import me.ash.reader.ui.theme.palette.colorspace.zcam.Izazbz.Companion.toIzazbz +import me.ash.reader.ui.theme.palette.core.* + +/** + * The L from CIELab + */ +typealias TonalValue = Int + +typealias TonalPalette = MutableMap + +val LocalTonalPalettes = compositionLocalOf { + TonalPalettes(238.36, 15.0) +} + +@Composable +private fun TonalValue.toZcamLightness(): Double = + CieLab(L = if (this != 50) toDouble() else 49.6, a = 0.0, b = 0.0).toXyz().toIzazbz() + .toZcam().Jz + +data class TonalPalettes( + val hue: Double, + val primaryChroma: Double, + val primary: TonalPalette = mutableMapOf(), + val secondary: TonalPalette = mutableMapOf(), + val tertiary: TonalPalette = mutableMapOf(), + val neutral: TonalPalette = mutableMapOf(), + val neutralVariant: TonalPalette = mutableMapOf(), + val error: TonalPalette = mutableMapOf(), +) { + @Composable + infix fun primary(tone: TonalValue): Color = primary.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = (1.2 * primaryChroma / MaterialYouStandard.sRGBLightnessChromaMap.maxOf { it.value }) + .coerceAtLeast(1.0) * MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone), + h = hue, + ).clampToRgb().toColor() + } + + @Composable + infix fun secondary(tone: TonalValue): Color = secondary.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone) / 3.0, + h = hue, + ).clampToRgb().toColor() + } + + @Composable + infix fun tertiary(tone: TonalValue): Color = tertiary.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone) * 2.0 / 3.0, + h = hue + 60.0, + ).clampToRgb().toColor() + } + + @Composable + infix fun neutral(tone: TonalValue): Color = neutral.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone) / 12.0, + h = hue, + ).clampToRgb().toColor() + } + + @Composable + infix fun neutralVariant(tone: TonalValue): Color = neutralVariant.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone) / 6.0, + h = hue, + ).clampToRgb().toColor() + } + + @Composable + infix fun error(tone: TonalValue): Color = error.getOrPut(tone) { + zcamLch( + L = tone.toZcamLightness(), + C = MaterialYouStandard.sRGBLightnessChromaMap.getValue(tone), + h = 33.44, + ).clampToRgb().toColor() + } + + companion object { + @Composable + fun Color.toTonalPalettes(): TonalPalettes { + val zcam = toRgb().toZcam() + return TonalPalettes(hue = zcam.hz, primaryChroma = zcam.Cz) + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLab.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLab.kt new file mode 100644 index 0000000..d5821cd --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLab.kt @@ -0,0 +1,55 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.cielab + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import kotlin.math.pow + +// TODO: test +data class CieLab( + val L: Double, + val a: Double, + val b: Double, +) { + fun toXyz( + whitePoint: CieXyz, + luminance: Double + ): CieXyz { + val lp = (L + 16.0) / 116.0 + val absoluteWhitePoint = whitePoint * luminance + return CieXyz( + x = absoluteWhitePoint.x * fInv(lp + (a / 500.0)), + y = absoluteWhitePoint.y * fInv(lp), + z = absoluteWhitePoint.z * fInv(lp - (b / 200.0)), + ) + } + + companion object { + private fun f(x: Double) = when { + x > 216.0 / 24389.0 -> x.pow(1.0 / 3.0) + else -> x / (108.0 / 841.0) + 4.0 / 29.0 + } + + private fun fInv(x: Double): Double = when { + x > 6.0 / 29.0 -> x.pow(3.0) + else -> 108.0 / 841.0 * (x - 4.0 / 29.0) + } + + fun CieXyz.toCieLab( + whitePoint: CieXyz, + luminance: Double + ): CieLab { + val relativeWhitePoint = whitePoint / luminance + return CieLab( + L = 116.0 * f(y / relativeWhitePoint.y) - 16.0, + a = 500.0 * (f(x / relativeWhitePoint.x) - f(y / relativeWhitePoint.y)), + b = 200.0 * (f(y / relativeWhitePoint.y) - f(z / relativeWhitePoint.z)), + ) + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLch.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLch.kt new file mode 100644 index 0000000..47ef2c9 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/cielab/CieLch.kt @@ -0,0 +1,39 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.cielab + +import me.ash.reader.ui.theme.palette.util.square +import me.ash.reader.ui.theme.palette.util.toDegrees +import me.ash.reader.ui.theme.palette.util.toRadians +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class CieLch( + val L: Double, + val C: Double, + val h: Double, +) { + fun toCieLab(): CieLab { + val hRad = h.toRadians() + return CieLab( + L = L, + a = C * cos(hRad), + b = C * sin(hRad), + ) + } + + companion object { + fun CieLab.toCieLch(): CieLch = CieLch( + L = L, + C = sqrt(square(a) + square(b)), + h = atan2(b, a).toDegrees().mod(360.0), + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/ciexyz/CieXyz.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/ciexyz/CieXyz.kt new file mode 100644 index 0000000..05bac71 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/ciexyz/CieXyz.kt @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.ciexyz + +import me.ash.reader.ui.theme.palette.util.div +import me.ash.reader.ui.theme.palette.util.times + +data class CieXyz( + val x: Double, + val y: Double, + val z: Double, +) { + inline val xyz: DoubleArray + get() = doubleArrayOf(x, y, z) + + inline val luminance: Double + get() = y + + operator fun times(luminance: Double): CieXyz = (xyz * luminance).asXyz() + + operator fun div(luminance: Double): CieXyz = (xyz / luminance).asXyz() + + companion object { + internal fun DoubleArray.asXyz(): CieXyz = CieXyz(this[0], this[1], this[2]) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzazbz.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzazbz.kt new file mode 100644 index 0000000..b018380 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzazbz.kt @@ -0,0 +1,79 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.jzazbz + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.util.Matrix3 +import kotlin.math.pow + +data class Jzazbz( + val Jz: Double, + val az: Double, + val bz: Double, +) { + fun toXyz(): CieXyz { + val (x_, y_, z) = lmsToXyz * ( + IzazbzToLms * doubleArrayOf( + (Jz + d_0) / (1.0 + d - d * (Jz + d_0)), + az, + bz, + ) + ).map { + 10000.0 * ((c_1 - it.pow(1.0 / p)) / (c_3 * it.pow(1.0 / p) - c_2)).pow(1.0 / n) + }.toDoubleArray() + val x = (x_ + (b - 1.0) * z) / b + val y = (y_ + (g - 1.0) * x) / g + return CieXyz( + x = x, + y = y, + z = z, + ) + } + + companion object { + private const val b = 1.15 + private const val g = 0.66 + private const val c_1 = 3424.0 / 4096.0 + private const val c_2 = 2413.0 / 128.0 + private const val c_3 = 2392.0 / 128.0 + private const val n = 2610.0 / 16384.0 + private const val p = 1.7 * 2523.0 / 32.0 + private const val d = -0.56 + private const val d_0 = 1.6295499532821566E-11 + + private val xyzToLms: Matrix3 = Matrix3( + doubleArrayOf(0.41478972, 0.579999, 0.01464800), + doubleArrayOf(-0.2015100, 1.120649, 0.05310080), + doubleArrayOf(-0.0166008, 0.264800, 0.66847990), + ) + private val lmsToXyz: Matrix3 = xyzToLms.inverse() + private val lmsToIzazbz: Matrix3 = Matrix3( + doubleArrayOf(0.5, 0.5, 0.0), + doubleArrayOf(3.524000, -4.066708, 0.542708), + doubleArrayOf(0.199076, 1.096799, -1.295875), + ) + private val IzazbzToLms: Matrix3 = lmsToIzazbz.inverse() + + fun CieXyz.toJzazbz(): Jzazbz { + val (Iz, az, bz) = lmsToIzazbz * ( + xyzToLms * doubleArrayOf( + b * x - (b - 1.0) * z, + g * y - (g - 1.0) * x, + z, + ) + ).map { + ((c_1 + c_2 * (it / 10000.0).pow(n)) / (1.0 + c_3 * (it / 10000.0).pow(n))).pow(p) + }.toDoubleArray() + return Jzazbz( + Jz = (1.0 + d) * Iz / (1.0 + d * Iz) - d_0, + az = az, + bz = bz, + ) + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzczhz.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzczhz.kt new file mode 100644 index 0000000..e4d1bc2 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/jzazbz/Jzczhz.kt @@ -0,0 +1,42 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.jzazbz + +import me.ash.reader.ui.theme.palette.util.square +import me.ash.reader.ui.theme.palette.util.toDegrees +import me.ash.reader.ui.theme.palette.util.toRadians +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class Jzczhz( + val Jz: Double, + val Cz: Double, + val hz: Double, +) { + fun toJzazbz(): Jzazbz { + val hRad = hz.toRadians() + return Jzazbz( + Jz = Jz, + az = Cz * cos(hRad), + bz = Cz * sin(hRad), + ) + } + + fun dE(other: Jzczhz): Double = + sqrt(square(Jz - other.Jz) + square(Cz - other.Cz) + 4.0 * Cz * other.Cz * square(sin((hz - other.hz) / 2.0))) + + companion object { + fun Jzazbz.toJzczhz(): Jzczhz = Jzczhz( + Jz = Jz, + Cz = sqrt(square(az) + square(bz)), + hz = atan2(bz, az).toDegrees().mod(360.0), + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklab.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklab.kt new file mode 100644 index 0000000..9d852fe --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklab.kt @@ -0,0 +1,42 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.oklab + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz.Companion.asXyz +import me.ash.reader.ui.theme.palette.util.Matrix3 +import kotlin.math.pow + +data class Oklab( + val L: Double, + val a: Double, + val b: Double, +) { + fun toXyz(): CieXyz = (lmsToXyz * (oklabToLms * doubleArrayOf(L, a, b)).map { it.pow(3.0) } + .toDoubleArray()).asXyz() + + companion object { + private val xyzToLms: Matrix3 = Matrix3( + doubleArrayOf(0.8189330101, 0.3618667424, -0.1288597137), + doubleArrayOf(0.0329845436, 0.9293118715, 0.0361456387), + doubleArrayOf(0.0482003018, 0.2643662691, 0.6338517070), + ) + private val lmsToXyz: Matrix3 = xyzToLms.inverse() + private val lmsToOklab: Matrix3 = Matrix3( + doubleArrayOf(0.2104542553, 0.7936177850, -0.0040720468), + doubleArrayOf(1.9779984951, -2.4285922050, 0.4505937099), + doubleArrayOf(0.0259040371, 0.7827717662, -0.8086757660), + ) + private val oklabToLms: Matrix3 = lmsToOklab.inverse() + + fun CieXyz.toOklab(): Oklab = + (lmsToOklab * (xyzToLms * xyz).map { it.pow(1.0 / 3.0) }.toDoubleArray()).asOklab() + + internal fun DoubleArray.asOklab(): Oklab = Oklab(this[0], this[1], this[2]) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklch.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklch.kt new file mode 100644 index 0000000..68a0027 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/oklab/Oklch.kt @@ -0,0 +1,76 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.oklab + +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb.Companion.toRgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.RgbColorSpace +import me.ash.reader.ui.theme.palette.util.square +import me.ash.reader.ui.theme.palette.util.toDegrees +import me.ash.reader.ui.theme.palette.util.toRadians +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +data class Oklch( + val L: Double, + val C: Double, + val h: Double, +) { + fun toOklab(): Oklab { + val hRad = h.toRadians() + return Oklab( + L = L, + a = C * cos(hRad), + b = C * sin(hRad), + ) + } + + fun clampToRgb(colorSpace: RgbColorSpace): Rgb = + toOklab().toXyz().toRgb(1.0, colorSpace).takeIf { it.isInGamut() } ?: copy( + C = findChromaBoundaryInRgb( + colorSpace, + 0.001 + ) + ).toOklab().toXyz().toRgb(1.0, colorSpace).clamp() + + private fun findChromaBoundaryInRgb( + colorSpace: RgbColorSpace, + error: Double + ): Double = chromaBoundary.getOrPut(Triple(colorSpace.hashCode(), h, L)) { + var low = 0.0 + var high = C + var current = this + while (high - low >= error) { + val mid = (low + high) / 2.0 + current = copy(C = mid) + if (!current.toOklab().toXyz().toRgb(1.0, colorSpace).isInGamut()) { + high = mid + } else { + val next = current.copy(C = mid + error).toOklab().toXyz().toRgb(1.0, colorSpace) + if (next.isInGamut()) { + low = mid + } else { + break + } + } + } + current.C + } + + companion object { + fun Oklab.toOklch(): Oklch = Oklch( + L = L, + C = sqrt(square(a) + square(b)), + h = atan2(b, a).toDegrees().mod(360.0), + ) + + private val chromaBoundary: MutableMap, Double> = mutableMapOf() + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/Rgb.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/Rgb.kt new file mode 100644 index 0000000..9f43047 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/Rgb.kt @@ -0,0 +1,45 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz.Companion.asXyz +import me.ash.reader.ui.theme.palette.util.div + +data class Rgb( + val r: Double, + val g: Double, + val b: Double, + val colorSpace: RgbColorSpace, +) { + inline val rgb: DoubleArray + get() = doubleArrayOf(r, g, b) + + fun isInGamut(): Boolean = rgb.map { it in colorSpace.componentRange }.all { it } + + fun clamp(): Rgb = rgb.map { it.coerceIn(colorSpace.componentRange) }.toDoubleArray().asRgb(colorSpace) + + fun toXyz(luminance: Double): CieXyz = ( + colorSpace.rgbToXyzMatrix * rgb.map { + colorSpace.transferFunction.EOTF(it) + }.toDoubleArray() + ).asXyz() * luminance + + override fun toString(): String = "Rgb(r=$r, g=$g, b=$b, colorSpace=${colorSpace.name})" + + companion object { + fun CieXyz.toRgb( + luminance: Double, + colorSpace: RgbColorSpace + ): Rgb = (colorSpace.rgbToXyzMatrix.inverse() * (xyz / luminance)) + .map { colorSpace.transferFunction.OETF(it) } + .toDoubleArray().asRgb(colorSpace) + + internal fun DoubleArray.asRgb(colorSpace: RgbColorSpace): Rgb = Rgb(this[0], this[1], this[2], colorSpace) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/RgbColorSpace.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/RgbColorSpace.kt new file mode 100644 index 0000000..7b6edde --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/RgbColorSpace.kt @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction.GammaTransferFunction +import me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction.HLGTransferFunction +import me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction.PQTransferFunction +import me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction.TransferFunction +import me.ash.reader.ui.theme.palette.data.Illuminant +import me.ash.reader.ui.theme.palette.util.Matrix3 + +data class RgbColorSpace( + val name: String, + val componentRange: ClosedRange, + val whitePoint: CieXyz, + internal val primaries: Matrix3, + internal val transferFunction: TransferFunction, +) { + internal val rgbToXyzMatrix: Matrix3 + get() { + val M1 = Matrix3( + doubleArrayOf( + primaries[0][0] / primaries[0][1], + primaries[1][0] / primaries[1][1], + primaries[2][0] / primaries[2][1], + ), + doubleArrayOf(1.0, 1.0, 1.0), + doubleArrayOf( + primaries[0][2] / primaries[0][1], + primaries[1][2] / primaries[1][1], + primaries[2][2] / primaries[2][1], + ) + ) + val M2 = M1.inverse() * whitePoint.xyz + return Matrix3( + doubleArrayOf(M1[0][0] * M2[0], M1[0][1] * M2[1], M1[0][2] * M2[2]), + doubleArrayOf(M1[1][0] * M2[0], M1[1][1] * M2[1], M1[1][2] * M2[2]), + doubleArrayOf(M1[2][0] * M2[0], M1[2][1] * M2[1], M1[2][2] * M2[2]), + ) + } + + companion object { + /** + * Standard: IEC 61966-2-1 + * - [Wikipedia: sRGB](https://en.wikipedia.org/wiki/SRGB) + */ + val Srgb: RgbColorSpace = RgbColorSpace( + name = "sRGB", + componentRange = 0.0..1.0, + whitePoint = Illuminant.D65, + primaries = Matrix3( + doubleArrayOf(0.64, 0.33, 0.03), + doubleArrayOf(0.30, 0.60, 0.10), + doubleArrayOf(0.15, 0.06, 0.79), + ), + transferFunction = GammaTransferFunction.sRGB, + ) + + /** + * Standard: SMPTE EG 432-1 + * - [Wikipedia: DCI-P3](https://en.wikipedia.org/wiki/DCI-P3) + */ + val DisplayP3: RgbColorSpace = RgbColorSpace( + name = "Display P3", + componentRange = 0.0..1.0, + whitePoint = Illuminant.D65, + primaries = Matrix3( + doubleArrayOf(0.68, 0.32, 0.00), + doubleArrayOf(0.265, 0.690, 0.045), + doubleArrayOf(0.15, 0.06, 0.79), + ), + transferFunction = GammaTransferFunction.sRGB, + ) + + /** + * Standard: ITU-R BT.2020 + * - [Wikipedia: Rec. 2020](https://en.wikipedia.org/wiki/Rec._2020) + */ + val BT2020: RgbColorSpace = RgbColorSpace( + name = "BT.2020", + componentRange = 0.0..1.0, + whitePoint = Illuminant.D65, + primaries = Matrix3( + doubleArrayOf(0.708, 0.292, 0.000), + doubleArrayOf(0.170, 0.797, 0.033), + doubleArrayOf(0.131, 0.046, 0.823), + ), + transferFunction = GammaTransferFunction.Rec709, + ) + + /** + * Standard: ITU-R BT.2100 + * - [Wikipedia: Rec. 2100](https://en.wikipedia.org/wiki/Rec._2100) + */ + val BT2100PQ: RgbColorSpace = RgbColorSpace( + name = "BT.2100 (PQ)", + componentRange = 0.0..1.0, + whitePoint = Illuminant.D65, + primaries = Matrix3( + doubleArrayOf(0.708, 0.292, 0.000), + doubleArrayOf(0.170, 0.797, 0.033), + doubleArrayOf(0.131, 0.046, 0.823), + ), + transferFunction = PQTransferFunction(), + ) + + /** + * Standard: ITU-R BT.2100 + * - [Wikipedia: Rec. 2100](https://en.wikipedia.org/wiki/Rec._2100) + */ + val BT2100HLG: RgbColorSpace = RgbColorSpace( + name = "BT.2100 (HLG)", + componentRange = 0.0..1.0, + whitePoint = Illuminant.D65, + primaries = Matrix3( + doubleArrayOf(0.708, 0.292, 0.000), + doubleArrayOf(0.170, 0.797, 0.033), + doubleArrayOf(0.131, 0.046, 0.823), + ), + transferFunction = HLGTransferFunction(), + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/GammaTransferFunction.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/GammaTransferFunction.kt new file mode 100644 index 0000000..61c3d4b --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/GammaTransferFunction.kt @@ -0,0 +1,51 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction + +import kotlin.math.pow + +class GammaTransferFunction( + val gamma: Double, // decoding gamma γ + val alpha: Double, // offset a, α = a + 1 + val beta: Double, // linear-domain threshold β = K_0 / φ = E_t + val delta: Double, // linear gain δ = φ +) : TransferFunction { + override fun EOTF(x: Double): Double = when { + x >= beta * delta -> ((x + alpha - 1.0) / alpha).pow(gamma) // transition point βδ = K_0 + else -> x / delta + } + + override fun OETF(x: Double): Double = when { + x >= beta -> alpha * (x.pow(1.0 / gamma) - 1.0) + 1.0 + else -> x * delta + } + + companion object { + /** + * [Wikipedia: sRGB - Computing the transfer function](https://en.wikipedia.org/wiki/SRGB#Computing_the_transfer_function) + */ + val sRGB = GammaTransferFunction( + gamma = 2.4, + alpha = 1.055, + beta = 0.055 / 1.4 / ((1.055 / 2.4).pow(2.4) * (1.4 / 0.055).pow(1.4)), + // ~0.03928571428571429 / ~12.923210180787857 = ~0.0030399346397784314 -> 0.003130804935 + delta = (1.055 / 2.4).pow(2.4) * (1.4 / 0.055).pow(1.4), + // ~12.923210180787857 -> 12.920020442059 + ) + + /** + * [Rec. 709](https://www.itu.int/rec/R-REC-BT.709) + */ + val Rec709 = GammaTransferFunction( + gamma = 2.4, + alpha = 1.0 + 5.5 * 0.018053968510807, // ~1.09929682680944 + beta = 0.018053968510807, + delta = 4.5, + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/HLGTransferFunction.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/HLGTransferFunction.kt new file mode 100644 index 0000000..57b6257 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/HLGTransferFunction.kt @@ -0,0 +1,36 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction + +import me.ash.reader.ui.theme.palette.util.square +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sqrt + +/** + * [Rec. 2100](https://www.itu.int/rec/R-REC-BT.2100) + */ +class HLGTransferFunction : TransferFunction { + companion object { + private val a = 0.17883277 + private val b = 1.0 - 4.0 * a // 0.28466892 + private val c = 0.5 - a * ln(4.0 * a) // 0.55991073 + } + + override fun EOTF(x: Double): Double = when (x) { + in 0.0..1.0 / 2.0 -> 3.0 * square(x) + in 1.0 / 2.0..1.0 -> (exp((x - c) / a) + b) / 12.0 + else -> Double.NaN + } + + override fun OETF(x: Double): Double = when (x) { + in 0.0..1.0 / 12.0 -> sqrt(3.0 * x) + in 1.0 / 12.0..1.0 -> a * ln(12.0 * x - b) + c + else -> Double.NaN + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/PQTransferFunction.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/PQTransferFunction.kt new file mode 100644 index 0000000..5a832f0 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/PQTransferFunction.kt @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction + +import kotlin.math.pow + +/** + * [Rec. 2100](https://www.itu.int/rec/R-REC-BT.2100) + */ +class PQTransferFunction : TransferFunction { + companion object { + private val m_1 = 2610.0 / 16384.0 // 0.1593017578125 + private val m_2 = 2523.0 / 4096.0 * 128.0 // 78.84375 + private val c_1 = 3424.0 / 4096.0 // 0.8359375 = c_3 − c_2 + 1 + private val c_2 = 2413.0 / 4096.0 * 32.0 // 18.8515625 + private val c_3 = 2392.0 / 4096.0 * 32.0 // 18.6875 + } + + override fun EOTF(x: Double): Double = + 10000.0 * ((x.pow(1.0 / m_2).coerceAtLeast(0.0)) / (c_2 - c_3 * x.pow(1.0 / m_2))).pow(1.0 / m_1) + + override fun OETF(x: Double): Double = ((c_1 + c_2 * x / 10000.0) / (1 + c_3 * x / 10000.0)).pow( + m_2 + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/TransferFunction.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/TransferFunction.kt new file mode 100644 index 0000000..ba70d64 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/rgb/transferfunction/TransferFunction.kt @@ -0,0 +1,16 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.rgb.transferfunction + +interface TransferFunction { + // nonlinear -> linear + fun EOTF(x: Double): Double + + // linear -> nonlinear + fun OETF(x: Double): Double +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Izazbz.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Izazbz.kt new file mode 100644 index 0000000..2beb18c --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Izazbz.kt @@ -0,0 +1,74 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.zcam + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.util.Matrix3 +import kotlin.math.pow + +data class Izazbz( + val Iz: Double, + val az: Double, + val bz: Double, +) { + fun toXyz(): CieXyz { + val (x_, y_, z) = lmsToXyz * (IzazbzToLms * doubleArrayOf(Iz + epsilon, az, bz)).map { + 10000.0 * ((c_1 - it.pow(1.0 / rho)) / (c_3 * it.pow(1.0 / rho) - c_2)).pow(1.0 / eta) + }.toDoubleArray() + val x = (x_ + (b - 1.0) * z) / b + val y = (y_ + (g - 1.0) * x) / g + return CieXyz( + x = x, + y = y, + z = z, + ) + } + + companion object { + private const val b = 1.15 + private const val g = 0.66 + private const val c_1 = 3424.0 / 4096.0 + private const val c_2 = 2413.0 / 128.0 + private const val c_3 = 2392.0 / 128.0 + private const val eta = 2610.0 / 16384.0 + private const val rho = 1.7 * 2523.0 / 32.0 + private const val epsilon = 3.7035226210190005E-11 + + private val xyzToLms: Matrix3 = Matrix3( + doubleArrayOf(0.41478972, 0.579999, 0.01464800), + doubleArrayOf(-0.2015100, 1.120649, 0.05310080), + doubleArrayOf(-0.0166008, 0.264800, 0.66847990), + ) + private val lmsToXyz: Matrix3 = xyzToLms.inverse() + private val lmsToIzazbz: Matrix3 = Matrix3( + doubleArrayOf(0.0, 1.0, 0.0), + doubleArrayOf(3.524000, -4.066708, 0.542708), + doubleArrayOf(0.199076, 1.096799, -1.295875), + ) + private val IzazbzToLms: Matrix3 = lmsToIzazbz.inverse() + + fun CieXyz.toIzazbz(): Izazbz { + val (I, az, bz) = lmsToIzazbz * ( + xyzToLms * doubleArrayOf( + b * x - (b - 1.0) * z, + g * y - (g - 1.0) * x, + z, + ) + ).map { + ((c_1 + c_2 * (it / 10000.0).pow(eta)) / (1.0 + c_3 * (it / 10000.0).pow(eta))).pow( + rho + ) + }.toDoubleArray() + return Izazbz( + Iz = I - epsilon, + az = az, + bz = bz, + ) + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Zcam.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Zcam.kt new file mode 100644 index 0000000..9d5e5b7 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/colorspace/zcam/Zcam.kt @@ -0,0 +1,162 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.colorspace.zcam + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb.Companion.toRgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.RgbColorSpace +import me.ash.reader.ui.theme.palette.colorspace.zcam.Izazbz.Companion.toIzazbz +import me.ash.reader.ui.theme.palette.util.square +import me.ash.reader.ui.theme.palette.util.toDegrees +import me.ash.reader.ui.theme.palette.util.toRadians +import kotlin.math.* + +data class Zcam( + val hz: Double = Double.NaN, + val Qz: Double = Double.NaN, + val Jz: Double = Double.NaN, + val Mz: Double = Double.NaN, + val Cz: Double = Double.NaN, + val Sz: Double = Double.NaN, + val Vz: Double = Double.NaN, + val Kz: Double = Double.NaN, + val Wz: Double = Double.NaN, + val cond: ViewingConditions, +) { + fun toIzazbz(): Izazbz { + require(!hz.isNaN()) { "Must provide hz." } + require(!Qz.isNaN() || !Jz.isNaN()) { "Must provide Qz or Jz." } + require(!Mz.isNaN() || !Cz.isNaN() || !Sz.isNaN() || !Vz.isNaN() || !Kz.isNaN() || !Wz.isNaN()) { + "Must provide Mz, Cz, Sz, Vz, Kz or Wz." + } + with(cond) { + val Iz = ( + when { + !Qz.isNaN() -> Qz + !Jz.isNaN() -> Jz * Qzw / 100.0 + else -> Double.NaN + } / (2700.0 * F_s.pow(2.2) * sqrt(F_b) * F_L.pow(0.2)) + ).pow(F_b.pow(0.12) / (1.6 * F_s)) + val Jz = Jz.takeUnless { it.isNaN() } ?: when { + !Qz.isNaN() -> 100.0 * Qz / Qzw + else -> Double.NaN + } + val Qz = Qz.takeUnless { it.isNaN() } ?: when { + !Jz.isNaN() -> Jz * Qzw / 100.0 + else -> Double.NaN + } + val Cz = Cz.takeUnless { it.isNaN() } ?: when { + !Sz.isNaN() -> Qz * square(Sz) / (100.0 * Qzw * F_L.pow(1.2)) + !Vz.isNaN() -> sqrt((square(Vz) - square(Jz - 58.0)) / 3.4) + !Kz.isNaN() -> sqrt((square((100.0 - Kz) / 0.8) - square(Jz)) / 8.0) + !Wz.isNaN() -> sqrt(square(100.0 - Wz) - square(100.0 - Jz)) + else -> Double.NaN + } + val Mz = Mz.takeUnless { it.isNaN() } ?: (Cz * Qzw / 100.0) + + val ez = 1.015 + cos(89.038 + hz).toRadians() + val Cz_ = + (Mz * Izw.pow(0.78) * F_b.pow(0.1) / (100.0 * ez.pow(0.068) * F_L.pow(0.2))).pow(1.0 / (0.37 * 2.0)) + val hzRad = hz.toRadians() + val az = Cz_ * cos(hzRad) + val bz = Cz_ * sin(hzRad) + + return Izazbz( + Iz = Iz, + az = az, + bz = bz, + ) + } + } + + fun clampToRgb(colorSpace: RgbColorSpace): Rgb = toIzazbz() + .toXyz() + .toRgb(cond.luminance, colorSpace) + .takeIf { it.isInGamut() } + ?: copy(Cz = findChromaBoundaryInRgb(colorSpace, 0.001)) + .toIzazbz() + .toXyz() + .toRgb(cond.luminance, colorSpace) + .clamp() + + private fun findChromaBoundaryInRgb( + colorSpace: RgbColorSpace, + error: Double + ): Double = chromaBoundary.getOrPut(Triple(colorSpace.hashCode(), hz, Jz)) { + var low = 0.0 + var high = Cz + var current = this + while (high - low >= error) { + val mid = (low + high) / 2.0 + current = copy(Cz = mid) + if (!current.toIzazbz().toXyz().toRgb(cond.luminance, colorSpace).isInGamut()) { + high = mid + } else { + val next = current.copy(Cz = mid + error).toIzazbz().toXyz().toRgb(cond.luminance, colorSpace) + if (next.isInGamut()) { + low = mid + } else { + break + } + } + } + current.Cz + } + + companion object { + private val chromaBoundary: MutableMap, Double> = mutableMapOf() + + data class ViewingConditions( + val whitePoint: CieXyz, + val luminance: Double, + val F_s: Double, + val L_a: Double, + val Y_b: Double, + ) { + private val absoluteWhitePoint = whitePoint * luminance + private val Y_w = absoluteWhitePoint.luminance + val F_b = sqrt(Y_b / Y_w) + val F_L = 0.171 * L_a.pow(1.0 / 3.0) * (1 - exp(-48.0 / 9.0 * L_a)) + val Izw = absoluteWhitePoint.toIzazbz().Iz + val Qzw = 2700.0 * Izw.pow(1.6 * F_s / F_b.pow(0.12)) * F_s.pow(2.2) * sqrt(F_b) * F_L.pow(0.2) + } + + fun Izazbz.toZcam(cond: ViewingConditions): Zcam { + with(cond) { + val hz = atan2(bz, az).toDegrees().mod(360.0) // hue angle + val Qz = + 2700.0 * Iz.pow(1.6 * F_s / F_b.pow(0.12)) * F_s.pow(2.2) * sqrt(F_b) * F_L.pow(0.2) // brightness + val Jz = 100.0 * Qz / Qzw // lightness + val ez = 1.015 + cos(89.038 + hz).toRadians() // ~ eccentricity factor + val Mz = + 100.0 * (square(az) + square(bz)).pow(0.37) * ez.pow(0.068) * F_L.pow(0.2) / + (F_b.pow(0.1) * Izw.pow(0.78)) // colorfulness + val Cz = 100.0 * Mz / Qzw // chroma + + val Sz = 100.0 * F_L.pow(0.6) * sqrt(Mz / Qz) // saturation + val Vz = sqrt(square(Jz - 58.0) + 3.4 * square(Cz)) // vividness + val Kz = 100.0 - 0.8 * sqrt(square(Jz) + 8.0 * square(Cz)) // blackness + val Wz = 100.0 - sqrt(square(100.0 - Jz) + square(Cz)) // blackness + + return Zcam( + hz = hz, + Qz = Qz, + Jz = Jz, + Mz = Mz, + Cz = Cz, + Sz = Sz, + Vz = Vz, + Kz = Kz, + Wz = Wz, + cond = cond, + ) + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorSpaces.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorSpaces.kt new file mode 100644 index 0000000..1ad9416 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorSpaces.kt @@ -0,0 +1,97 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.core + +import androidx.compose.runtime.Composable +import me.ash.reader.ui.theme.palette.colorspace.cielab.CieLab +import me.ash.reader.ui.theme.palette.colorspace.cielab.CieLab.Companion.toCieLab +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.oklab.Oklch +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb.Companion.toRgb +import me.ash.reader.ui.theme.palette.colorspace.zcam.Izazbz +import me.ash.reader.ui.theme.palette.colorspace.zcam.Izazbz.Companion.toIzazbz +import me.ash.reader.ui.theme.palette.colorspace.zcam.Zcam +import me.ash.reader.ui.theme.palette.colorspace.zcam.Zcam.Companion.toZcam + +@Composable +fun rgb( + r: Double, + g: Double, + b: Double, +): Rgb = Rgb( + r = r, + g = g, + b = b, + colorSpace = LocalRgbColorSpace.current, +) + +@Composable +fun zcamLch( + L: Double, + C: Double, + h: Double, +): Zcam = Zcam( + hz = h, + Jz = L, + Cz = C, + cond = LocalZcamViewingConditions.current, +) + +@Composable +fun zcam( + hue: Double = Double.NaN, + brightness: Double = Double.NaN, + lightness: Double = Double.NaN, + colorfulness: Double = Double.NaN, + chroma: Double = Double.NaN, + saturation: Double = Double.NaN, + vividness: Double = Double.NaN, + blackness: Double = Double.NaN, + whiteness: Double = Double.NaN, +): Zcam = Zcam( + hz = hue, + Qz = brightness, + Jz = lightness, + Mz = colorfulness, + Cz = chroma, + Sz = saturation, + Vz = vividness, + Kz = blackness, + Wz = whiteness, + cond = LocalZcamViewingConditions.current, +) + +@Composable +fun CieXyz.toRgb(): Rgb = toRgb(LocalLuminance.current, LocalRgbColorSpace.current) + +@Composable +fun CieLab.toXyz(): CieXyz = toXyz(LocalWhitePoint.current, LocalLuminance.current) + +@Composable +fun CieXyz.toCieLab(): CieLab = toCieLab(LocalWhitePoint.current, LocalLuminance.current) + +@Composable +fun Rgb.toXyz(): CieXyz = toXyz(LocalLuminance.current) + +@Composable +fun Rgb.toZcam(): Zcam = toXyz().toIzazbz().toZcam() + +@Composable +fun Oklch.clampToRgb(): Rgb = clampToRgb(LocalRgbColorSpace.current) + +@Composable +fun Izazbz.toZcam(): Zcam = toZcam(LocalZcamViewingConditions.current) + +@Composable +fun Zcam.toRgb(): Rgb = + toIzazbz().toXyz() + .toRgb(LocalZcamViewingConditions.current.luminance, LocalRgbColorSpace.current) + +@Composable +fun Zcam.clampToRgb(): Rgb = clampToRgb(LocalRgbColorSpace.current) diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorUtils.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorUtils.kt new file mode 100644 index 0000000..37f433f --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ColorUtils.kt @@ -0,0 +1,67 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.core + +import androidx.compose.animation.core.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import me.ash.reader.ui.theme.palette.colorspace.rgb.Rgb +import me.ash.reader.ui.theme.palette.colorspace.rgb.RgbColorSpace + +fun Rgb.toColor(): Color = if (!r.isNaN() && !g.isNaN() && !b.isNaN()) + Color( + red = r.toFloat(), + green = g.toFloat(), + blue = b.toFloat(), + colorSpace = when (colorSpace) { + RgbColorSpace.Srgb -> ColorSpaces.Srgb + RgbColorSpace.DisplayP3 -> ColorSpaces.DisplayP3 + RgbColorSpace.BT2020 -> ColorSpaces.Bt2020 + else -> ColorSpaces.Srgb + } + ) else Color.Black + +@Composable +fun Color.toRgb(): Rgb { + val color = convert( + when (LocalRgbColorSpace.current) { + RgbColorSpace.Srgb -> ColorSpaces.Srgb + RgbColorSpace.DisplayP3 -> ColorSpaces.DisplayP3 + RgbColorSpace.BT2020 -> ColorSpaces.Bt2020 + else -> ColorSpaces.Srgb + } + ) + return Rgb( + r = color.red.toDouble(), + g = color.green.toDouble(), + b = color.blue.toDouble(), + colorSpace = LocalRgbColorSpace.current + ) +} + +@Composable +fun animateZcamLchAsState( + targetValue: ZcamLch, + animationSpec: AnimationSpec = spring(), + finishedListener: ((ZcamLch) -> Unit)? = null +): State { + val converter = remember { + TwoWayConverter( + convertToVector = { + AnimationVector3D(it.L.toFloat(), it.C.toFloat(), it.h.toFloat()) + }, + convertFromVector = { + ZcamLch(L = it.v1.toDouble(), C = it.v2.toDouble(), h = it.v3.toDouble()) + } + ) + } + return animateValueAsState(targetValue, converter, animationSpec, finishedListener = finishedListener) +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/core/CompositionLocals.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/core/CompositionLocals.kt new file mode 100644 index 0000000..6d4dd66 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/core/CompositionLocals.kt @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import me.ash.reader.ui.theme.palette.colorspace.cielab.CieLab +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz +import me.ash.reader.ui.theme.palette.colorspace.rgb.RgbColorSpace +import me.ash.reader.ui.theme.palette.colorspace.zcam.Zcam +import me.ash.reader.ui.theme.palette.data.Illuminant + +val LocalWhitePoint = staticCompositionLocalOf { + Illuminant.D65 +} + +val LocalLuminance = staticCompositionLocalOf { + 1.0 +} + +val LocalRgbColorSpace = staticCompositionLocalOf { + RgbColorSpace.Srgb +} + +val LocalZcamViewingConditions = staticCompositionLocalOf { + createZcamViewingConditions() +} + +@Composable +fun ProvideZcamViewingConditions( + whitePoint: CieXyz = Illuminant.D65, + luminance: Double = 203.0, // BT.2408-4, HDR white luminance + surroundFactor: Double = 0.69, // average surround + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalWhitePoint provides whitePoint, + LocalLuminance provides luminance, + LocalZcamViewingConditions provides createZcamViewingConditions( + whitePoint = whitePoint, + luminance = luminance, + surroundFactor = surroundFactor, + ) + ) { + content() + } +} + +fun createZcamViewingConditions( + whitePoint: CieXyz = Illuminant.D65, + luminance: Double = 203.0, + surroundFactor: Double = 0.69, +): Zcam.Companion.ViewingConditions = Zcam.Companion.ViewingConditions( + whitePoint = whitePoint, + luminance = luminance, + F_s = surroundFactor, + L_a = 0.4 * luminance, + Y_b = CieLab(50.0, 0.0, 0.0).toXyz(whitePoint, luminance).luminance, +) diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/core/ZcamLch.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ZcamLch.kt new file mode 100644 index 0000000..cd4e8c1 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/core/ZcamLch.kt @@ -0,0 +1,28 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.core + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import me.ash.reader.ui.theme.palette.colorspace.zcam.Zcam + +data class ZcamLch( + val L: Double, + val C: Double, + val h: Double, +) { + @Composable + fun toZcam(): Zcam = zcamLch(L = L, C = C, h = h) + + companion object { + @Composable + fun Color.toZcamLch(): ZcamLch = toRgb().toZcam().toZcamLch() + + fun Zcam.toZcamLch(): ZcamLch = ZcamLch(L = Jz, C = Cz, h = hz) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/data/Illuminant.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/data/Illuminant.kt new file mode 100644 index 0000000..b85e9ae --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/data/Illuminant.kt @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.data + +import me.ash.reader.ui.theme.palette.colorspace.ciexyz.CieXyz + +object Illuminant { + /** CIE Illuminant D65 - standard 2º observer. 6504 K color temperature. + * Values are calculated from [this table](https://github.com/gpmarques/colorimetry/blob/master/all_1nm_data.xls). + */ + val D65: CieXyz by lazy { + CieXyz( + x = 10043.7000153676 / 10567.0816669881, + y = 1.0, + z = 11505.7421788588 / 10567.0816669881, + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/data/Theme.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/data/Theme.kt new file mode 100644 index 0000000..a96f407 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/data/Theme.kt @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.data + +import me.ash.reader.ui.theme.palette.TonalPalettes + +data class Theme( + val hue: Double, + val primaryChroma: Double, +) { + fun toTonalPalettes(): TonalPalettes = TonalPalettes( + hue = hue, + primaryChroma = primaryChroma, + ) + + companion object { + fun TonalPalettes.toTheme(): Theme = Theme( + hue = hue, + primaryChroma = primaryChroma, + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/Harmonies.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/Harmonies.kt new file mode 100644 index 0000000..5276b4a --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/Harmonies.kt @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + */ + +package me.ash.reader.ui.theme.palette.dynamic + +import me.ash.reader.ui.theme.palette.colorspace.zcam.Zcam +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.sign + +fun Zcam.harmonizeTowards( + target: Zcam, + factor: Double = 0.5, + maxHueShift: Double = 15.0 +): Zcam = copy( + hz = hz + ( + ((180.0 - abs(abs(hz - target.hz) - 180.0)) * factor).coerceAtMost(maxHueShift) + ) * ( + listOf( + target.hz - hz, + target.hz - hz + 360.0, + target.hz - hz - 360.0 + ).minOf { + it.absoluteValue + }.sign.takeIf { it != 0.0 } ?: 1.0 + ) +) diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/WallpaperColors.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/WallpaperColors.kt new file mode 100644 index 0000000..e771f04 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/dynamic/WallpaperColors.kt @@ -0,0 +1,52 @@ +/** + * Copyright (C) 2021 Kyant0 + * + * @link https://github.com/Kyant0/MusicYou + * @author Kyant0 + * @modifier Ashinch + */ + +package me.ash.reader.ui.theme.palette.dynamic + +import android.annotation.SuppressLint +import android.app.WallpaperManager +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import kotlinx.coroutines.flow.map +import me.ash.reader.ui.ext.DataStoreKeys +import me.ash.reader.ui.ext.dataStore +import me.ash.reader.ui.theme.palette.TonalPalettes +import me.ash.reader.ui.theme.palette.TonalPalettes.Companion.toTonalPalettes +import me.ash.reader.ui.theme.palette.safeHexToColor + +@SuppressLint("FlowOperatorInvokedInComposition") +@Composable +fun extractTonalPalettesFromUserWallpaper(): List { + val context = LocalContext.current + val customPrimaryColor = + context.dataStore.data.map { it[DataStoreKeys.CustomPrimaryColor.key] ?: "" } + .collectAsState(initial = "").value + + val preset = mutableListOf( + Color(0xFF80BBFF).toTonalPalettes(), + Color(0xFFFFD8E4).toTonalPalettes(), + Color(0xFF62539f).toTonalPalettes(), + Color(0xFFE9B666).toTonalPalettes(), + customPrimaryColor.safeHexToColor().toTonalPalettes() + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && !LocalView.current.isInEditMode) { + val colors = WallpaperManager.getInstance(LocalContext.current) + .getWallpaperColors(WallpaperManager.FLAG_SYSTEM) + val primary = colors?.primaryColor?.toArgb() + val secondary = colors?.secondaryColor?.toArgb() + val tertiary = colors?.tertiaryColor?.toArgb() + if (primary != null) preset.add(Color(primary).toTonalPalettes()) + if (secondary != null) preset.add(Color(secondary).toTonalPalettes()) + if (tertiary != null) preset.add(Color(tertiary).toTonalPalettes()) + } + return preset +} diff --git a/app/src/main/java/me/ash/reader/ui/theme/color/BlueColor.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/BlueColor.kt similarity index 99% rename from app/src/main/java/me/ash/reader/ui/theme/color/BlueColor.kt rename to app/src/main/java/me/ash/reader/ui/theme/palette/preset/BlueColor.kt index 64de0dd..ae3787d 100644 --- a/app/src/main/java/me/ash/reader/ui/theme/color/BlueColor.kt +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/BlueColor.kt @@ -1,4 +1,4 @@ -package me.ash.reader.ui.theme.color +package me.ash.reader.ui.theme.palette.preset import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/me/ash/reader/ui/theme/color/GreenColor.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/GreenColor.kt similarity index 99% rename from app/src/main/java/me/ash/reader/ui/theme/color/GreenColor.kt rename to app/src/main/java/me/ash/reader/ui/theme/palette/preset/GreenColor.kt index 17ac344..2511930 100644 --- a/app/src/main/java/me/ash/reader/ui/theme/color/GreenColor.kt +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/GreenColor.kt @@ -1,4 +1,4 @@ -package me.ash.reader.ui.theme.color +package me.ash.reader.ui.theme.palette.preset import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/me/ash/reader/ui/theme/color/IColor.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/IColor.kt similarity index 99% rename from app/src/main/java/me/ash/reader/ui/theme/color/IColor.kt rename to app/src/main/java/me/ash/reader/ui/theme/palette/preset/IColor.kt index 652b865..bc992da 100644 --- a/app/src/main/java/me/ash/reader/ui/theme/color/IColor.kt +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/IColor.kt @@ -1,4 +1,4 @@ -package me.ash.reader.ui.theme.color +package me.ash.reader.ui.theme.palette.preset import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme diff --git a/app/src/main/java/me/ash/reader/ui/theme/color/PurpleColor.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/PurpleColor.kt similarity index 99% rename from app/src/main/java/me/ash/reader/ui/theme/color/PurpleColor.kt rename to app/src/main/java/me/ash/reader/ui/theme/palette/preset/PurpleColor.kt index 202f188..c4e1ffc 100644 --- a/app/src/main/java/me/ash/reader/ui/theme/color/PurpleColor.kt +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/preset/PurpleColor.kt @@ -1,4 +1,4 @@ -package me.ash.reader.ui.theme.color +package me.ash.reader.ui.theme.palette.preset import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/me/ash/reader/ui/theme/palette/util/MathUtils.kt b/app/src/main/java/me/ash/reader/ui/theme/palette/util/MathUtils.kt new file mode 100644 index 0000000..cdcd13d --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/theme/palette/util/MathUtils.kt @@ -0,0 +1,64 @@ +package me.ash.reader.ui.theme.palette.util + +import kotlin.math.PI + +internal fun square(x: Double): Double = x * x + +internal fun Double.toRadians(): Double = this * PI / 180.0 +internal fun Double.toDegrees(): Double = this * 180.0 / PI + +operator fun DoubleArray.times(x: Double): DoubleArray = map { it * x }.toDoubleArray() +operator fun DoubleArray.div(x: Double): DoubleArray = map { it / x }.toDoubleArray() + +class Matrix3( + private val x: DoubleArray, + private val y: DoubleArray, + private val z: DoubleArray, +) { + fun inverse(): Matrix3 { + val det = determinant() + return Matrix3( + doubleArrayOf( + (y[1] * z[2] - y[2] * z[1]) / det, + (y[2] * z[0] - y[0] * z[2]) / det, + (y[0] * z[1] - y[1] * z[0]) / det, + ), + doubleArrayOf( + (x[2] * z[1] - x[1] * z[2]) / det, + (x[0] * z[2] - x[2] * z[0]) / det, + (x[1] * z[0] - x[0] * z[1]) / det, + ), + doubleArrayOf( + (x[1] * y[2] - x[2] * y[1]) / det, + (x[2] * y[0] - x[0] * y[2]) / det, + (x[0] * y[1] - x[1] * y[0]) / det, + ), + ).transpose() + } + + private fun determinant(): Double = + x[0] * (y[1] * z[2] - y[2] * z[1]) - + x[1] * (y[0] * z[2] - y[2] * z[0]) + + x[2] * (y[0] * z[1] - y[1] * z[0]) + + private fun transpose() = Matrix3( + doubleArrayOf(x[0], y[0], z[0]), + doubleArrayOf(x[1], y[1], z[1]), + doubleArrayOf(x[2], y[2], z[2]), + ) + + operator fun get(i: Int): DoubleArray = when (i) { + 0 -> x + 1 -> y + 2 -> z + else -> throw IndexOutOfBoundsException("Index must be 0, 1 or 2") + } + + operator fun times(vec: DoubleArray): DoubleArray = doubleArrayOf( + x[0] * vec[0] + x[1] * vec[1] + x[2] * vec[2], + y[0] * vec[0] + y[1] * vec[1] + y[2] * vec[2], + z[0] * vec[0] + z[1] * vec[1] + z[2] * vec[2], + ) + + override fun toString(): String = "{" + arrayOf(x, y, z).joinToString { "[" + it.joinToString() + "]" } + "}" +} diff --git a/app/src/main/res/drawable/palettie.xml b/app/src/main/res/drawable/palettie.xml new file mode 100644 index 0000000..a13a0a3 --- /dev/null +++ b/app/src/main/res/drawable/palettie.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8182efe..8652c39 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -79,7 +79,7 @@ 颜色和样式 主题、色彩系统、字体大小 交互 - 布局、触觉反馈 + 布局、触感反馈 语言 英语、中文 提示和支持 @@ -89,4 +89,16 @@ 查看《<u>服务条款与隐私政策</u>》 https://gitee.com/Ashinch/ReadYou/blob/main/TERMS_OF_SERVICE_AND_PRIVACY_POLICY-zh.md 同意并继续 + 壁纸颜色 + 暂无色板 + 仅限 Android 8.1+ + 基本颜色 + 样式 + 深色模式 + 跟随系统设置 + 色调海拔 + 字体 + 基本字体 + 阅读字体 + 阅读字体大小 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4da96ff..93f3345 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,16 @@ View the <i><u>Terms of Service and Privacy Policy</u></i> https://github.com/Ashinch/ReadYou/blob/main/TERMS_OF_SERVICE_AND_PRIVACY_POLICY.md Agree and Continue + Wallpaper Colors + No Palettes + Only Android 8.1+ + Basic Colors + Style + Dark Theme + Use Device Theme + Tonal Elevation + Fonts + Basic Fonts + Reading Fonts + Reading Fonts Size \ No newline at end of file