Compare commits
3 Commits
9883eb4b44
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ffb01485 | |||
| 8a36537994 | |||
| b4712a782c |
@@ -35,7 +35,7 @@ com.example.m20_gamepad/
|
|||||||
│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等)
|
│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等)
|
||||||
├── service/
|
├── service/
|
||||||
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
|
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
|
||||||
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射
|
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持
|
||||||
├── video/
|
├── video/
|
||||||
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装)
|
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装)
|
||||||
├── data/
|
├── data/
|
||||||
@@ -91,12 +91,23 @@ Joystick(
|
|||||||
- 角度 270° = 正下 → X 负方向(后退)
|
- 角度 270° = 正下 → X 负方向(后退)
|
||||||
- 强度百分比 → [-1, 1] 比例值
|
- 强度百分比 → [-1, 1] 比例值
|
||||||
|
|
||||||
**映射公式(从左摇杆角度/强度到 X/Y)**:
|
**标准手型(STANDARD)**:
|
||||||
```
|
```
|
||||||
val x = power / 100.0 * cos(angle) // 垂直分量
|
左摇杆: X = sin(angle) * power/100, Y = cos(angle) * power/100
|
||||||
val y = power / 100.0 * sin(angle) // 水平分量
|
右摇杆: Yaw = cos(angle) * power/100
|
||||||
|
```
|
||||||
|
|
||||||
|
**单摇杆左(SINGLE_LEFT)**:左摇杆控制 X(前后) + Yaw(转向,类车操控),右摇杆仅控制 Y(横移)
|
||||||
|
|
||||||
|
**单摇杆右(SINGLE_RIGHT)**:左摇杆仅控制 Y(横移),右摇杆控制 X(前后) + Yaw(转向,类车操控)
|
||||||
|
|
||||||
|
**坦克式(TANK)**:两摇杆 up/down 差速计算 X/Yaw,无横移 Y
|
||||||
|
```
|
||||||
|
左履带 = sin(leftAngle) * leftScale
|
||||||
|
右履带 = sin(rightAngle) * rightScale
|
||||||
|
X = (左履带 + 右履带) / 2
|
||||||
|
Yaw = (左履带 - 右履带) / 2
|
||||||
```
|
```
|
||||||
右摇杆控制 Yaw(偏航角速度)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,6 +128,7 @@ val y = power / 100.0 * sin(angle) // 水平分量
|
|||||||
| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 |
|
| `rtsp_url` | String | `rtsp://10.21.31.103:554/stream` | RTSP 视频流地址 |
|
||||||
| `video_codec` | Enum | `AUTO` | `AUTO` / `FORCE_HW` / `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` |
|
| `video_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` |
|
||||||
|
| `hand_style` | Enum | `STANDARD` | `STANDARD` / `SINGLE_LEFT` / `SINGLE_RIGHT` / `TANK` |
|
||||||
|
|
||||||
### 持久化
|
### 持久化
|
||||||
|
|
||||||
@@ -168,10 +180,9 @@ gradlew installDebug
|
|||||||
|
|
||||||
## 已完成 / 待办
|
## 已完成 / 待办
|
||||||
|
|
||||||
### 已完成
|
## 已完成 / 待办
|
||||||
|
|
||||||
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
|
见 [TODO.md](./TODO.md)
|
||||||
- [x] 协议层:Header.kt(16 字节小端编解码)
|
|
||||||
- [x] 协议层:ApduMessage.kt(Header + ASDU 封装)
|
- [x] 协议层:ApduMessage.kt(Header + ASDU 封装)
|
||||||
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
|
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
|
||||||
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
|
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
|
||||||
@@ -219,7 +230,8 @@ gradlew installDebug
|
|||||||
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
|
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
|
||||||
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
|
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
|
||||||
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()` 和 `SUB_*` 常量,彻底清除死代码
|
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()` 和 `SUB_*` 常量,彻底清除死代码
|
||||||
|
- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效
|
||||||
|
|
||||||
### 待办
|
### 待办
|
||||||
|
|
||||||
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView)
|
见 [TODO.md](./TODO.md)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## 待办
|
||||||
|
|
||||||
|
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView)
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
|
||||||
|
- [x] 协议层:Header.kt(16 字节小端编解码)
|
||||||
|
- [x] 协议层:ApduMessage.kt(Header + ASDU 封装)
|
||||||
|
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
|
||||||
|
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
|
||||||
|
- [x] 协议层:PacketEncoder.kt(MsgId 管理 + APDU 编码)
|
||||||
|
- [x] 协议层:PacketDecoder.kt(APDU 解码 + 缓冲区搜索)
|
||||||
|
- [x] 网络层:ProtocolClient.kt(UDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
|
||||||
|
- [x] 摇杆映射:JoystickController.kt(angle/power -> X/Y/Yaw)
|
||||||
|
- [x] 状态管理:MainViewModel.kt(ProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
|
||||||
|
- [x] 视频播放:RtspVideoPlayer.kt(Media3 ExoPlayer RTSP,Composable 包装)
|
||||||
|
- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
|
||||||
|
- [x] 入口:MainActivity.kt + INTERNET 权限
|
||||||
|
- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.kt(DataStore Preferences 封装,`settings: Flow<AppSettings>` + 单项 setter + `setAll()`)、M20App.kt(Application 子类,进程级 `settingsRepository` 单例)
|
||||||
|
- [x] 设置界面 UI:SettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`)
|
||||||
|
- [x] 导航框架:NavRoutes.kt(`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`(NavHost,startDestination=MAIN,主界面齿轮按钮跳转设置,返回/保存后 popBackStack 到 MAIN)
|
||||||
|
- [x] 连接参数从设置读取:MainViewModel 构造注入 `SettingsRepository`,`settings: StateFlow<AppSettings>`(`stateIn(Eagerly)`),`connect()` 读取 `settings.value` 的 host/port;`init {}` 监听 host/port 变化(`distinctUntilChanged`)后断开当前连接,下次连接使用新参数(符合"修改设置后断开当前连接"约束)。移除 DEFAULT_HOST/DEFAULT_PORT/DEFAULT_RTSP_URL 硬编码
|
||||||
|
- [x] 连接状态机:RobotConnection.kt(连接状态流转校验 `isValidConnectionTransition`,运动状态机 `requestMotionTransition` 按 proto.md 2.3 正向流程 `空闲->站立->RL控制` 校验;`canSwitchGait`/`canSendAxisCommand` 仅 RL 控制下放行;`MotionTransitionResult` 枚举反馈)。MainViewModel 经状态机校验后下发指令,轴指令循环非 RL 控制时发零速度
|
||||||
|
- [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),设置界面新增"视频缩放模式"下拉选项,持久化保存,实时生效
|
||||||
|
- [x] 状态机修复:趴下状态下允许切换站立状态(机器人站立后自动进入 RL 控制,无需 App 下发)
|
||||||
|
- [x] UI 布局:增加 bg1.png 作为底图,无视频流时显示(视频流播放时覆盖底图)
|
||||||
|
- [x] UI 布局:开关灯合并为前灯/后灯两个按键,通过颜色(琥珀色=开/暗色=关)表示置位状态,配合机器人回报状态同步
|
||||||
|
- [x] 状态栏:ICMP 延迟测量 + WiFi 信号强度图标显示,放置于连接状态文字右侧,延迟独立于连接生命周期
|
||||||
|
- [x] UI 布局:StatusBar 背景延伸到屏幕顶端,视觉吸附优化(Box + Row 双层结构,背景填满状态栏区域)
|
||||||
|
- [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
|
||||||
|
- [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
|
||||||
|
- [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
|
||||||
|
- [x] UI 布局:功能按钮均布在四周——左上角模式选择+步态切换+起立/趴下,右上角灯光+休眠+充电,充分利用屏幕空间,优化单手操作体验
|
||||||
|
- [x] UI 布局:起立/趴下合并为切换按钮(琥珀色=站立时显示"趴下",暗色=非站立时显示"起立"),移至左上角 StatusBar 下方
|
||||||
|
- [x] UI 布局:模式选择器(常规/导航/辅助,3个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
|
||||||
|
- [x] UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL控制时可点击
|
||||||
|
- [x] UI 布局:照明/休眠/充电移至右上角,紧凑排列(前灯后灯无缝隙一行,休眠/唤醒切换,充电/充电中切换)
|
||||||
|
- [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
|
||||||
|
- [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
|
||||||
|
- [x] UI 布局:所有控制按钮在未连接时灰色不可点击
|
||||||
|
- [x] 连接状态准确性修复:`ProtocolClient` 收到首个有效数据包后才标记 `CONNECTED`,不再 socket 创建后立即标记;`CONNECTING` 状态 5 秒无响应自动超时回退 `TIMEOUT`
|
||||||
|
- [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试
|
||||||
|
- [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换
|
||||||
|
- [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING` 和 `CONNECTED` 状态下均正常运行
|
||||||
|
- [x] 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(`MOTION_DAMPING`),跟随机器人状态上报自动归位
|
||||||
|
- [x] 状态上报闪烁修复:`_latestStatus` 改用合并策略(`prev.copy`),保留上次报告的非 null 字段,避免 4 个独立订阅交替覆盖导致状态栏/步态按钮闪烁
|
||||||
|
- [x] 电量显示稳定性优化:`StatusBar` 电量改用独立 `batteryStatus` StateFlow,不依赖 `status?.batteryStatus`
|
||||||
|
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
|
||||||
|
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
|
||||||
|
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()` 和 `SUB_*` 常量,彻底清除死代码
|
||||||
|
- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.m20_gamepad.data
|
package com.example.m20_gamepad.data
|
||||||
|
|
||||||
|
import com.example.m20_gamepad.service.HandStyle
|
||||||
import com.example.m20_gamepad.video.VideoCodec
|
import com.example.m20_gamepad.video.VideoCodec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,8 @@ data class AppSettings(
|
|||||||
val robotPort: Int = DEFAULT_ROBOT_PORT,
|
val robotPort: Int = DEFAULT_ROBOT_PORT,
|
||||||
val rtspUrl: String = DEFAULT_RTSP_URL,
|
val rtspUrl: String = DEFAULT_RTSP_URL,
|
||||||
val videoCodec: VideoCodec = VideoCodec.AUTO,
|
val videoCodec: VideoCodec = VideoCodec.AUTO,
|
||||||
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM
|
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM,
|
||||||
|
val handStyle: HandStyle = HandStyle.STANDARD
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_ROBOT_HOST = "10.21.41.1"
|
const val DEFAULT_ROBOT_HOST = "10.21.41.1"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.example.m20_gamepad.service.HandStyle
|
||||||
import com.example.m20_gamepad.video.VideoCodec
|
import com.example.m20_gamepad.video.VideoCodec
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -35,7 +36,8 @@ class SettingsRepository(context: Context) {
|
|||||||
robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT,
|
robotPort = prefs[KEY_PORT] ?: AppSettings.DEFAULT_ROBOT_PORT,
|
||||||
rtspUrl = prefs[KEY_RTSP_URL] ?: AppSettings.DEFAULT_RTSP_URL,
|
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 }
|
videoResizeMode = VideoResizeMode.entries.getOrElse(prefs[KEY_RESIZE_MODE] ?: 1) { VideoResizeMode.ZOOM },
|
||||||
|
handStyle = HandStyle.entries.getOrElse(prefs[KEY_HAND_STYLE] ?: 0) { HandStyle.STANDARD }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,10 @@ class SettingsRepository(context: Context) {
|
|||||||
dataStore.edit { it[KEY_CODEC] = codec.ordinal }
|
dataStore.edit { it[KEY_CODEC] = codec.ordinal }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setHandStyle(handStyle: HandStyle) {
|
||||||
|
dataStore.edit { it[KEY_HAND_STYLE] = handStyle.ordinal }
|
||||||
|
}
|
||||||
|
|
||||||
/** 一次性写入全部设置(用于设置界面保存按钮) */
|
/** 一次性写入全部设置(用于设置界面保存按钮) */
|
||||||
suspend fun setAll(settings: AppSettings) {
|
suspend fun setAll(settings: AppSettings) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
@@ -63,6 +69,7 @@ class SettingsRepository(context: Context) {
|
|||||||
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
|
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
|
||||||
prefs[KEY_CODEC] = settings.videoCodec.ordinal
|
prefs[KEY_CODEC] = settings.videoCodec.ordinal
|
||||||
prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal
|
prefs[KEY_RESIZE_MODE] = settings.videoResizeMode.ordinal
|
||||||
|
prefs[KEY_HAND_STYLE] = settings.handStyle.ordinal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,5 +79,6 @@ class SettingsRepository(context: Context) {
|
|||||||
private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url")
|
private val KEY_RTSP_URL = stringPreferencesKey("rtsp_url")
|
||||||
private val KEY_CODEC = intPreferencesKey("video_codec")
|
private val KEY_CODEC = intPreferencesKey("video_codec")
|
||||||
private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode")
|
private val KEY_RESIZE_MODE = intPreferencesKey("video_resize_mode")
|
||||||
|
private val KEY_HAND_STYLE = intPreferencesKey("hand_style")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,9 +130,6 @@ class ProtocolClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送订阅请求(触发服务端 UDP 推送)
|
|
||||||
sendSubscriptionRequests()
|
|
||||||
|
|
||||||
// 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
|
// 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
|
||||||
timeoutJob = scope.launch {
|
timeoutJob = scope.launch {
|
||||||
timeoutCheckLoop()
|
timeoutCheckLoop()
|
||||||
@@ -195,25 +192,6 @@ class ProtocolClient(
|
|||||||
socket = null
|
socket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送订阅请求 */
|
|
||||||
private fun sendSubscriptionRequests() {
|
|
||||||
val subscriptions: List<Pair<Int, Int>> = listOf(
|
|
||||||
ControlCommands.SUB_BASIC,
|
|
||||||
ControlCommands.SUB_MOTION_CONTROL,
|
|
||||||
ControlCommands.SUB_DEVICE,
|
|
||||||
ControlCommands.SUB_ERROR
|
|
||||||
)
|
|
||||||
val sock = socket ?: return
|
|
||||||
val addr = remoteAddress ?: return
|
|
||||||
for ((type, cmd) in subscriptions) {
|
|
||||||
try {
|
|
||||||
val json = ControlCommands.subscribeStatus(type, cmd)
|
|
||||||
val packet = encoder.encode(json)
|
|
||||||
sock.send(DatagramPacket(packet, packet.size, addr, port))
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 接收循环 */
|
/** 接收循环 */
|
||||||
private fun CoroutineScope.receiveLoop(sock: DatagramSocket) {
|
private fun CoroutineScope.receiveLoop(sock: DatagramSocket) {
|
||||||
val buffer = ByteArray(65535)
|
val buffer = ByteArray(65535)
|
||||||
|
|||||||
@@ -122,10 +122,6 @@ object ControlCommands {
|
|||||||
// ---- 2.10 休眠状态查询 ----
|
// ---- 2.10 休眠状态查询 ----
|
||||||
fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time)
|
fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time)
|
||||||
|
|
||||||
// ---- 订阅状态上报 ----
|
|
||||||
fun subscribeStatus(type: Int, command: Int, time: String = now()): String =
|
|
||||||
buildAsdu(type = type, command = command, time = time)
|
|
||||||
|
|
||||||
// ========== 常量 ==========
|
// ========== 常量 ==========
|
||||||
|
|
||||||
// 运动状态常量
|
// 运动状态常量
|
||||||
@@ -147,9 +143,4 @@ object ControlCommands {
|
|||||||
const val MODE_NAVIGATION = 1
|
const val MODE_NAVIGATION = 1
|
||||||
const val MODE_ASSIST = 2
|
const val MODE_ASSIST = 2
|
||||||
|
|
||||||
// 订阅 Type/Command — 用于 subscribeStatus()
|
|
||||||
val SUB_ERROR = Pair(1002, 3)
|
|
||||||
val SUB_MOTION_CONTROL = Pair(1002, 4)
|
|
||||||
val SUB_DEVICE = Pair(1002, 5)
|
|
||||||
val SUB_BASIC = Pair(1002, 6)
|
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,20 @@ import com.example.m20_gamepad.network.models.ControlCommands
|
|||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手型(摇杆-轴映射模式)。
|
||||||
|
*/
|
||||||
|
enum class HandStyle {
|
||||||
|
/** 标准:左摇杆 X/Y, 右摇杆 Yaw */
|
||||||
|
STANDARD,
|
||||||
|
/** 单摇杆左:左摇杆 X/Yaw(类车), 右摇杆 Y */
|
||||||
|
SINGLE_LEFT,
|
||||||
|
/** 单摇杆右:左摇杆 Y, 右摇杆 X/Yaw(类车) */
|
||||||
|
SINGLE_RIGHT,
|
||||||
|
/** 坦克式:两摇杆 up/down 差速 X/Yaw, 无 Y, cos 忽略 */
|
||||||
|
TANK
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 摇杆输入 -> 协议轴指令映射。
|
* 摇杆输入 -> 协议轴指令映射。
|
||||||
*
|
*
|
||||||
@@ -15,39 +29,59 @@ import kotlin.math.sin
|
|||||||
*
|
*
|
||||||
* 协议轴定义:
|
* 协议轴定义:
|
||||||
* - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转)
|
* - X 正 = 前进,Y 正 = 左移,Yaw 正 = 逆时针(左转)
|
||||||
*
|
|
||||||
* 映射公式:
|
|
||||||
* - 左摇杆 X(前后) = sin(angle) * power/100
|
|
||||||
* - 左摇杆 Y(左右) = cos(angle) * power/100
|
|
||||||
* - 右摇杆 Yaw = cos(angle) * power/100
|
|
||||||
*/
|
*/
|
||||||
object JoystickController {
|
object JoystickController {
|
||||||
|
|
||||||
/** 左摇杆角度/强度 -> 轴指令 JSON(X=前后, Y=左右) */
|
/**
|
||||||
fun leftStickToAxisCommand(angle: Double, power: Double): String {
|
* 根据手型将双摇杆输入映射为轴指令 JSON。
|
||||||
val scale = power / 100.0
|
*
|
||||||
val x = sin(angle) * scale
|
* @param handStyle 当前手型
|
||||||
val y = cos(angle) * scale
|
* @param leftAngle 左摇杆角度(弧度)
|
||||||
return ControlCommands.axisCommand(x = x, y = y)
|
* @param leftPower 左摇杆强度(0-100)
|
||||||
}
|
* @param rightAngle 右摇杆角度(弧度)
|
||||||
|
* @param rightPower 右摇杆强度(0-100)
|
||||||
/** 右摇杆角度/强度 -> 轴指令 JSON(Yaw=偏航) */
|
*/
|
||||||
fun rightStickToAxisCommand(angle: Double, power: Double): String {
|
|
||||||
val yaw = cos(angle) * power / 100.0
|
|
||||||
return ControlCommands.axisCommand(yaw = yaw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 双摇杆合并 -> 轴指令 JSON(左摇杆控制 X/Y,右摇杆控制 Yaw) */
|
|
||||||
fun dualStickToAxisCommand(
|
fun dualStickToAxisCommand(
|
||||||
|
handStyle: HandStyle,
|
||||||
leftAngle: Double, leftPower: Double,
|
leftAngle: Double, leftPower: Double,
|
||||||
rightAngle: Double, rightPower: Double
|
rightAngle: Double, rightPower: Double
|
||||||
): String {
|
): String {
|
||||||
val leftScale = leftPower / 100.0
|
val leftScale = leftPower / 100.0
|
||||||
val rightScale = rightPower / 100.0
|
val rightScale = rightPower / 100.0
|
||||||
val x = sin(leftAngle) * leftScale
|
|
||||||
val y = cos(leftAngle) * leftScale
|
return when (handStyle) {
|
||||||
val yaw = cos(rightAngle) * rightScale
|
HandStyle.STANDARD -> {
|
||||||
return ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
val x = sin(leftAngle) * leftScale
|
||||||
|
val y = cos(leftAngle) * leftScale
|
||||||
|
val yaw = cos(rightAngle) * rightScale
|
||||||
|
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||||
|
}
|
||||||
|
HandStyle.SINGLE_LEFT -> {
|
||||||
|
// 左摇杆:X(前后) + Yaw(转向),类车操控
|
||||||
|
val x = sin(leftAngle) * leftScale
|
||||||
|
val yaw = cos(leftAngle) * leftScale
|
||||||
|
// 右摇杆:Y(横移),仅左右有效
|
||||||
|
val y = cos(rightAngle) * rightScale
|
||||||
|
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||||
|
}
|
||||||
|
HandStyle.SINGLE_RIGHT -> {
|
||||||
|
// 左摇杆:Y(横移),仅左右有效
|
||||||
|
val y = cos(leftAngle) * leftScale
|
||||||
|
// 右摇杆:X(前后) + Yaw(转向),类车操控
|
||||||
|
val x = sin(rightAngle) * rightScale
|
||||||
|
val yaw = cos(rightAngle) * rightScale
|
||||||
|
ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
|
||||||
|
}
|
||||||
|
HandStyle.TANK -> {
|
||||||
|
// 左摇杆 up/down → 左履带,右摇杆 up/down → 右履带
|
||||||
|
// cos 方向忽略 → 仅 sin 参与
|
||||||
|
val leftTrack = sin(leftAngle) * leftScale
|
||||||
|
val rightTrack = sin(rightAngle) * rightScale
|
||||||
|
val x = (leftTrack + rightTrack) / 2.0
|
||||||
|
val yaw = (leftTrack - rightTrack) / 2.0
|
||||||
|
ControlCommands.axisCommand(x = x, yaw = yaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 零速度指令(松手时发送,保持连接活跃) */
|
/** 零速度指令(松手时发送,保持连接活跃) */
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ class MainViewModel(
|
|||||||
if (c != null && c.isConnected) {
|
if (c != null && c.isConnected) {
|
||||||
val cmd = if (robot.canSendAxisCommand) {
|
val cmd = if (robot.canSendAxisCommand) {
|
||||||
JoystickController.dualStickToAxisCommand(
|
JoystickController.dualStickToAxisCommand(
|
||||||
|
settings.value.handStyle,
|
||||||
leftAngle, leftPower,
|
leftAngle, leftPower,
|
||||||
rightAngle, rightPower
|
rightAngle, rightPower
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.example.m20_gamepad.data.AppSettings
|
import com.example.m20_gamepad.data.AppSettings
|
||||||
import com.example.m20_gamepad.data.SettingsRepository
|
import com.example.m20_gamepad.data.SettingsRepository
|
||||||
import com.example.m20_gamepad.data.VideoResizeMode
|
import com.example.m20_gamepad.data.VideoResizeMode
|
||||||
|
import com.example.m20_gamepad.service.HandStyle
|
||||||
import com.example.m20_gamepad.video.VideoCodec
|
import com.example.m20_gamepad.video.VideoCodec
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -67,6 +68,7 @@ fun SettingsScreen(
|
|||||||
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
|
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
|
||||||
var codec by remember { mutableStateOf(VideoCodec.AUTO) }
|
var codec by remember { mutableStateOf(VideoCodec.AUTO) }
|
||||||
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
|
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
|
||||||
|
var handStyle by remember { mutableStateOf(HandStyle.STANDARD) }
|
||||||
var hostError by remember { mutableStateOf<String?>(null) }
|
var hostError by remember { mutableStateOf<String?>(null) }
|
||||||
var portError by remember { mutableStateOf<String?>(null) }
|
var portError by remember { mutableStateOf<String?>(null) }
|
||||||
var initialized by remember { mutableStateOf(false) }
|
var initialized by remember { mutableStateOf(false) }
|
||||||
@@ -79,6 +81,7 @@ fun SettingsScreen(
|
|||||||
rtspUrl = s.rtspUrl
|
rtspUrl = s.rtspUrl
|
||||||
codec = s.videoCodec
|
codec = s.videoCodec
|
||||||
resizeMode = s.videoResizeMode
|
resizeMode = s.videoResizeMode
|
||||||
|
handStyle = s.handStyle
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,12 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HandStyleDropdown(
|
||||||
|
selected = handStyle,
|
||||||
|
onSelect = { handStyle = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.padding(top = 8.dp))
|
Spacer(Modifier.padding(top = 8.dp))
|
||||||
|
|
||||||
// ---- 操作按钮 ----
|
// ---- 操作按钮 ----
|
||||||
@@ -193,7 +202,8 @@ fun SettingsScreen(
|
|||||||
robotPort = port,
|
robotPort = port,
|
||||||
rtspUrl = rtspUrl,
|
rtspUrl = rtspUrl,
|
||||||
videoCodec = codec,
|
videoCodec = codec,
|
||||||
videoResizeMode = resizeMode
|
videoResizeMode = resizeMode,
|
||||||
|
handStyle = handStyle
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onSaved()
|
onSaved()
|
||||||
@@ -330,3 +340,60 @@ private fun ResizeModeDropdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun HandStyleDropdown(
|
||||||
|
selected: HandStyle,
|
||||||
|
onSelect: (HandStyle) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val options = HandStyle.entries
|
||||||
|
val labels = mapOf(
|
||||||
|
HandStyle.STANDARD to "标准(左摇杆 X/Y,右摇杆 Yaw)",
|
||||||
|
HandStyle.SINGLE_LEFT to "单摇杆左(左摇杆 X/Yaw,右摇杆 Y)",
|
||||||
|
HandStyle.SINGLE_RIGHT to "单摇杆右(左摇杆 Y,右摇杆 X/Yaw)",
|
||||||
|
HandStyle.TANK 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 { h ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(labels[h] ?: h.name) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(h)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.example.m20_gamepad.video
|
package com.example.m20_gamepad.video
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -14,6 +17,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
@@ -26,6 +32,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
|||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.example.m20_gamepad.data.VideoResizeMode
|
import com.example.m20_gamepad.data.VideoResizeMode
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频解码器选择策略。
|
* 视频解码器选择策略。
|
||||||
@@ -44,13 +51,18 @@ enum class VideoCodec {
|
|||||||
/** RTSP 播放状态 */
|
/** RTSP 播放状态 */
|
||||||
sealed class RtspState {
|
sealed class RtspState {
|
||||||
/** 连接中 */
|
/** 连接中 */
|
||||||
object Connecting : RtspState()
|
data class Connecting(val retryCount: Int = 0) : RtspState()
|
||||||
/** 播放中 */
|
/** 播放中 */
|
||||||
object Playing : RtspState()
|
object Playing : RtspState()
|
||||||
/** 连接失败 */
|
/** 连接失败(自动重试中) */
|
||||||
data class Error(val message: String) : RtspState()
|
data class Error(val message: String, val retryCount: Int = 0, val nextRetryMs: Long = 0) : RtspState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 指数退避参数 */
|
||||||
|
private const val RETRY_DELAY_INITIAL_MS = 1000L
|
||||||
|
private const val RETRY_DELAY_MAX_MS = 30_000L
|
||||||
|
private const val RETRY_DELAY_MULTIPLIER = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RTSP 视频流播放 Composable。
|
* RTSP 视频流播放 Composable。
|
||||||
*
|
*
|
||||||
@@ -72,7 +84,10 @@ fun RtspVideoPlayer(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting) }
|
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting()) }
|
||||||
|
var retryCount by remember { mutableIntStateOf(0) }
|
||||||
|
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
|
||||||
|
var retrySignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
|
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
|
||||||
// 同时使用最小缓冲策略降低延迟
|
// 同时使用最小缓冲策略降低延迟
|
||||||
@@ -99,24 +114,42 @@ fun RtspVideoPlayer(
|
|||||||
addListener(object : Player.Listener {
|
addListener(object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
state = when (playbackState) {
|
state = when (playbackState) {
|
||||||
Player.STATE_BUFFERING -> RtspState.Connecting
|
Player.STATE_BUFFERING -> RtspState.Connecting(retryCount)
|
||||||
Player.STATE_READY -> RtspState.Playing
|
Player.STATE_READY -> RtspState.Playing
|
||||||
Player.STATE_IDLE -> RtspState.Connecting
|
Player.STATE_IDLE -> RtspState.Connecting(retryCount)
|
||||||
else -> state
|
else -> state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
state = RtspState.Error(error.message ?: "连接失败")
|
state = RtspState.Error(
|
||||||
|
message = error.message ?: "连接失败",
|
||||||
|
retryCount = retryCount,
|
||||||
|
nextRetryMs = retryDelayMs
|
||||||
|
)
|
||||||
|
// 递增 retrySignal 触发 LaunchedEffect 自动重试
|
||||||
|
retrySignal++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL 或 codec 变化时重新加载媒体源
|
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
|
||||||
LaunchedEffect(url, codec) {
|
LaunchedEffect(url, codec, retrySignal) {
|
||||||
if (url.isNotBlank()) {
|
if (url.isNotBlank()) {
|
||||||
state = RtspState.Connecting
|
if (retrySignal == 0) {
|
||||||
|
// 首次连接 / URL 或 codec 变更:重置退避
|
||||||
|
retryCount = 0
|
||||||
|
retryDelayMs = RETRY_DELAY_INITIAL_MS
|
||||||
|
} else {
|
||||||
|
// 重试:指数退避等待
|
||||||
|
delay(retryDelayMs)
|
||||||
|
retryDelayMs = (retryDelayMs * RETRY_DELAY_MULTIPLIER).coerceAtMost(RETRY_DELAY_MAX_MS)
|
||||||
|
retryCount++
|
||||||
|
}
|
||||||
|
state = RtspState.Connecting(retryCount)
|
||||||
|
player.stop()
|
||||||
|
player.clearMediaItems()
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
player.setMediaItem(mediaItem)
|
player.setMediaItem(mediaItem)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
@@ -152,23 +185,50 @@ fun RtspVideoPlayer(
|
|||||||
|
|
||||||
// 连接中 / 错误时的提示覆盖层
|
// 连接中 / 错误时的提示覆盖层
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is RtspState.Connecting -> StatusOverlay("连接 RTSP 流中...")
|
is RtspState.Connecting -> {
|
||||||
is RtspState.Error -> StatusOverlay("RTSP 连接失败: ${s.message}")
|
val msg = if (s.retryCount > 0) {
|
||||||
|
"RTSP 重连中... (第 ${s.retryCount} 次)"
|
||||||
|
} else {
|
||||||
|
"连接 RTSP 流中..."
|
||||||
|
}
|
||||||
|
StatusOverlay(msg)
|
||||||
|
}
|
||||||
|
is RtspState.Error -> {
|
||||||
|
val retryHint = if (s.nextRetryMs > 0) {
|
||||||
|
"${s.nextRetryMs / 1000}s 后自动重试"
|
||||||
|
} else ""
|
||||||
|
StatusOverlay(
|
||||||
|
text = "RTSP 连接失败: ${s.message}",
|
||||||
|
subtitle = retryHint
|
||||||
|
)
|
||||||
|
}
|
||||||
is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ }
|
is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusOverlay(text: String) {
|
private fun StatusOverlay(text: String, subtitle: String = "") {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
text = text,
|
Text(
|
||||||
color = Color.White
|
text = text,
|
||||||
)
|
color = Color.White,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
if (subtitle.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user