feat(android): T-AND-007/008 连接页/运行页/关于页 + 骨架叠加
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.opendance.studio.data.AppSettings
|
||||
import com.opendance.studio.ui.AboutScreen
|
||||
import com.opendance.studio.ui.ConnectScreen
|
||||
import com.opendance.studio.ui.RunningScreen
|
||||
import com.opendance.studio.ui.theme.OpenDanceTheme
|
||||
|
||||
/**
|
||||
* 应用入口与页面导航(T-AND-001 / 006 / 007 / 008)。
|
||||
*
|
||||
* 只有三个页面,用简单的状态机导航,不引入 Navigation 组件:
|
||||
* Connect ──开始推流──▶ Running ──结束/返回──▶ Connect
|
||||
* Connect ──关于──▶ About ──返回──▶ Connect
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
OpenDanceTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
OpenDanceRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Screen { CONNECT, RUNNING, ABOUT }
|
||||
|
||||
@Composable
|
||||
private fun OpenDanceRoot() {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { AppSettings(context.applicationContext) }
|
||||
var screen by remember { mutableStateOf(Screen.CONNECT) }
|
||||
|
||||
// 推流中按返回键等同于结束推流,避免相机在后台继续占用
|
||||
BackHandler(enabled = screen == Screen.RUNNING) {
|
||||
screen = Screen.CONNECT
|
||||
}
|
||||
|
||||
when (screen) {
|
||||
Screen.CONNECT -> ConnectScreen(
|
||||
settings = settings,
|
||||
onStart = { screen = Screen.RUNNING },
|
||||
onAbout = { screen = Screen.ABOUT },
|
||||
)
|
||||
|
||||
Screen.RUNNING -> RunningScreen(
|
||||
settings = settings,
|
||||
onStop = { screen = Screen.CONNECT },
|
||||
)
|
||||
|
||||
Screen.ABOUT -> AboutScreen(
|
||||
onBack = { screen = Screen.CONNECT },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import android.app.Application
|
||||
|
||||
/**
|
||||
* 应用级单例容器(T-AND-001 骨架)。
|
||||
*
|
||||
* 只持有跨页面共享、生命周期长于 Activity 的对象;相机与推理器由运行页持有,
|
||||
* 离开运行页即释放,避免相机被后台占用。
|
||||
*/
|
||||
class OpenDanceApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: OpenDanceApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.opendance.studio.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.opendance.studio.BuildConfig
|
||||
import com.opendance.studio.ui.theme.OpenDanceBlue
|
||||
import com.opendance.studio.ui.theme.OpenDanceMuted
|
||||
import com.opendance.studio.ui.theme.OpenDanceSurface
|
||||
|
||||
/**
|
||||
* T-AND-008:关于页面 + 开源声明
|
||||
*
|
||||
* 文档 §8.3 要求 Android App 与 Windows 客户端的「关于」页展示同款声明,可滚动查看。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("关于") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = OpenDanceSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("OpenDance 辅助端", style = MaterialTheme.typography.titleLarge)
|
||||
Text("v${BuildConfig.VERSION_NAME}", color = OpenDanceMuted)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
|
||||
InfoRow("契约版本", BuildConfig.CONTRACT_VERSION)
|
||||
InfoRow("WebSocket 子协议", BuildConfig.WS_SUBPROTOCOL)
|
||||
InfoRow("关键点拓扑", "MediaPipe Pose 33 点")
|
||||
InfoRow("传输帧长", "239 字节")
|
||||
}
|
||||
}
|
||||
|
||||
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("隐私说明", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"本应用只在设备本地进行姿态推理,画面不会离开手机。" +
|
||||
"通过网络发送的只有 33 个骨骼关键点坐标,不含任何图像数据。",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
"未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑,以规避非商用许可限制。",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"第三方开源项目声明(共 ${Credits.entryCount()} 项)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
"与 contracts/open-source-credits.md 及 THIRD_PARTY.md 保持一致。",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Credits.ENTRIES.forEachIndexed { index, entry ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = OpenDanceSurface),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"${index + 1}. ${entry.name}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (entry.copyright.isNotBlank()) {
|
||||
Text("版权:${entry.copyright}", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text("许可证:${entry.license}", style = MaterialTheme.typography.bodySmall)
|
||||
Text("用途:${entry.usage}", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(entry.url)),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(entry.url, color = OpenDanceBlue, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = OpenDanceMuted, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.opendance.studio.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.opendance.studio.data.AppSettings
|
||||
import com.opendance.studio.data.Resolutions
|
||||
import com.opendance.studio.net.HostDiscovery
|
||||
import com.opendance.studio.net.PoseWebSocketClient
|
||||
import com.opendance.studio.ui.theme.OpenDanceGreen
|
||||
import com.opendance.studio.ui.theme.OpenDanceMuted
|
||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||
import com.opendance.studio.ui.theme.OpenDanceSurface
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* T-AND-006:连接页 + 主机发现
|
||||
*
|
||||
* Windows 客户端是裸 WebSocket 服务端,因此发现手段是扫描局域网内开放 8765 端口的主机。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: AppSettings,
|
||||
onStart: () -> Unit,
|
||||
onAbout: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var host by remember { mutableStateOf(settings.host) }
|
||||
var portText by remember { mutableStateOf(settings.port.toString()) }
|
||||
var targetFps by remember { mutableStateOf(settings.targetFps.toString()) }
|
||||
var mirror by remember { mutableStateOf(settings.mirror) }
|
||||
var upperBodyOnly by remember { mutableStateOf(settings.upperBodyOnly) }
|
||||
var resolution by remember { mutableStateOf(settings.resolution) }
|
||||
|
||||
var localIp by remember { mutableStateOf<String?>(null) }
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var candidates by remember { mutableStateOf<List<HostDiscovery.Candidate>>(emptyList()) }
|
||||
var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") }
|
||||
var statusIsError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
localIp = HostDiscovery.localIpv4Address()
|
||||
}
|
||||
|
||||
fun persist() {
|
||||
settings.host = host
|
||||
settings.port = portText.toIntOrNull() ?: AppSettings.DEFAULT_PORT
|
||||
settings.targetFps = targetFps.toIntOrNull() ?: AppSettings.DEFAULT_FPS
|
||||
settings.mirror = mirror
|
||||
settings.upperBodyOnly = upperBodyOnly
|
||||
settings.resolution = resolution
|
||||
// hello 控制帧里要带上这两项
|
||||
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
|
||||
PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("连接设置") },
|
||||
actions = { TextButton(onClick = onAbout) { Text("关于") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = OpenDanceSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
|
||||
// ---------------- 目标地址 ----------------
|
||||
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Windows 客户端地址", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"本机地址:${localIp ?: "未连接到 WiFi"}",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("IP 地址") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(2f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = portText,
|
||||
onValueChange = { portText = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("端口") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
enabled = !scanning,
|
||||
onClick = {
|
||||
val ip = localIp
|
||||
if (ip == null) {
|
||||
status = "未连接到 WiFi,无法扫描"
|
||||
statusIsError = true
|
||||
return@OutlinedButton
|
||||
}
|
||||
val prefix = HostDiscovery.subnetPrefix(ip)
|
||||
if (prefix == null) {
|
||||
status = "无法解析网段($ip)"
|
||||
statusIsError = true
|
||||
return@OutlinedButton
|
||||
}
|
||||
scanning = true
|
||||
candidates = emptyList()
|
||||
status = "正在扫描 $prefix.0/24 …"
|
||||
statusIsError = false
|
||||
scope.launch {
|
||||
try {
|
||||
HostDiscovery.scan(prefix).collect { candidate ->
|
||||
candidates = candidates + candidate
|
||||
}
|
||||
status = if (candidates.isEmpty()) {
|
||||
"未发现开放 ${AppSettings.DEFAULT_PORT} 端口的主机;请确认 Windows 客户端已启动并放行防火墙"
|
||||
} else {
|
||||
"发现 ${candidates.size} 台候选主机,点击即可填入"
|
||||
}
|
||||
statusIsError = candidates.isEmpty()
|
||||
} catch (e: Exception) {
|
||||
status = "扫描失败:${e.message}"
|
||||
statusIsError = true
|
||||
} finally {
|
||||
scanning = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("扫描局域网") }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val p = portText.toIntOrNull() ?: AppSettings.DEFAULT_PORT
|
||||
scope.launch {
|
||||
status = "正在测试 $host:$p …"
|
||||
statusIsError = false
|
||||
val result = HostDiscovery.checkHost(host, p)
|
||||
if (result != null) {
|
||||
status = "端口可达(${result.latencyMs} ms),可以开始推流"
|
||||
statusIsError = false
|
||||
} else {
|
||||
status = "无法连接 $host:$p,请检查 IP、端口与防火墙"
|
||||
statusIsError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("测试连接") }
|
||||
}
|
||||
|
||||
if (scanning) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.width(18.dp))
|
||||
Text("扫描中…", color = OpenDanceMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Text("候选主机", style = MaterialTheme.typography.labelLarge)
|
||||
candidates.forEach { candidate ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
host = candidate.ip
|
||||
status = "已选择 ${candidate.ip}:${candidate.port}(${candidate.latencyMs} ms)"
|
||||
statusIsError = false
|
||||
},
|
||||
) {
|
||||
Text("${candidate.ip}:${candidate.port} ${candidate.latencyMs} ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 推流参数 ----------------
|
||||
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("推流参数", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = Resolutions.label(resolution),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("分辨率") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
Resolutions.ALL.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(Resolutions.label(option)) },
|
||||
onClick = {
|
||||
resolution = option
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = targetFps,
|
||||
onValueChange = { targetFps = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("目标帧率(FPS)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SwitchRow(
|
||||
label = "镜像画面",
|
||||
hint = "前置摄像头常用;会在帧头 flags.bit0 中声明,接收端据此还原",
|
||||
checked = mirror,
|
||||
onCheckedChange = { mirror = it },
|
||||
)
|
||||
|
||||
SwitchRow(
|
||||
label = "仅上半身模式",
|
||||
hint = "下半身点置信度置 0,接收端会插值;带宽不足时更稳",
|
||||
checked = upperBodyOnly,
|
||||
onCheckedChange = { upperBodyOnly = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
status,
|
||||
color = if (statusIsError) OpenDanceRed else OpenDanceGreen,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
persist()
|
||||
onStart()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("开始推流")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(
|
||||
label: String,
|
||||
hint: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label)
|
||||
Text(hint, color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.opendance.studio.ui
|
||||
|
||||
/**
|
||||
* T-AND-008:开源声明数据
|
||||
*
|
||||
* 与 contracts/open-source-credits.md(v0.1.0)逐条对应,
|
||||
* 并与 opendance-web 的 THIRD_PARTY.md、opendance-windows 的关于页保持一致。
|
||||
* 该表是**唯一来源**,新增依赖须先回到 opendance-web 更新契约。
|
||||
*/
|
||||
object Credits {
|
||||
|
||||
const val CONTRACT_VERSION = "0.1.0"
|
||||
|
||||
data class Entry(
|
||||
val name: String,
|
||||
val license: String,
|
||||
val url: String,
|
||||
val usage: String,
|
||||
val copyright: String = "",
|
||||
)
|
||||
|
||||
val ENTRIES: List<Entry> = listOf(
|
||||
Entry("MediaPipe", "Apache License 2.0", "https://github.com/google-ai-edge/mediapipe",
|
||||
"端侧姿态推理(pose_landmarker_lite.task)", "Copyright 2023 Google LLC"),
|
||||
Entry("Three.js", "MIT", "https://github.com/mrdoob/three.js", "Web 管理端 3D 预览"),
|
||||
Entry("@pixiv/three-vrm", "MIT", "https://github.com/pixiv/three-vrm", "Web 管理端 VRM 加载"),
|
||||
Entry("Assimp", "BSD-3-Clause", "https://github.com/assimp/assimp", "FBX/glTF 动作转换(可选)"),
|
||||
Entry("Godot Engine", "MIT", "https://github.com/godotengine/godot", "Windows 客户端引擎",
|
||||
"Copyright Godot Engine contributors"),
|
||||
Entry("godot-vrm", "MIT", "https://github.com/V-Sekai/godot-vrm", "VRM humanoid 扩展"),
|
||||
Entry("NestJS", "MIT", "https://github.com/nestjs/nest", "Web 管理服务端"),
|
||||
Entry("TypeORM", "MIT", "https://github.com/typeorm/typeorm", "Web 管理服务端 ORM"),
|
||||
Entry("OkHttp", "Apache License 2.0", "https://github.com/square/okhttp",
|
||||
"本应用的 WebSocket 推流"),
|
||||
Entry("bvh", "MIT", "https://github.com/aimoonchen/bvh", "BVH 解析参考实现"),
|
||||
Entry("React", "MIT", "https://github.com/facebook/react", "Web 管理端界面"),
|
||||
Entry("Vite", "MIT", "https://github.com/vitejs/vite", "Web 管理端构建"),
|
||||
Entry("CameraX", "Apache License 2.0",
|
||||
"https://developer.android.com/jetpack/androidx/releases/camera",
|
||||
"本应用的相机采集"),
|
||||
Entry("Jetpack Compose", "Apache License 2.0", "https://developer.android.com/jetpack/compose",
|
||||
"本应用界面"),
|
||||
Entry("mysql2", "MIT", "https://github.com/sidorares/node-mysql2", "Web 服务端数据库驱动"),
|
||||
)
|
||||
|
||||
fun entryCount(): Int = ENTRIES.size
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.opendance.studio.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.os.SystemClock
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.opendance.studio.camera.CameraController
|
||||
import com.opendance.studio.data.AppSettings
|
||||
import com.opendance.studio.net.PoseWebSocketClient
|
||||
import com.opendance.studio.pose.PoseDetector
|
||||
import com.opendance.studio.ui.theme.OpenDanceGreen
|
||||
import com.opendance.studio.ui.theme.OpenDanceMuted
|
||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||
import com.opendance.studio.ui.theme.OpenDanceSurface
|
||||
import com.opendance.studio.ui.theme.OpenDanceYellow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* T-AND-007:运行页 + 状态指示
|
||||
*
|
||||
* 数据流:
|
||||
* ```
|
||||
* CameraX(ImageAnalysis) ──ImageProxy──▶ MediaPipe PoseLandmarker(端侧推理)
|
||||
* │ 33 点归一化坐标
|
||||
* ▼
|
||||
* KeypointFrame(239B) ──▶ PoseWebSocketClient ──▶ Windows
|
||||
* │
|
||||
* └──▶ SkeletonOverlay(叠加层)
|
||||
* ```
|
||||
* 画面本身**从不离开设备**。
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun RunningScreen(
|
||||
settings: AppSettings,
|
||||
onStop: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
var overlay by remember { mutableStateOf(FloatArray(0)) }
|
||||
var confidence by remember { mutableStateOf<FloatArray?>(null) }
|
||||
var detectionFps by remember { mutableStateOf(0f) }
|
||||
var inferenceMs by remember { mutableStateOf(0L) }
|
||||
var poseDetected by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var cameraError by remember { mutableStateOf("") }
|
||||
|
||||
val wsStats = remember { PoseWebSocketClient(scope) }
|
||||
val stats by wsStats.stats.collectAsState()
|
||||
|
||||
val cameraController = remember { CameraController(context) }
|
||||
val poseDetector = remember { PoseDetector(context) }
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
|
||||
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例
|
||||
val analyzer = remember {
|
||||
CameraController.FrameAnalyzer { imageProxy ->
|
||||
try {
|
||||
poseDetector.detect(
|
||||
imageProxy = imageProxy,
|
||||
timestampMs = SystemClock.uptimeMillis(),
|
||||
rotationDegrees = imageProxy.imageInfo.rotationDegrees,
|
||||
)
|
||||
} finally {
|
||||
// toBitmap() 已在 detect() 内同步完成,此处可安全关闭
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 帧率统计窗口
|
||||
var framesInWindow by remember { mutableStateOf(0) }
|
||||
var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) }
|
||||
|
||||
// ---------------- 权限 ----------------
|
||||
LaunchedEffect(Unit) {
|
||||
if (!cameraPermission.status.isGranted) {
|
||||
cameraPermission.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 初始化:MediaPipe + WebSocket ----------------
|
||||
DisposableEffect(Unit) {
|
||||
poseDetector.mirror = settings.mirror
|
||||
poseDetector.upperBodyOnly = settings.upperBodyOnly
|
||||
poseDetector.onError = { errorMessage = it }
|
||||
poseDetector.onResult = { result ->
|
||||
inferenceMs = result.inferenceMs
|
||||
poseDetected = result.overlay.isNotEmpty()
|
||||
|
||||
if (result.overlay.isNotEmpty()) {
|
||||
overlay = result.overlay
|
||||
confidence = result.frame.confidence
|
||||
wsStats.sendFrame(result.frame)
|
||||
}
|
||||
|
||||
framesInWindow += 1
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val elapsed = now - windowStart
|
||||
if (elapsed >= 1000) {
|
||||
detectionFps = framesInWindow * 1000f / elapsed
|
||||
framesInWindow = 0
|
||||
windowStart = now
|
||||
wsStats.sendStats(
|
||||
fps = detectionFps,
|
||||
dropped = stats.droppedFrames,
|
||||
latencyMs = 0,
|
||||
confidenceAvg = result.frame.averageConfidence(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!poseDetector.initialize()) {
|
||||
errorMessage = "MediaPipe 初始化失败,请确认模型文件已随 APK 打包"
|
||||
}
|
||||
|
||||
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
|
||||
PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution
|
||||
wsStats.connect(settings.webSocketUrl(), settings.autoReconnect)
|
||||
|
||||
onDispose {
|
||||
wsStats.release()
|
||||
poseDetector.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 权限未授予 ----------------
|
||||
if (!cameraPermission.status.isGranted) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("需要相机权限才能进行姿态识别")
|
||||
Text(
|
||||
"画面只在设备本地推理,不会上传",
|
||||
color = OpenDanceMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(onClick = { cameraPermission.launchPermissionRequest() }) {
|
||||
Text("授予相机权限")
|
||||
}
|
||||
OutlinedButton(onClick = onStop) { Text("返回") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------- 主体 ----------------
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
post {
|
||||
previewView = this
|
||||
cameraController.start(
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
previewView = this,
|
||||
resolution = settings.resolution,
|
||||
frontCamera = true,
|
||||
analyzer = analyzer,
|
||||
onError = { cameraError = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SkeletonOverlay(
|
||||
overlay = overlay,
|
||||
confidence = confidence,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// ---------------- HUD ----------------
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = OpenDanceSurface.copy(alpha = 0.88f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("连接状态", color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
text = stateLabel(stats.state, stats.reconnectAttempts),
|
||||
color = stateColor(stats.state),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
InfoLine("目标", stats.url)
|
||||
if (stats.serverSessionId.isNotBlank()) {
|
||||
InfoLine("会话", stats.serverSessionId)
|
||||
}
|
||||
InfoLine("已发送", "${stats.sentFrames} 帧")
|
||||
InfoLine(
|
||||
"已丢弃",
|
||||
"${stats.droppedFrames} 帧" + if (stats.droppedFrames > 0) "(推理/连接跟不上)" else "",
|
||||
)
|
||||
InfoLine("发送帧率", "%.1f FPS".format(stats.sendFps))
|
||||
InfoLine("识别帧率", "%.1f FPS".format(detectionFps))
|
||||
InfoLine("推理耗时", "$inferenceMs ms")
|
||||
InfoLine(
|
||||
"人体识别",
|
||||
if (poseDetected) "已识别" else "未识别到人体",
|
||||
if (poseDetected) OpenDanceGreen else OpenDanceYellow,
|
||||
)
|
||||
confidence?.let {
|
||||
val avg = it.average()
|
||||
InfoLine(
|
||||
"平均置信度",
|
||||
"%.0f%%".format(avg * 100),
|
||||
if (avg >= 0.7f) OpenDanceGreen else OpenDanceYellow,
|
||||
)
|
||||
}
|
||||
if (stats.lastError.isNotBlank()) {
|
||||
InfoLine("最近错误", stats.lastError, OpenDanceRed)
|
||||
}
|
||||
if (errorMessage.isNotBlank()) {
|
||||
InfoLine("推理错误", errorMessage, OpenDanceRed)
|
||||
}
|
||||
if (cameraError.isNotBlank()) {
|
||||
InfoLine("相机错误", cameraError, OpenDanceRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 底部操作 ----------------
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val view = previewView
|
||||
if (view == null) {
|
||||
cameraError = "预览尚未就绪"
|
||||
} else {
|
||||
cameraError = ""
|
||||
cameraController.switchCamera(lifecycleOwner, view, analyzer)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("切换摄像头") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
wsStats.sendBye("user_stop")
|
||||
onStop()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("结束推流") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoLine(label: String, value: String, valueColor: Color = Color.Unspecified) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = OpenDanceMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = valueColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateLabel(state: PoseWebSocketClient.State, attempts: Int): String = when (state) {
|
||||
PoseWebSocketClient.State.IDLE -> "未连接"
|
||||
PoseWebSocketClient.State.CONNECTING -> "连接中…"
|
||||
PoseWebSocketClient.State.CONNECTED -> "已连接"
|
||||
PoseWebSocketClient.State.RECONNECTING -> "重连中(第 $attempts 次)"
|
||||
PoseWebSocketClient.State.CLOSED -> "已断开"
|
||||
}
|
||||
|
||||
private fun stateColor(state: PoseWebSocketClient.State): Color = when (state) {
|
||||
PoseWebSocketClient.State.CONNECTED -> OpenDanceGreen
|
||||
PoseWebSocketClient.State.CONNECTING,
|
||||
PoseWebSocketClient.State.RECONNECTING,
|
||||
-> OpenDanceYellow
|
||||
else -> OpenDanceRed
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.opendance.studio.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import com.opendance.studio.model.KeypointFrame
|
||||
import com.opendance.studio.model.SkeletonTopology
|
||||
import com.opendance.studio.ui.theme.OpenDanceBlue
|
||||
import com.opendance.studio.ui.theme.OpenDanceRed
|
||||
import com.opendance.studio.ui.theme.OpenDanceYellow
|
||||
|
||||
/**
|
||||
* T-AND-007:骨架叠加层
|
||||
*
|
||||
* 把 33 个关键点与骨架连线画在相机预览之上。
|
||||
* 低置信度点用红色标出,让用户直观看到「哪些部位没被识别到」——
|
||||
* 这比单纯显示一个错误码有用得多。
|
||||
*
|
||||
* @param overlay 归一化坐标 [x0,y0,x1,y1,...],与预览同一坐标系(已按需镜像)
|
||||
*/
|
||||
@Composable
|
||||
fun SkeletonOverlay(
|
||||
overlay: FloatArray,
|
||||
confidence: FloatArray?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
if (overlay.size < SkeletonTopology.KEYPOINT_COUNT * 2) return@Canvas
|
||||
|
||||
fun point(index: Int): Offset = Offset(
|
||||
overlay[index * 2] * size.width,
|
||||
overlay[index * 2 + 1] * size.height,
|
||||
)
|
||||
|
||||
// 连线
|
||||
for ((a, b) in SkeletonTopology.CONNECTIONS) {
|
||||
val reliable = confidence == null ||
|
||||
(confidence.getOrElse(a) { 1f } >= KeypointFrame.CONFIDENCE_THRESHOLD &&
|
||||
confidence.getOrElse(b) { 1f } >= KeypointFrame.CONFIDENCE_THRESHOLD)
|
||||
drawLine(
|
||||
color = if (reliable) OpenDanceBlue else OpenDanceRed.copy(alpha = 0.6f),
|
||||
start = point(a),
|
||||
end = point(b),
|
||||
strokeWidth = if (reliable) 6f else 3f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
|
||||
// 关节点
|
||||
for (i in 0 until SkeletonTopology.KEYPOINT_COUNT) {
|
||||
val reliable = confidence == null ||
|
||||
confidence.getOrElse(i) { 1f } >= KeypointFrame.CONFIDENCE_THRESHOLD
|
||||
val center = point(i)
|
||||
if (reliable) {
|
||||
drawCircle(OpenDanceYellow, radius = 7f, center = center)
|
||||
drawCircle(Color.Black.copy(alpha = 0.35f), radius = 7f, center = center, style = Stroke(width = 2f))
|
||||
} else {
|
||||
drawCircle(OpenDanceRed.copy(alpha = 0.7f), radius = 4f, center = center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.opendance.studio.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** 与 Windows 客户端 / 管理端一致的配色 */
|
||||
val OpenDanceBlue = Color(0xFF4EA1FF)
|
||||
val OpenDanceGreen = Color(0xFF7AE582)
|
||||
val OpenDanceYellow = Color(0xFFFFD166)
|
||||
val OpenDanceRed = Color(0xFFFF6B6B)
|
||||
val OpenDanceBackground = Color(0xFF1B1F2A)
|
||||
val OpenDanceSurface = Color(0xFF232838)
|
||||
val OpenDanceText = Color(0xFFE8ECF5)
|
||||
val OpenDanceMuted = Color(0xFF9AA4B8)
|
||||
|
||||
private val OpenDanceColorScheme = darkColorScheme(
|
||||
primary = OpenDanceBlue,
|
||||
onPrimary = OpenDanceBackground,
|
||||
secondary = OpenDanceGreen,
|
||||
onSecondary = OpenDanceBackground,
|
||||
tertiary = OpenDanceYellow,
|
||||
background = OpenDanceBackground,
|
||||
onBackground = OpenDanceText,
|
||||
surface = OpenDanceSurface,
|
||||
onSurface = OpenDanceText,
|
||||
error = OpenDanceRed,
|
||||
onError = OpenDanceBackground,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OpenDanceTheme(
|
||||
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// 本应用固定深色:相机预览场景下深色 UI 干扰更小
|
||||
MaterialTheme(
|
||||
colorScheme = OpenDanceColorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user