feat(android): T-AND-007/008 连接页/运行页/关于页 + 骨架叠加

This commit is contained in:
2026-09-14 20:40:16 +08:00
parent 5737a66afd
commit 8f0eeb7697
9 changed files with 1177 additions and 0 deletions
@@ -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)
}
}