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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.opendance.studio
|
||||||
|
|
||||||
|
import com.opendance.studio.ui.Credits
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-AND-008:开源声明一致性与完整性
|
||||||
|
*
|
||||||
|
* 文档 §8.2 列出的最低 10 项必须全部声明;每条都必须有许可证与 https 源链接。
|
||||||
|
* 客户端「关于」页与 THIRD_PARTY.md、contracts/open-source-credits.md 三处一致。
|
||||||
|
*/
|
||||||
|
class CreditsTest {
|
||||||
|
|
||||||
|
private val requiredNames = listOf(
|
||||||
|
"MediaPipe", "Three.js", "@pixiv/three-vrm", "Assimp", "Godot Engine",
|
||||||
|
"godot-vrm", "NestJS", "TypeORM", "OkHttp", "bvh",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun coversDocumentedMinimumSet() {
|
||||||
|
val names = Credits.ENTRIES.map { it.name }
|
||||||
|
for (required in requiredNames) {
|
||||||
|
assertTrue("文档要求的开源项目「$required」必须声明", names.contains(required))
|
||||||
|
}
|
||||||
|
assertTrue("声明条目不少于 10 项", Credits.entryCount() >= 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyEntryHasLicenseAndHttpsLink() {
|
||||||
|
for (entry in Credits.ENTRIES) {
|
||||||
|
assertTrue("${entry.name} 有许可证", entry.license.isNotBlank())
|
||||||
|
assertTrue("${entry.name} 的源链接为 https", entry.url.startsWith("https://"))
|
||||||
|
assertTrue("${entry.name} 注明了用途", entry.usage.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun knownLicensesAreCorrect() {
|
||||||
|
val expected = mapOf(
|
||||||
|
"MediaPipe" to "Apache License 2.0",
|
||||||
|
"Godot Engine" to "MIT",
|
||||||
|
"Assimp" to "BSD-3-Clause",
|
||||||
|
"OkHttp" to "Apache License 2.0",
|
||||||
|
"CameraX" to "Apache License 2.0",
|
||||||
|
"Jetpack Compose" to "Apache License 2.0",
|
||||||
|
"bvh" to "MIT",
|
||||||
|
)
|
||||||
|
for (entry in Credits.ENTRIES) {
|
||||||
|
expected[entry.name]?.let { license ->
|
||||||
|
assertEquals("${entry.name} 的许可证", license, entry.license)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thisAppOwnDependenciesAreDeclared() {
|
||||||
|
// 本端实际引入的运行时依赖必须出现在声明里
|
||||||
|
val names = Credits.ENTRIES.map { it.name }
|
||||||
|
for (own in listOf("MediaPipe", "OkHttp", "CameraX", "Jetpack Compose")) {
|
||||||
|
assertTrue("本端依赖「$own」必须声明", names.contains(own))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun entryNamesAreUnique() {
|
||||||
|
val names = Credits.ENTRIES.map { it.name }
|
||||||
|
assertEquals("开源项目名不得重复", names.size, names.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contractVersionIsPinned() {
|
||||||
|
assertEquals("契约版本必须与 opendance-web v0.1.0 对齐", "0.1.0", Credits.CONTRACT_VERSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user