diff --git a/app/shared/data/common/data/serializers/DanmakuConfigSerializer.kt b/app/shared/data/common/data/serializers/DanmakuConfigSerializer.kt index 19ab1bf466..8ce8833887 100644 --- a/app/shared/data/common/data/serializers/DanmakuConfigSerializer.kt +++ b/app/shared/data/common/data/serializers/DanmakuConfigSerializer.kt @@ -18,6 +18,7 @@ object DanmakuConfigSerializer : KSerializer { val style: DanmakuStyleData = DanmakuStyleData(), val speed: Float = DanmakuConfig.Default.speed, val safeSeparation: Int = DanmakuConfig.Default.safeSeparation.value.toInt(), + val enableColor: Boolean = DanmakuConfig.Default.enableColor, ) /** @@ -45,6 +46,7 @@ object DanmakuConfigSerializer : KSerializer { ), speed = data.speed, safeSeparation = data.safeSeparation.dp, + enableColor = data.enableColor, ) } @@ -58,6 +60,7 @@ object DanmakuConfigSerializer : KSerializer { ), speed = value.speed, safeSeparation = value.safeSeparation.value.toInt(), + enableColor = value.enableColor, ) return DanmakuConfigData.serializer().serialize(encoder, data) diff --git a/app/shared/pages/episode-play/common/EpisodeVideo.kt b/app/shared/pages/episode-play/common/EpisodeVideo.kt index 9d635c77bc..06d4dca7f8 100644 --- a/app/shared/pages/episode-play/common/EpisodeVideo.kt +++ b/app/shared/pages/episode-play/common/EpisodeVideo.kt @@ -129,7 +129,7 @@ internal fun EpisodeVideoImpl( enter = fadeIn(tween(200)), exit = fadeOut(tween(200)) ) { - DanmakuHost(danmakuHostState, Modifier.matchParentSize(), danmakuConfig()) + DanmakuHost(danmakuHostState, Modifier.matchParentSize(), danmakuConfig) } }, gestureHost = { diff --git a/app/shared/pages/episode-play/common/video/settings/EpisodeVideoSettings.kt b/app/shared/pages/episode-play/common/video/settings/EpisodeVideoSettings.kt index a4b52e02ba..03a3591c89 100644 --- a/app/shared/pages/episode-play/common/video/settings/EpisodeVideoSettings.kt +++ b/app/shared/pages/episode-play/common/video/settings/EpisodeVideoSettings.kt @@ -1,210 +1,181 @@ package me.him188.ani.app.ui.subject.episode.video.settings -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.merge import me.him188.ani.app.data.repositories.SettingsRepository -import me.him188.ani.app.ui.foundation.AbstractViewModel -import me.him188.ani.app.ui.foundation.launchInBackground +import me.him188.ani.app.ui.external.placeholder.placeholder +import me.him188.ani.app.ui.settings.SettingsTab +import me.him188.ani.app.ui.settings.SliderItem +import me.him188.ani.app.ui.settings.SwitchItem +import me.him188.ani.app.ui.settings.framework.AbstractSettingsViewModel import me.him188.ani.app.ui.theme.aniDarkColorTheme import me.him188.ani.danmaku.ui.DanmakuConfig import me.him188.ani.danmaku.ui.DanmakuStyle -import me.him188.ani.utils.logging.info -import me.him188.ani.utils.logging.logger -import moe.tlaster.precompose.flow.collectAsStateWithLifecycle import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.seconds +@Stable interface EpisodeVideoSettingsViewModel { - val danmakuConfig: Flow + val danmakuConfig: DanmakuConfig + val isLoading: Boolean + fun setDanmakuConfig(config: DanmakuConfig) } - fun EpisodeVideoSettingsViewModel(): EpisodeVideoSettingsViewModel = EpisodeVideoSettingsViewModelImpl() -private class EpisodeVideoSettingsViewModelImpl : EpisodeVideoSettingsViewModel, AbstractViewModel(), KoinComponent { +private class EpisodeVideoSettingsViewModelImpl : EpisodeVideoSettingsViewModel, AbstractSettingsViewModel(), + KoinComponent { private val settingsRepository by inject() - private val danmakuConfigPersistent = settingsRepository.danmakuConfig.flow - private val danmakuConfigUpdate: MutableStateFlow = MutableStateFlow(null) - override val danmakuConfig = merge(danmakuConfigPersistent, danmakuConfigUpdate).filterNotNull() + val danmakuConfigSettings by settings( + settingsRepository.danmakuConfig, + DanmakuConfig(_placeholder = -1) + ) - init { - launchInBackground { - danmakuConfigUpdate.debounce(0.1.seconds).filterNotNull().collect { - logger.info { "Saving DanmakuConfig: $it" } - settingsRepository.danmakuConfig.set(it) - } - } - } + override val danmakuConfig: DanmakuConfig by danmakuConfigSettings + override val isLoading: Boolean get() = danmakuConfigSettings.loading override fun setDanmakuConfig(config: DanmakuConfig) { - danmakuConfigUpdate.value = config - } - - private companion object { - private val logger = logger(EpisodeVideoSettingsViewModelImpl::class) + danmakuConfigSettings.update(config) } } -private data class StepRange( - val range: ClosedFloatingPointRange, - val steps: Int, -) - -//@Stable -//private fun generateRange( -// maxRange: ClosedFloatingPointRange, -// stepSize: Float, -// steps: Int -//): StepRange { -// return base - stepSize * steps / 2..base + stepSize * steps / 2 -//} - @Composable fun EpisodeVideoSettings( vm: EpisodeVideoSettingsViewModel, modifier: Modifier = Modifier, ) { - val danmakuConfig by vm.danmakuConfig.collectAsStateWithLifecycle(DanmakuConfig.Default) return EpisodeVideoSettings( - danmakuConfig = danmakuConfig, - setDanmakuConfig = vm::setDanmakuConfig, + danmakuConfig = vm.danmakuConfig, + setDanmakuConfig = remember(vm) { + vm::setDanmakuConfig + }, + isLoading = remember(vm) { + { vm.isLoading } + }, modifier = modifier, ) } +@Stable +private val LOADING_FALSE = { false } + @Composable fun EpisodeVideoSettings( danmakuConfig: DanmakuConfig, setDanmakuConfig: (config: DanmakuConfig) -> Unit, + isLoading: () -> Boolean = LOADING_FALSE, modifier: Modifier = Modifier, ) { - Column(modifier.verticalScroll(rememberScrollState())) { - Text( - "弹幕设置", - Modifier.padding(horizontal = 8.dp, vertical = 8.dp), - style = MaterialTheme.typography.titleMedium - ) - - Column { + val isLoadingState by remember(isLoading) { + derivedStateOf(isLoading) + } + SettingsTab(modifier) { + Group( + useThinHeader = true, + title = { + Text("弹幕设置") + }, + ) { val fontSize by remember(danmakuConfig) { mutableFloatStateOf(danmakuConfig.style.fontSize.value / DanmakuStyle.Default.fontSize.value) } - Text( - "弹幕字号: ${(fontSize * 100).roundToInt()}%", - Modifier.padding(horizontal = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - - Slider( + SliderItem( value = fontSize, onValueChange = { + // 故意每次改都更新, 可以即时预览 setDanmakuConfig( - danmakuConfig.copy(danmakuConfig.style.copy(fontSize = DanmakuStyle.Default.fontSize * it)) + danmakuConfig.copy(style = danmakuConfig.style.copy(fontSize = DanmakuStyle.Default.fontSize * it)) ) }, valueRange = 0.50f..3f, steps = ((3f - 0.50f) / 0.05f).toInt() - 1, + title = { Text("弹幕字号") }, + valueLabel = { Text(remember(fontSize) { "${(fontSize * 100).roundToInt()}%" }) }, + modifier = Modifier.placeholder(isLoadingState), ) - } - Column { val alpha by remember(danmakuConfig) { mutableFloatStateOf(danmakuConfig.style.alpha) } - Text( - "不透明度: ${(alpha * 100).roundToInt()}%", - Modifier.padding(horizontal = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - - Slider( + SliderItem( value = alpha, onValueChange = { setDanmakuConfig( - danmakuConfig.copy(danmakuConfig.style.copy(alpha = it)) + danmakuConfig.copy(style = danmakuConfig.style.copy(alpha = it)) ) }, valueRange = 0f..1f, steps = ((1f - 0f) / 0.05f).toInt() - 1, + title = { Text("不透明度") }, + valueLabel = { Text(remember(alpha) { "${(alpha * 100).roundToInt()}%" }) }, + modifier = Modifier.placeholder(isLoadingState), ) - } - - Column { - var strokeMiterValue by remember(danmakuConfig) { + val strokeWidth by remember(danmakuConfig) { mutableFloatStateOf(danmakuConfig.style.strokeWidth / DanmakuStyle.Default.strokeWidth) } - Text( - "描边宽度: ${(strokeMiterValue * 100).roundToInt()}%", - Modifier.padding(horizontal = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - Slider( - value = strokeMiterValue, - onValueChange = { strokeMiterValue = it }, - valueRange = 0f..2f, - steps = ((2f - 0f) / 0.1f).toInt() - 1, - onValueChangeFinished = { + SliderItem( + value = strokeWidth, + onValueChange = { setDanmakuConfig( - danmakuConfig.copy(danmakuConfig.style.copy(strokeMidth = strokeMiterValue * DanmakuStyle.Default.strokeWidth)) + danmakuConfig.copy(style = danmakuConfig.style.copy(strokeWidth = it * DanmakuStyle.Default.strokeWidth)) ) - } + }, + valueRange = 0f..2f, + steps = ((2f - 0f) / 0.1f).toInt() - 1, + title = { Text("描边宽度") }, + valueLabel = { Text(remember(strokeWidth) { "${(strokeWidth * 100).roundToInt()}%" }) }, + modifier = Modifier.placeholder(isLoadingState), ) - } - Column { var speed by remember(danmakuConfig) { mutableFloatStateOf( danmakuConfig.speed / DanmakuConfig.Default.speed ) } - Text( - "弹幕速度: ${(speed * 100).roundToInt()}%", - Modifier.padding(horizontal = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - Text( - "弹幕速度不会跟随视频倍速变化", - Modifier.padding(horizontal = 8.dp).padding(top = 4.dp), - style = MaterialTheme.typography.labelSmall - ) - Slider( + SliderItem( value = speed, onValueChange = { speed = it }, valueRange = 0.2f..3f, steps = ((3f - 0.2f) / 0.1f).toInt() - 1, + title = { Text("弹幕速度") }, + description = { Text("弹幕速度不会跟随视频倍速变化") }, onValueChangeFinished = { setDanmakuConfig( danmakuConfig.copy( speed = speed * DanmakuConfig.Default.speed ) ) - } + }, + valueLabel = { Text(remember(speed) { "${(speed * 100).roundToInt()}%" }) }, + modifier = Modifier.placeholder(isLoadingState), + ) + var enableColor = remember(danmakuConfig) { danmakuConfig.enableColor } + SwitchItem( + enableColor, + onCheckedChange = { + enableColor = it + setDanmakuConfig( + danmakuConfig.copy(enableColor = it) + ) + }, + title = { Text("彩色弹幕") }, + description = { Text("关闭后所有彩色弹幕都会显示为白色") }, + modifier = Modifier.placeholder(isLoadingState), ) } } diff --git a/danmaku/ui/androidMain/DanmakuHost.android.kt b/danmaku/ui/androidMain/DanmakuHost.android.kt index a3294e1345..81d3caa9f3 100644 --- a/danmaku/ui/androidMain/DanmakuHost.android.kt +++ b/danmaku/ui/androidMain/DanmakuHost.android.kt @@ -97,13 +97,13 @@ internal actual fun PreviewDanmakuHost() = ProvideCompositionLocalsForPreview { Row { DanmakuHost( state, - Modifier.weight(1f), - config - ) + Modifier.weight(1f) + ) { config } VerticalDivider() EpisodeVideoSettings( config, { config = it }, + isLoading = { false }, Modifier.weight(1f) ) } @@ -113,13 +113,13 @@ internal actual fun PreviewDanmakuHost() = ProvideCompositionLocalsForPreview { state, Modifier .weight(1f) - .fillMaxWidth(), - config - ) + .fillMaxWidth() + ) { config } HorizontalDivider() EpisodeVideoSettings( config, { config = it }, + isLoading = { false }, Modifier.weight(1f) ) } @@ -135,7 +135,7 @@ private fun PreviewDanmakuText() { Surface(color = Color.White) { DanmakuText( DummyDanmakuState, - style = DanmakuStyle() + style = DanmakuStyle(), ) } } diff --git a/danmaku/ui/commonMain/DanmakuConfig.kt b/danmaku/ui/commonMain/DanmakuConfig.kt index 3d2f307e00..ea4c09d24d 100644 --- a/danmaku/ui/commonMain/DanmakuConfig.kt +++ b/danmaku/ui/commonMain/DanmakuConfig.kt @@ -12,13 +12,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.serialization.Transient import me.him188.ani.danmaku.api.Danmaku /** * Configuration for the presentation of each [Danmaku]. */ @Immutable -class DanmakuConfig( +data class DanmakuConfig( /** * Controls the text styles of the [Danmaku]. * For example, font size, stroke width. @@ -35,30 +36,14 @@ class DanmakuConfig( * The minimum distance between two [Danmaku]s so that they don't overlap. */ val safeSeparation: Dp = 48.dp, + /** + * 允许彩色弹幕. 禁用时将会把所有彩色弹幕都显示为白色. + */ + val enableColor: Boolean = true, + @Suppress("PropertyName") @Transient val _placeholder: Int = 0, ) { - fun copy( - style: DanmakuStyle = this.style, - speed: Float = this.speed, - safeSeparation: Dp = this.safeSeparation, - ): DanmakuConfig { - if (style == this.style && - speed == this.speed && - safeSeparation == this.safeSeparation - ) { - return this - } - return DanmakuConfig( - style = style, - speed = speed, - safeSeparation = safeSeparation, - ) - } - - override fun toString(): String { - return "DanmakuConfig(style=$style, speed=$speed, safeSeparation=$safeSeparation)" - } - companion object { + @Stable val Default = DanmakuConfig() } } @@ -86,9 +71,9 @@ class DanmakuStyle( // 'inside' the border @Stable - fun styleForText(): TextStyle = TextStyle( + fun styleForText(color: Color = Color.White): TextStyle = TextStyle( fontSize = fontSize, - color = Color.White, + color = color, textMotion = TextMotion.Animated, ) @@ -96,13 +81,13 @@ class DanmakuStyle( fontSize: TextUnit = this.fontSize, alpha: Float = this.alpha, strokeColor: Color = this.strokeColor, - strokeMidth: Float = this.strokeWidth, + strokeWidth: Float = this.strokeWidth, shadow: Shadow? = this.shadow, ): DanmakuStyle { if (fontSize == this.fontSize && alpha == this.alpha && strokeColor == this.strokeColor && - strokeMidth == this.strokeWidth && + strokeWidth == this.strokeWidth && shadow == this.shadow ) { return this @@ -111,7 +96,7 @@ class DanmakuStyle( fontSize = fontSize, alpha = alpha, strokeColor = strokeColor, - strokeWidth = strokeMidth, + strokeWidth = strokeWidth, shadow = shadow, ) } diff --git a/danmaku/ui/commonMain/DanmakuHost.kt b/danmaku/ui/commonMain/DanmakuHost.kt index a8224602d6..bd728fcfb9 100644 --- a/danmaku/ui/commonMain/DanmakuHost.kt +++ b/danmaku/ui/commonMain/DanmakuHost.kt @@ -84,7 +84,7 @@ fun DanmakuHostState( fun DanmakuHost( state: DanmakuHostState, modifier: Modifier = Modifier, - config: DanmakuConfig = DanmakuConfig.Default, + config: () -> DanmakuConfig, ) { Column(modifier.background(Color.Transparent)) { val baseStyle = MaterialTheme.typography.bodyMedium @@ -93,11 +93,11 @@ fun DanmakuHost( DanmakuTrack(track, Modifier.fillMaxWidth(), config, baseStyle = baseStyle) { // fix height even if there is no danmaku showing // so that the second track will not move to the top of the screen when the first track is empty. - danmaku(DummyDanmakuState, Modifier.alpha(0f).padding(vertical = 1.dp), style = config.style) + danmaku(DummyDanmakuState, Modifier.alpha(0f).padding(vertical = 1.dp)) for (danmaku in track.visibleDanmaku) { key(danmaku.presentation.id) { - danmaku(danmaku, style = config.style) + danmaku(danmaku) } } } diff --git a/danmaku/ui/commonMain/DanmakuTrack.kt b/danmaku/ui/commonMain/DanmakuTrack.kt index d39f7fe97a..1dd0a8b09c 100644 --- a/danmaku/ui/commonMain/DanmakuTrack.kt +++ b/danmaku/ui/commonMain/DanmakuTrack.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onPlaced @@ -300,9 +301,8 @@ abstract class DanmakuTrackScope { fun danmaku( danmaku: DanmakuState, modifier: Modifier = Modifier, - style: DanmakuStyle, ) { - danmakuImpl(danmaku, style, modifier) + danmakuImpl(danmaku, modifier) } /** @@ -312,7 +312,6 @@ abstract class DanmakuTrackScope { internal abstract fun danmakuImpl( // need this because abstract composable cannot have defaults danmaku: DanmakuState, - style: DanmakuStyle, modifier: Modifier, ) } @@ -321,11 +320,11 @@ abstract class DanmakuTrackScope { fun DanmakuTrack( trackState: DanmakuTrackState, modifier: Modifier = Modifier, - config: DanmakuConfig = DanmakuConfig.Default, + config: () -> DanmakuConfig = { DanmakuConfig.Default }, baseStyle: TextStyle = MaterialTheme.typography.bodyMedium, content: @Composable DanmakuTrackScope.() -> Unit, // box scope ) { - val configUpdated by rememberUpdatedState(config) + val configUpdated by remember(config) { derivedStateOf(config) } val safeSeparation by rememberUpdatedState( with(LocalDensity.current) { configUpdated.safeSeparation.toPx() @@ -344,7 +343,6 @@ fun DanmakuTrack( @Composable override fun danmakuImpl( danmaku: DanmakuState, - style: DanmakuStyle, modifier: Modifier ) { Box( @@ -360,7 +358,7 @@ fun DanmakuTrack( ) { DanmakuText( danmaku, - style = style, + config = configUpdated, baseStyle = baseStyle, onTextLayout = { danmaku.textWidth = it.size.width @@ -419,7 +417,8 @@ fun DanmakuTrack( fun DanmakuText( state: DanmakuState, modifier: Modifier = Modifier, - style: DanmakuStyle = DanmakuStyle.Default, + config: DanmakuConfig = DanmakuConfig.Default, + style: DanmakuStyle = config.style, baseStyle: TextStyle = MaterialTheme.typography.bodyMedium, onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { @@ -442,8 +441,21 @@ fun DanmakuText( overflow = TextOverflow.Clip, maxLines = 1, softWrap = false, - style = baseStyle.merge(style.styleForText()), + style = baseStyle.merge( + style.styleForText( + color = if (config.enableColor) { + rgbColor( + state.presentation.danmaku.color.toUInt().toLong() + ) + } else Color.White + ) + ), textDecoration = if (state.presentation.isSelf) TextDecoration.Underline else null, ) } } + +@Suppress("NOTHING_TO_INLINE") +private inline fun rgbColor(value: Long): Color { + return Color(0xFF_00_00_00L or value) +}