diff --git a/app/src/main/java/com/opendance/studio/MainActivity.kt b/app/src/main/java/com/opendance/studio/MainActivity.kt new file mode 100644 index 0000000..1a673f6 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/MainActivity.kt @@ -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 }, + ) + } +} diff --git a/app/src/main/java/com/opendance/studio/OpenDanceApp.kt b/app/src/main/java/com/opendance/studio/OpenDanceApp.kt new file mode 100644 index 0000000..44dc378 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/OpenDanceApp.kt @@ -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 + } +} diff --git a/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt b/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt new file mode 100644 index 0000000..a72d491 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/AboutScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt b/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt new file mode 100644 index 0000000..cf89295 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/ConnectScreen.kt @@ -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(null) } + var scanning by remember { mutableStateOf(false) } + var candidates by remember { mutableStateOf>(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) + } +} diff --git a/app/src/main/java/com/opendance/studio/ui/Credits.kt b/app/src/main/java/com/opendance/studio/ui/Credits.kt new file mode 100644 index 0000000..9777389 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/Credits.kt @@ -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 = 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 +} diff --git a/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt new file mode 100644 index 0000000..5e9ab64 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/RunningScreen.kt @@ -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(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(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 +} diff --git a/app/src/main/java/com/opendance/studio/ui/SkeletonOverlay.kt b/app/src/main/java/com/opendance/studio/ui/SkeletonOverlay.kt new file mode 100644 index 0000000..351ece4 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/SkeletonOverlay.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/opendance/studio/ui/theme/Theme.kt b/app/src/main/java/com/opendance/studio/ui/theme/Theme.kt new file mode 100644 index 0000000..90b0d06 --- /dev/null +++ b/app/src/main/java/com/opendance/studio/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/test/java/com/opendance/studio/CreditsTest.kt b/app/src/test/java/com/opendance/studio/CreditsTest.kt new file mode 100644 index 0000000..1d6874c --- /dev/null +++ b/app/src/test/java/com/opendance/studio/CreditsTest.kt @@ -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) + } +}