Skip to content

Commit 64fc11e

Browse files
committed
feat: live2d character overlay
1 parent 8c61741 commit 64fc11e

13 files changed

Lines changed: 230 additions & 48 deletions

File tree

composeApp/src/commonMain/kotlin/com/xiaoyv/bangumi/BgmApp.kt

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
package com.xiaoyv.bangumi
44

5+
import androidx.compose.foundation.gestures.awaitEachGesture
6+
import androidx.compose.foundation.gestures.awaitFirstDown
7+
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.BoxScope
59
import androidx.compose.foundation.layout.aspectRatio
610
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.offset
712
import androidx.compose.foundation.layout.padding
813
import androidx.compose.foundation.layout.systemBarsPadding
914
import androidx.compose.foundation.layout.width
@@ -17,7 +22,12 @@ import androidx.compose.runtime.remember
1722
import androidx.compose.runtime.setValue
1823
import androidx.compose.ui.Alignment
1924
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.input.pointer.PointerEventPass
26+
import androidx.compose.ui.input.pointer.pointerInput
27+
import androidx.compose.ui.input.pointer.positionChange
28+
import androidx.compose.ui.unit.IntOffset
2029
import androidx.compose.ui.unit.dp
30+
import kotlin.math.roundToInt
2131
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2232
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
2333
import coil3.ImageLoader
@@ -38,6 +48,8 @@ import com.xiaoyv.bangumi.shared.component.LaunchReceiveShareImageEffect
3848
import com.xiaoyv.bangumi.shared.component.Live2D
3949
import com.xiaoyv.bangumi.shared.component.rememberLive2DState
4050
import com.xiaoyv.bangumi.shared.data.api.client.ApiClient
51+
import com.xiaoyv.bangumi.shared.data.model.response.bgm.ComposeSetting
52+
import com.xiaoyv.bangumi.shared.ui.theme.currentInDarkTheme
4153
import com.xiaoyv.bangumi.shared.data.manager.app.LocalPersonalState
4254
import com.xiaoyv.bangumi.shared.data.manager.app.PersonalStateStore
4355
import com.xiaoyv.bangumi.shared.data.manager.shared.LocalSharedModelStoreOwner
@@ -142,34 +154,104 @@ fun App() = KoinApplication(configuration = koinConfiguration(declaration = { in
142154
hostState = popupTipState.state
143155
)
144156

145-
if (LocalSharedState.current.settings.live2d.enable) {
146-
val live2DState = rememberLive2DState()
147-
148-
Live2D(
149-
modifier = Modifier
150-
.align(Alignment.BottomEnd)
151-
.systemBarsPadding()
152-
.padding(bottom = 80.dp)
153-
.width(100.dp)
154-
.aspectRatio(202 / 308f),
155-
state = live2DState
156-
)
157+
BgmLive2DOverlay(
158+
live2dConfig = LocalSharedState.current.settings.live2d
159+
)
160+
}
161+
}
162+
163+
HandleShareContent(navigator)
164+
}
165+
166+
@Composable
167+
private fun BoxScope.BgmLive2DOverlay(
168+
live2dConfig: ComposeSetting.Live2dConfig,
169+
) {
170+
if (!live2dConfig.enable) return
171+
172+
val inDark = currentInDarkTheme()
173+
val modelName = remember(live2dConfig.shell, inDark) {
174+
when (live2dConfig.shell) {
175+
ComposeSetting.Live2dConfig.Shell.MUSUME -> "bangumi_musume_2026_parts_grouped"
176+
ComposeSetting.Live2dConfig.Shell.BLACK_MUSUME -> "bangumi_black_musume_2026_parts"
177+
else -> if (inDark) "bangumi_black_musume_2026_parts" else "bangumi_musume_2026_parts_grouped"
178+
}
179+
}
180+
val live2DState = rememberLive2DState()
181+
182+
var offsetX by remember { mutableStateOf(0f) }
183+
var offsetY by remember { mutableStateOf(0f) }
184+
185+
Box(
186+
modifier = Modifier
187+
.align(Alignment.BottomEnd)
188+
.systemBarsPadding()
189+
.padding(bottom = 80.dp)
190+
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
191+
.pointerInput(Unit) {
192+
awaitEachGesture {
193+
val down = awaitFirstDown(requireUnconsumed = false)
194+
val downPosition = down.position
195+
val downTime = down.uptimeMillis
196+
val pointerId = down.id
197+
var isLongPress = false
157198

158-
LaunchedEffect(Unit) {
159-
val workDir = (FileKit.filesDir / "live2d").also {
160-
it.createDirectories()
199+
while (true) {
200+
val event = awaitPointerEvent(PointerEventPass.Main)
201+
val change = event.changes.firstOrNull { it.id == pointerId } ?: break
202+
203+
if (!change.pressed) {
204+
break
205+
}
206+
207+
val distance = (change.position - downPosition).getDistance()
208+
val duration = change.uptimeMillis - downTime
209+
210+
// 如果在长按等待时间内移动距离超过 TouchSlop,说明是 Live2D 的交互滑动而非长按拖拽
211+
if (distance > viewConfiguration.touchSlop) {
212+
break
213+
}
214+
215+
// 保持长按静止达到 LongPress 阈值
216+
if (duration >= viewConfiguration.longPressTimeoutMillis) {
217+
isLongPress = true
218+
break
219+
}
161220
}
162-
val name = "bangumi_black_musume_2026_parts"
163-
val targetFile = Res.copyToDir(resourcePath = "files/live2d/$name.zip", workDir)
164221

165-
live2DState.workDir = workDir.absolutePath()
166-
live2DState.loadModel(targetFile.absolutePath(), name)
222+
if (isLongPress) {
223+
while (true) {
224+
val event = awaitPointerEvent(PointerEventPass.Main)
225+
val change = event.changes.firstOrNull { it.id == pointerId } ?: break
226+
if (!change.pressed) break
227+
228+
val dragAmount = change.positionChange()
229+
offsetX += dragAmount.x
230+
offsetY += dragAmount.y
231+
change.consume()
232+
}
233+
}
167234
}
168235
}
169-
}
236+
.width(live2dConfig.size.dp)
237+
.aspectRatio(202 / 308f)
238+
) {
239+
Live2D(
240+
modifier = Modifier.fillMaxSize(),
241+
state = live2DState
242+
)
170243
}
171244

172-
HandleShareContent(navigator)
245+
LaunchedEffect(modelName) {
246+
val workDir = (FileKit.filesDir / "live2d").also {
247+
it.createDirectories()
248+
}
249+
250+
val targetFile = Res.copyToDir(resourcePath = "files/live2d/$modelName.zip", workDir)
251+
252+
live2DState.workDir = workDir.absolutePath()
253+
live2DState.loadModel(targetFile.absolutePath(), modelName)
254+
}
173255
}
174256

175257
@Composable

features/settings/live2d/src/commonMain/kotlin/com/xiaoyv/bangumi/features/settings/live2d/SettingsLive2dScreen.kt

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,31 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
1616
import androidx.compose.ui.unit.dp
1717
import com.xiaoyv.bangumi.core_resource.resources.Res
1818
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d
19-
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_voice
19+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell
20+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_auto
21+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_black_musume
22+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_musume
23+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size
24+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_100
25+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_150
26+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_200
27+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_50
2028
import com.xiaoyv.bangumi.features.settings.live2d.business.SettingsLive2dEvent
2129
import com.xiaoyv.bangumi.features.settings.live2d.business.SettingsLive2dState
2230
import com.xiaoyv.bangumi.features.settings.live2d.business.SettingsLive2dViewModel
2331
import com.xiaoyv.bangumi.shared.core.mvi.UiState
2432
import com.xiaoyv.bangumi.shared.data.manager.shared.currentSettings
33+
import com.xiaoyv.bangumi.shared.data.model.response.bgm.ComposeSetting
2534
import com.xiaoyv.bangumi.shared.ui.component.bar.BgmLargeTopAppBar
2635
import com.xiaoyv.bangumi.shared.ui.component.layout.state.StateLayout
2736
import com.xiaoyv.bangumi.shared.ui.component.navigation.Screen
2837
import com.xiaoyv.bangumi.shared.ui.component.settings.SettingContainer
38+
import com.xiaoyv.bangumi.shared.ui.component.settings.SettingOptionItem
2939
import com.xiaoyv.bangumi.shared.ui.component.settings.SettingSwitchItem
40+
import com.xiaoyv.bangumi.shared.ui.component.tab.ComposeTextTab
41+
import kotlinx.collections.immutable.persistentListOf
3042
import com.xiaoyv.bangumi.shared.ui.kts.collectBaseSideEffect
43+
import org.jetbrains.compose.resources.StringResource
3144
import org.jetbrains.compose.resources.stringResource
3245
import org.orbitmvi.orbit.compose.collectAsState
3346

@@ -100,20 +113,47 @@ private fun SettingsLive2dScreenContent(
100113
SettingContainer(label = { Text(text = stringResource(Res.string.settings_live2d)) }) {
101114
SettingSwitchItem(
102115
title = stringResource(Res.string.settings_live2d),
103-
shape = ListItemDefaults.segmentedShapes(0, 2),
116+
shape = ListItemDefaults.segmentedShapes(0, 3),
104117
value = settings.live2d.enable,
105118
onValueChange = {
106119
onActionEvent(SettingsLive2dEvent.Action.OnUpdate(settings.live2d.copy(enable = it)))
107120
}
108121
)
109-
SettingSwitchItem(
110-
title = stringResource(Res.string.settings_live2d_voice),
111-
shape = ListItemDefaults.segmentedShapes(1, 2),
112-
value = settings.live2d.voiceEnable,
113-
onValueChange = {
114-
onActionEvent(SettingsLive2dEvent.Action.OnUpdate(settings.live2d.copy(voiceEnable = it)))
122+
SettingOptionItem(
123+
title = stringResource(Res.string.settings_live2d_shell),
124+
shape = ListItemDefaults.segmentedShapes(1, 3),
125+
value = stringResource(getLive2dShellStringRes(settings.live2d.shell)),
126+
items = state.shellItems,
127+
onClick = {
128+
onActionEvent(SettingsLive2dEvent.Action.OnUpdate(settings.live2d.copy(shell = it)))
129+
}
130+
)
131+
SettingOptionItem(
132+
title = stringResource(Res.string.settings_live2d_size),
133+
shape = ListItemDefaults.segmentedShapes(2, 3),
134+
value = stringResource(getLive2dSizeStringRes(settings.live2d.size)),
135+
items = state.sizeItems,
136+
onClick = {
137+
onActionEvent(SettingsLive2dEvent.Action.OnUpdate(settings.live2d.copy(size = it)))
115138
}
116139
)
117140
}
118141
}
119142
}
143+
144+
private fun getLive2dShellStringRes(shell: Int): StringResource {
145+
return when (shell) {
146+
ComposeSetting.Live2dConfig.Shell.MUSUME -> Res.string.settings_live2d_shell_musume
147+
ComposeSetting.Live2dConfig.Shell.BLACK_MUSUME -> Res.string.settings_live2d_shell_black_musume
148+
else -> Res.string.settings_live2d_shell_auto
149+
}
150+
}
151+
152+
private fun getLive2dSizeStringRes(size: Int): StringResource {
153+
return when (size) {
154+
ComposeSetting.Live2dConfig.Size.SIZE_50 -> Res.string.settings_live2d_size_50
155+
ComposeSetting.Live2dConfig.Size.SIZE_150 -> Res.string.settings_live2d_size_150
156+
ComposeSetting.Live2dConfig.Size.SIZE_200 -> Res.string.settings_live2d_size_200
157+
else -> Res.string.settings_live2d_size_100
158+
}
159+
}

features/settings/live2d/src/commonMain/kotlin/com/xiaoyv/bangumi/features/settings/live2d/business/SettingsLive2dState.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
package com.xiaoyv.bangumi.features.settings.live2d.business
22

33
import androidx.compose.runtime.Immutable
4+
import com.xiaoyv.bangumi.core_resource.resources.Res
5+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_auto
6+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_black_musume
7+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_shell_musume
8+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_100
9+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_150
10+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_200
11+
import com.xiaoyv.bangumi.core_resource.resources.settings_live2d_size_50
12+
import com.xiaoyv.bangumi.shared.core.utils.serialization.SerializeList
13+
import com.xiaoyv.bangumi.shared.data.model.response.bgm.ComposeSetting
14+
import com.xiaoyv.bangumi.shared.ui.component.tab.ComposeTextTab
15+
import kotlinx.collections.immutable.persistentListOf
416

517
/**
618
* [SettingsLive2dState]
@@ -11,4 +23,15 @@ import androidx.compose.runtime.Immutable
1123
@Immutable
1224
data class SettingsLive2dState(
1325
val title: String = "",
26+
val shellItems: SerializeList<ComposeTextTab<Int>> = persistentListOf(
27+
ComposeTextTab(ComposeSetting.Live2dConfig.Shell.AUTO, Res.string.settings_live2d_shell_auto),
28+
ComposeTextTab(ComposeSetting.Live2dConfig.Shell.MUSUME, Res.string.settings_live2d_shell_musume),
29+
ComposeTextTab(ComposeSetting.Live2dConfig.Shell.BLACK_MUSUME, Res.string.settings_live2d_shell_black_musume),
30+
),
31+
val sizeItems: SerializeList<ComposeTextTab<Int>> = persistentListOf(
32+
ComposeTextTab(ComposeSetting.Live2dConfig.Size.SIZE_50, Res.string.settings_live2d_size_50),
33+
ComposeTextTab(ComposeSetting.Live2dConfig.Size.SIZE_100, Res.string.settings_live2d_size_100),
34+
ComposeTextTab(ComposeSetting.Live2dConfig.Size.SIZE_150, Res.string.settings_live2d_size_150),
35+
ComposeTextTab(ComposeSetting.Live2dConfig.Size.SIZE_200, Res.string.settings_live2d_size_200),
36+
),
1437
)

features/splash/src/commonMain/kotlin/com/xiaoyv/bangumi/features/settings/SplashScreen.kt

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import com.xiaoyv.bangumi.shared.component.Live2D
4646
import com.xiaoyv.bangumi.shared.component.rememberLive2DState
4747
import com.xiaoyv.bangumi.shared.core.utils.debugLog
4848
import com.xiaoyv.bangumi.shared.core.utils.joinToString
49+
import com.xiaoyv.bangumi.shared.data.manager.shared.currentSettings
50+
import com.xiaoyv.bangumi.shared.data.model.response.bgm.ComposeSetting
51+
import com.xiaoyv.bangumi.shared.ui.theme.currentInDarkTheme
4952
import com.xiaoyv.bangumi.shared.ui.component.layout.state.BgmProgressIndicator
5053
import com.xiaoyv.bangumi.shared.resource.copyToDir
5154
import com.xiaoyv.bangumi.shared.ui.component.navigation.Screen
@@ -107,15 +110,19 @@ fun Live2DSplash(onClick: () -> Unit) {
107110
debugLog { "Fetched expressions (${expressions.size}): ${expressions.joinToString(",")}" }
108111
}
109112

110-
fun loadModel() {
113+
fun loadModel(shell: Int, inDark: Boolean) {
111114
if (isLoading) return
112115
isLoading = true
113116
scope.launch {
114117
try {
115118
val workDir = (FileKit.filesDir / "live2d").also {
116119
it.createDirectories()
117120
}
118-
val name = "bangumi_black_musume_2026_parts"
121+
val name = when (shell) {
122+
ComposeSetting.Live2dConfig.Shell.MUSUME -> "bangumi_musume_2026_parts_grouped"
123+
ComposeSetting.Live2dConfig.Shell.BLACK_MUSUME -> "bangumi_black_musume_2026_parts"
124+
else -> if (inDark) "bangumi_black_musume_2026_parts" else "bangumi_musume_2026_parts_grouped"
125+
}
119126
val targetFile = Res.copyToDir("files/live2d/$name.zip", workDir)
120127
val path = targetFile.absolutePath()
121128
live2DState.workDir = workDir.absolutePath()
@@ -245,15 +252,18 @@ fun Live2DSplash(onClick: () -> Unit) {
245252
}
246253
}
247254

248-
// --- Action Buttons Bar ---
249-
Row(
250-
modifier = Modifier.fillMaxWidth(),
251-
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
255+
val shell = currentSettings().live2d.shell
256+
val inDark = currentInDarkTheme()
257+
258+
// --- Action Buttons Bar ---
259+
Row(
260+
modifier = Modifier.fillMaxWidth(),
261+
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally)
262+
) {
263+
Button(
264+
onClick = { loadModel(shell, inDark) },
265+
enabled = !isLoading
252266
) {
253-
Button(
254-
onClick = { loadModel() },
255-
enabled = !isLoading
256-
) {
257267
Text(if (isLoaded) "重新加载模型" else "加载 Live2D 模型")
258268
}
259269

Binary file not shown.
-1.16 KB
Binary file not shown.
Binary file not shown.

shared/core-native/src/cpp/live2d_renderer.cpp

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,12 @@ class Live2DModelWrapper : public CubismUserModel {
400400
if (pixels) {
401401
for (int pIdx = 0; pIdx < w * h; pIdx++) {
402402
unsigned char* p = pixels + pIdx * 4;
403-
float alphaFactor = static_cast<float>(p[3]) / 255.0f;
404-
p[0] = static_cast<unsigned char>(p[0] * alphaFactor);
405-
p[1] = static_cast<unsigned char>(p[1] * alphaFactor);
406-
p[2] = static_cast<unsigned char>(p[2] * alphaFactor);
403+
unsigned int a = p[3];
404+
if (a == 255) continue;
405+
if (a == 0) { p[0] = p[1] = p[2] = 0; continue; }
406+
p[0] = static_cast<unsigned char>((static_cast<unsigned int>(p[0]) * a + 127) / 255);
407+
p[1] = static_cast<unsigned char>((static_cast<unsigned int>(p[1]) * a + 127) / 255);
408+
p[2] = static_cast<unsigned char>((static_cast<unsigned int>(p[2]) * a + 127) / 255);
407409
}
408410

409411
GLuint texId;
@@ -622,10 +624,12 @@ class Live2DModelWrapper : public CubismUserModel {
622624
if (pixels) {
623625
for (int pIdx = 0; pIdx < w * h; pIdx++) {
624626
unsigned char* p = pixels + pIdx * 4;
625-
float alphaFactor = static_cast<float>(p[3]) / 255.0f;
626-
p[0] = static_cast<unsigned char>(p[0] * alphaFactor);
627-
p[1] = static_cast<unsigned char>(p[1] * alphaFactor);
628-
p[2] = static_cast<unsigned char>(p[2] * alphaFactor);
627+
unsigned int a = p[3];
628+
if (a == 255) continue;
629+
if (a == 0) { p[0] = p[1] = p[2] = 0; continue; }
630+
p[0] = static_cast<unsigned char>((static_cast<unsigned int>(p[0]) * a + 127) / 255);
631+
p[1] = static_cast<unsigned char>((static_cast<unsigned int>(p[1]) * a + 127) / 255);
632+
p[2] = static_cast<unsigned char>((static_cast<unsigned int>(p[2]) * a + 127) / 255);
629633
}
630634

631635
GLuint texId;
64 Bytes
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)