feat: 底图bg1.png+开关灯合并按键+默认IP更新

- 添加 bg1.png 作为底层背景图,视频未播放时透出
- PlayerView 仅在视频播放中时渲染,避免黑色 SurfaceView 遮挡底图
- 开关灯合并为前灯/后灯两个按键,依据 LED 回报状态显示琥珀色/暗色
- 默认 IP 改为 10.21.41.1,RTSP 地址改为 rtsp://10.21.41.1:8554/video1

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-17 21:27:11 +08:00
parent 23babb867b
commit f055777a6f
5 changed files with 87 additions and 27 deletions
+2 -2
View File
@@ -191,12 +191,12 @@ val y = power / 100.0 * sin(angle) // 水平分量
- [x] 摇杆尺寸增大:左右摇杆从 140dp 增大到 180dp,提升操控精度
- [x] 视频缩放模式:`VideoResizeMode` 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
### 待办
- [ ] UI 布局:增加 logo 图作为底图,无视频流时显示底图
- [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] UI 布局:开关灯合并为一个按键,通过图标/颜色表示置位状态,需配合机器人回报状态同步
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
@@ -28,9 +28,9 @@ data class AppSettings(
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM
) {
companion object {
const val DEFAULT_ROBOT_HOST = "10.21.31.103"
const val DEFAULT_ROBOT_HOST = "10.21.41.1"
const val DEFAULT_ROBOT_PORT = 30000
const val DEFAULT_RTSP_URL = "rtsp://10.21.31.103:554/stream"
const val DEFAULT_RTSP_URL = "rtsp://10.21.41.1:8554/video1"
/** RTSP 端口范围校验 */
fun isValidPort(port: Int): Boolean = port in 1..65535
@@ -1,5 +1,6 @@
package com.example.m20_gamepad.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -40,6 +41,8 @@ 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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -49,9 +52,11 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.erz.joysticklibrary.Joystick
import com.erz.joysticklibrary.JoystickType
import com.example.m20_gamepad.R
import com.example.m20_gamepad.data.SettingsRepository
import com.example.m20_gamepad.network.ProtocolClient
import com.example.m20_gamepad.network.models.ControlCommands
import com.example.m20_gamepad.network.models.LedStatus
import com.example.m20_gamepad.ui.components.StatusPanel
import com.example.m20_gamepad.video.RtspVideoPlayer
@@ -83,6 +88,7 @@ fun MainScreen(
val motionStatus by viewModel.motionStatus.collectAsStateWithLifecycle()
val deviceTemperature by viewModel.deviceTemperature.collectAsStateWithLifecycle()
val batteryStatus by viewModel.batteryStatus.collectAsStateWithLifecycle()
val ledStatus by viewModel.ledStatus.collectAsStateWithLifecycle()
val lastUpdate by viewModel.lastUpdate.collectAsStateWithLifecycle()
val actionFeedback by viewModel.actionFeedback.collectAsStateWithLifecycle()
@@ -99,6 +105,14 @@ fun MainScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
// ---- 底图层(无视频流时显示) ----
Image(
painter = painterResource(id = R.drawable.bg1),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
// ---- 视频背景层 ----
RtspVideoPlayer(
url = settings.rtspUrl,
@@ -123,6 +137,7 @@ fun MainScreen(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState,
ledStatus = ledStatus,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
@@ -245,6 +260,7 @@ private fun BottomControls(
viewModel: MainViewModel,
connectionState: ProtocolClient.State,
motionState: Int,
ledStatus: LedStatus?,
modifier: Modifier = Modifier
) {
Row(
@@ -269,7 +285,8 @@ private fun BottomControls(
ControlButtons(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState
motionState = motionState,
ledStatus = ledStatus
)
// 右摇杆(Yaw 旋转)
@@ -289,7 +306,8 @@ private fun BottomControls(
private fun ControlButtons(
viewModel: MainViewModel,
connectionState: ProtocolClient.State,
motionState: Int
motionState: Int,
ledStatus: LedStatus?
) {
val connected = connectionState == ProtocolClient.State.CONNECTED
val isRlControl = motionState == ControlCommands.MOTION_RL_CONTROL
@@ -383,13 +401,23 @@ private fun ControlButtons(
// 照明
SectionLabel("照明")
val frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("前灯开") { viewModel.sendLightControl(1, 0) }
MiniButton("前灯") { viewModel.sendLightControl(0, 0) }
LightToggleButton(
text = "前灯",
isOn = frontOn,
enabled = connected
) {
viewModel.sendLightControl(if (frontOn) 0 else 1, ledStatus?.back ?: 0)
}
LightToggleButton(
text = "后灯",
isOn = backOn,
enabled = connected
) {
viewModel.sendLightControl(ledStatus?.front ?: 0, if (backOn) 0 else 1)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("后灯开") { viewModel.sendLightControl(0, 1) }
MiniButton("后灯关") { viewModel.sendLightControl(0, 0) }
}
Spacer(Modifier.height(4.dp))
@@ -451,6 +479,30 @@ private fun MiniButton(text: String, enabled: Boolean = true, onClick: () -> Uni
}
}
@Composable
private fun LightToggleButton(
text: String,
isOn: Boolean,
enabled: Boolean = true,
onClick: () -> Unit
) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
modifier = Modifier.width(110.dp)
) {
Text(
"$text ${if (isOn) "开" else "关"}",
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
@@ -9,6 +9,7 @@ import com.example.m20_gamepad.network.models.BatteryStatus
import com.example.m20_gamepad.network.models.ControlCommands
import com.example.m20_gamepad.network.models.DeviceTemperature
import com.example.m20_gamepad.network.models.ErrorInfo
import com.example.m20_gamepad.network.models.LedStatus
import com.example.m20_gamepad.network.models.MotionStatus
import com.example.m20_gamepad.network.models.StatusReport
import com.example.m20_gamepad.service.JoystickController
@@ -70,6 +71,9 @@ class MainViewModel(
private val _batteryStatus = MutableStateFlow<BatteryStatus?>(null)
val batteryStatus: StateFlow<BatteryStatus?> = _batteryStatus.asStateFlow()
private val _ledStatus = MutableStateFlow<LedStatus?>(null)
val ledStatus: StateFlow<LedStatus?> = _ledStatus.asStateFlow()
private val _lastUpdate = MutableStateFlow<String?>(null)
val lastUpdate: StateFlow<String?> = _lastUpdate.asStateFlow()
@@ -153,6 +157,7 @@ class MainViewModel(
_motionStatus.value = null
_deviceTemperature.value = null
_batteryStatus.value = null
_ledStatus.value = null
_lastUpdate.value = null
}
@@ -165,6 +170,7 @@ class MainViewModel(
report.motionStatus?.let { _motionStatus.value = it }
report.deviceTemperature?.let { _deviceTemperature.value = it }
report.batteryStatus?.let { _batteryStatus.value = it }
report.led?.let { _ledStatus.value = it }
// 通用响应错误提示
report.errorCode?.let { code ->
if (code != 0) {
@@ -131,6 +131,8 @@ fun RtspVideoPlayer(
}
Box(modifier = modifier) {
// 仅在视频播放中时渲染 PlayerView,否则透出底层背景
if (state is RtspState.Playing) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
@@ -138,15 +140,15 @@ fun RtspVideoPlayer(
useController = false
this.resizeMode = resizeMode.toMedia3ResizeMode()
this.player = player
setShutterBackgroundColor(android.graphics.Color.BLACK)
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
}
},
// player 引用变化时(codec 重建)更新 PlayerView
update = { view ->
view.player = player
view.resizeMode = resizeMode.toMedia3ResizeMode()
}
)
}
// 连接中 / 错误时的提示覆盖层
when (val s = state) {