UI: 增大摇杆尺寸至180dp;视频全屏延展(ZOOM模式)并加入缩放模式设置项

- 左右摇杆 Modifier.size 140dp -> 180dp (MainScreen.kt)
- RtspVideoPlayer 新增 resizeMode: VideoResizeMode 参数,默认 ZOOM
- 新增 VideoResizeMode 枚举 (FIT/ZOOM/FILL) 及 Media3 常量映射
- 设置界面新增"视频缩放模式"下拉选项,持久化到 DataStore
- AGENTS.md 更新已完成项和配置项表格

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-17 20:54:03 +08:00
parent 6d7231b064
commit d563585cd5
9 changed files with 115 additions and 10 deletions
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-07-17T08:19:04.728150600Z">
<DropdownSelection timestamp="2026-07-17T11:46:26.977945400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.137.221:5555;connection=f0ff1c4d" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\evan\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+4 -3
View File
@@ -115,7 +115,8 @@ val y = power / 100.0 * sin(angle) // 水平分量
| `robot_host` | String | `10.21.31.103` | 机器人 IP 地址 |
| `robot_port` | Int | `30000` | 机器人 UDP 端口 |
| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 |
| `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_SW_H264` / `FORCE_SW_H265` |
| `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_HW` / `FORCE_SW_H264` / `FORCE_SW_H265` |
| `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` |
### 持久化
@@ -187,14 +188,14 @@ val y = power / 100.0 * sin(angle) // 水平分量
- [x] 状态上报 UI 展示:StatusPanel.kt(异常列表+错误码映射 ErrorCodes.kt、运控状态 Roll/Pitch/Yaw/速度/高度、设备温度电机/驱动器最高温、电池左右电压/电量/温度),顶部状态栏 Info 按钮触发底部弹出面板;指令失败/状态机拒绝通过 Snackbar 反馈
- [x] 更多控制按钮:Mode(常规/导航/辅助)、步态(基础/楼梯/平地敏捷/楼梯敏捷)、照明(前/后灯开/关)、充电(开始/结束)、休眠(休眠/唤醒)。底部控制区可纵向滚动,点击"更多"展开全部控制选项
- [x] RTSP 解码器策略实现:`VideoCodec` 枚举含 `AUTO`/`FORCE_HW`/`FORCE_SW_H264`/`FORCE_SW_H265``toMediaCodecSelector()` 映射到对应 `MediaCodecSelector``DefaultLoadControl` 最小缓冲(100ms起播,总缓冲<500ms,无回退缓存,时间优先于大小阈值);`FORCE_HW` 模式对所有 MIME 仅保留硬件加速解码器
- [x] 摇杆尺寸增大:左右摇杆从 140dp 增大到 180dp,提升操控精度
- [x] 视频缩放模式:`VideoResizeMode` 枚举(FIT/ZOOM/FILL),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
### 待办
- [ ] UI 布局:增加 logo 图作为底图,无视频流时显示底图
- [ ] UI 布局:视频默认延展与屏幕等大(填充全屏)
- [ ] UI 布局:header 部分虽已避开状态栏,但底色与屏幕顶端视觉不吸附,需优化视觉传达效果
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] UI 布局:增大摇杆尺寸,提高操控精度和舒适度
- [ ] UI 布局:开关灯合并为一个按键,通过图标/颜色表示置位状态,需配合机器人回报状态同步
- [ ] 状态栏:增加 ICMP 延迟测量显示,显示当前 WiFi 强度和延迟 ms
- [ ] 状态机修复:趴下状态和空闲状态均允许切换站立状态,切换站立后自动进入 RL 控制状态
@@ -2,6 +2,18 @@ package com.example.m20_gamepad.data
import com.example.m20_gamepad.video.VideoCodec
/**
* 视频缩放模式。
*/
enum class VideoResizeMode {
/** 等比缩放,完整显示(留黑边) */
FIT,
/** 等比缩放,填充全屏(裁切多余部分) */
ZOOM,
/** 拉伸填满全屏 */
FILL
}
/**
* 应用配置数据类。
*
@@ -12,7 +24,8 @@ data class AppSettings(
val robotHost: String = DEFAULT_ROBOT_HOST,
val robotPort: Int = DEFAULT_ROBOT_PORT,
val rtspUrl: String = DEFAULT_RTSP_URL,
val videoCodec: VideoCodec = VideoCodec.AUTO
val videoCodec: VideoCodec = VideoCodec.AUTO,
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM
) {
companion object {
const val DEFAULT_ROBOT_HOST = "10.21.31.103"
@@ -34,7 +34,8 @@ class SettingsRepository(context: Context) {
robotHost = prefs[KEY_HOST] ?: AppSettings.DEFAULT_ROBOT_HOST,
robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT,
rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL,
videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO }
videoCodec = VideoCodec.entries.getOrElse(prefs[KEY_CODEC] ?: 0) { VideoCodec.AUTO },
videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM }
)
}
@@ -61,6 +62,7 @@ class SettingsRepository(context: Context) {
prefs[KEY_PORT] = settings.robotPort.coerceIn(1, 65535)
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
prefs[KEY_CODEC] = settings.videoCodec.ordinal
prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal
}
}
@@ -69,5 +71,6 @@ class SettingsRepository(context: Context) {
private val KEY_PORT = intPreferencesKey("robot_port")
private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url")
private val KEY_CODEC = intPreferencesKey("video_codec")
private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode")
}
}
@@ -103,6 +103,7 @@ fun MainScreen(
RtspVideoPlayer(
url = settings.rtspUrl,
codec = settings.videoCodec,
resizeMode = settings.videoResizeMode,
modifier = Modifier.fillMaxSize()
)
@@ -255,7 +256,7 @@ private fun BottomControls(
) {
// 左摇杆(X/Y 移动)
Joystick(
modifier = Modifier.size(140.dp),
modifier = Modifier.size(180.dp),
type = JoystickType.EIGHT_AXIS,
padColor = Color(0x55808080),
buttonColor = Color(0xCCFFFFFF),
@@ -273,7 +274,7 @@ private fun BottomControls(
// 右摇杆(Yaw 旋转)
Joystick(
modifier = Modifier.size(140.dp),
modifier = Modifier.size(180.dp),
type = JoystickType.EIGHT_AXIS,
padColor = Color(0x55808080),
buttonColor = Color(0xCCFFFFFF),
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.m20_gamepad.data.AppSettings
import com.example.m20_gamepad.data.SettingsRepository
import com.example.m20_gamepad.data.VideoResizeMode
import com.example.m20_gamepad.video.VideoCodec
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -65,6 +66,7 @@ fun SettingsScreen(
var portText by remember { mutableStateOf(AppSettings.DEFAULT_ROBOT_PORT.toString()) }
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
var codec by remember { mutableStateOf(VideoCodec.AUTO) }
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
var hostError by remember { mutableStateOf<String?>(null) }
var portError by remember { mutableStateOf<String?>(null) }
var initialized by remember { mutableStateOf(false) }
@@ -76,6 +78,7 @@ fun SettingsScreen(
portText = s.robotPort.toString()
rtspUrl = s.rtspUrl
codec = s.videoCodec
resizeMode = s.videoResizeMode
initialized = true
}
}
@@ -161,6 +164,12 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth()
)
ResizeModeDropdown(
selected = resizeMode,
onSelect = { resizeMode = it },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.padding(top = 8.dp))
// ---- 操作按钮 ----
@@ -183,7 +192,8 @@ fun SettingsScreen(
robotHost = host,
robotPort = port,
rtspUrl = rtspUrl,
videoCodec = codec
videoCodec = codec,
videoResizeMode = resizeMode
)
)
onSaved()
@@ -264,3 +274,59 @@ private fun CodecDropdown(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ResizeModeDropdown(
selected: VideoResizeMode,
onSelect: (VideoResizeMode) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val options = VideoResizeMode.entries
val labels = mapOf(
VideoResizeMode.FIT to "等比缩放(留黑边)",
VideoResizeMode.ZOOM to "等比缩放(填充全屏)",
VideoResizeMode.FILL to "拉伸填充"
)
Column(modifier = modifier) {
Text(
text = "视频缩放模式",
style = androidx.compose.material3.MaterialTheme.typography.titleSmall,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
Spacer(Modifier.padding(top = 4.dp))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = labels[selected] ?: selected.name,
onValueChange = {},
readOnly = true,
singleLine = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { m ->
DropdownMenuItem(
text = { Text(labels[m] ?: m.name) },
onClick = {
onSelect(m)
expanded = false
}
)
}
}
}
}
}
@@ -23,7 +23,9 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.example.m20_gamepad.data.VideoResizeMode
/**
* 视频解码器选择策略。
@@ -58,12 +60,14 @@ sealed class RtspState {
*
* @param url RTSP 流地址,如 `rtsp://10.21.31.103:554/stream`
* @param codec 解码器选择策略
* @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏)
* @param modifier 布局修饰符
*/
@Composable
fun RtspVideoPlayer(
url: String,
codec: VideoCodec = VideoCodec.AUTO,
resizeMode: VideoResizeMode = VideoResizeMode.ZOOM,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -132,6 +136,7 @@ fun RtspVideoPlayer(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
this.resizeMode = resizeMode.toMedia3ResizeMode()
this.player = player
setShutterBackgroundColor(android.graphics.Color.BLACK)
}
@@ -139,6 +144,7 @@ fun RtspVideoPlayer(
// player 引用变化时(codec 重建)更新 PlayerView
update = { view ->
view.player = player
view.resizeMode = resizeMode.toMedia3ResizeMode()
}
)
@@ -222,4 +228,13 @@ private fun forceHardware(): MediaCodecSelector = object : MediaCodecSelector {
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
).filter { it.hardwareAccelerated }
}
}
// ==================== 缩放模式映射 ====================
/** 将 [VideoResizeMode] 映射为 [AspectRatioFrameLayout] 的 resizeMode 常量。 */
private fun VideoResizeMode.toMedia3ResizeMode(): Int = when (this) {
VideoResizeMode.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
VideoResizeMode.ZOOM -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
VideoResizeMode.FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB