Compare commits

..

6 Commits

Author SHA1 Message Date
hexone2086 18b326d416 feat: 集成 rtsp-client-android 零缓冲 RTSP 渲染,替换 ExoPlayer
- 新增本地模块 rtspclientlibrary/(拷贝自 rtsp-client-android v5.6.5 library-client-rtsp)
- RtspVideoPlayer.kt 重写:RtspSurfaceView + AndroidView,VideoCodec 枚举映射
  DecoderType.HARDWARE/SOFTWARE;指数退避自动重连(1s→30s,URL/codec 变化重置)
- 延迟统计:每 2s 轮询 statistics 输出 videoDecoderLatencyMsec/networkLatencyMsec
- 本地修复两个已知坑:CSD 参数集顺序 sps+pps+vps -> vps+sps+pps(H.265 起播黑屏根因),
  codecType 硬编码 H264 -> 按实际 MIME 标记(H.265 宽高解析)
- FrameQueue 60 -> 20 压低积压延迟;剔除 camera 依赖(删除 bitmap 渲染路径)
- app 移除 media3-exoplayer-rtsp/ui 依赖(media3-exoplayer 由库模块提供)
2026-08-13 09:24:28 +08:00
hexone2086 d3b2ef1102 docs: 记录 RTSP 低延迟调研结论与 rtsp-client-android 集成决策
- AGENTS.md: RTSP 视频流章节重写——现状(ExoPlayer)、延迟根因分析、
  决策(弃用 ExoPlayer 视频渲染,集成 rtsp-client-android v5.6.5)、
  库的关键技术要点(零缓冲架构/仅TCP/H.265 FU/内置延迟统计/已知坑)
- TODO.md: 视频低延迟改造拆解为 7 个可执行任务
2026-08-13 08:55:01 +08:00
hexone2086 e3ffb01485 docs: 拆分 TODO 到独立文件,AGENTS.md 引用 TODO.md
- 创建 TODO.md,包含待办和已完成列表
- AGENTS.md 中已完成/待办节改为引用 TODO.md

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:35:04 +08:00
hexone2086 8a36537994 feat: 摇杆手型 HandStyle 支持(标准/单摇杆左/单摇杆右/坦克式),设置界面持久化
- AppSettings.kt: 添加 handStyle 字段,默认 STANDARD
- SettingsRepository.kt: 添加 KEY_HAND_STYLE 持久化键,setHandStyle() 方法,setAll() 包含手型
- JoystickController.kt: 添加 HandStyle 枚举,dualStickToAxisCommand() 按手型映射轴
- MainViewModel.kt: 轴指令循环传入当前 handStyle
- SettingsScreen.kt: 添加 HandStyleDropdown 下拉选择组件
- AGENTS.md: 同步更新手型文档

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:32:16 +08:00
hexone2086 b4712a782c chore: 清理订阅请求死代码 + RTSP 自动重连指数退避
- ControlCommands.kt: 删除 subscribeStatus() 及 SUB_* 常量
- ProtocolClient.kt: 删除 sendSubscriptionRequests() 方法及调用
- RtspVideoPlayer.kt: 播放失败后自动重试,指数退避(1s~30s,倍率2),覆盖层显示重试次数和倒计时

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-27 21:32:15 +08:00
hexone2086 9883eb4b44 docs: 同步 AGENTS.md — 修改解码器优化方向 2026-07-20 01:48:13 +08:00
37 changed files with 6521 additions and 236 deletions
+1
View File
@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
build/
+66 -23
View File
@@ -35,9 +35,9 @@ com.example.m20_gamepad/
│ └── StatusReports.kt # 下行状态结构(基础状态、运控状态、设备状态等)
├── service/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持
├── video/
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装)
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(rtspclientlibrary + Composable 包装)
├── data/
│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装)
│ └── AppSettings.kt # 配置数据类定义 + 默认值
@@ -52,12 +52,12 @@ com.example.m20_gamepad/
- **Header 结构**: `Sync(0xEB 91 EB 90) | Length(u16 LE) | MsgId(u16 LE) | Format(0x01) | Reserved(7字节)`
- **ASDU 固定外层**: `{"PatrolDevice": {"Type": int, "Command": int, "Time": "YYYY-MM-DD HH:mm:ss", "Items": {}}}`
- **关键指令**:
- 心跳: Type=100, Cmd=100, ≥1 Hz
- 轴指令: Type=2, Cmd=21, Items: {X,Y,Z,Roll,Pitch,Yaw}, [-1,1], ≥20 Hz
- 速度指令: Type=2, Cmd=25, Items: {X,Y,Z,Roll,Pitch,Yaw}, 物理单位, ≥10 Hz
- 运动状态转换: Type=2, Cmd=22, Items: {MotionParam}
- 步态切换: Type=2, Cmd=23, Items: {GaitParam}
- 使用模式切换: Type=1101, Cmd=5, Items: {Mode}
- 心跳Type=100, Cmd=100, ≥1 Hz
- 轴指令Type=2, Cmd=21, Items: {X,Y,Z,Roll,Pitch,Yaw}, [-1,1], ≥20 Hz
- 速度指令Type=2, Cmd=25, Items: {X,Y,Z,Roll,Pitch,Yaw}, 物理单位≥10 Hz
- 运动状态转换Type=2, Cmd=22, Items: {MotionParam}
- 步态切换Type=2, Cmd=23, Items: {GaitParam}
- 使用模式切换Type=1101, Cmd=5, Items: {Mode}
- **状态上报**: 上行响应含 ErrorCode/ErrorMessage; 主动订阅制(Type=1002, Cmd=3/4/5/6
---
@@ -91,18 +91,60 @@ Joystick(
- 角度 270° = 正下 → X 负方向(后退)
- 强度百分比 → [-1, 1] 比例值
**映射公式(从左摇杆角度/强度到 X/Y**:
**标准手型(STANDARD**
```
val x = power / 100.0 * cos(angle) // 垂直分量
val y = power / 100.0 * sin(angle) // 水平分量
左摇杆: X = sin(angle) * power/100, Y = cos(angle) * power/100
右摇杆: 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(偏航角速度)。
---
## RTSP 视频流
使用 `MediaPlayer` + `SurfaceView``ExoPlayer` 拉取 RTSP 流。视频层作为 UI 最底层背景,摇杆等控件覆盖其上(半透明)。
视频层作为 UI 最底层背景,摇杆等控件覆盖其上(半透明)。
### 当前实现(已集成 rtsp-client-android
- 本地模块 **`rtspclientlibrary/`**(拷贝自 rtsp-client-android v5.6.5 的 `library-client-rtsp`),实现见 `video/RtspVideoPlayer.kt`Compose 包装 `RtspSurfaceView` + AndroidView
- 架构:RTSP **仅 TCP interleaved**(无 UDP)→ RTP 解析(H.264/H.265)→ `FrameQueue(20)` → MediaCodec 直解 → 立即渲染。**无播放时钟 pacing,帧到即解即渲染** = 零缓冲低延迟
- `VideoCodec` 枚举(AUTO/FORCE_HW/FORCE_SW_H264/FORCE_SW_H265)→ `DecoderType.HARDWARE/SOFTWARE`(库仅区分硬/软解,FORCE_SW_* 不支持按 MIME 分别指定)
- 自动重连:指数退避 1s→30s(URL/codec 变化重置);`RtspSurfaceView` 无内置重连,重连逻辑在 Composable 层
- 延迟统计:轮询 `rtspView.statistics` 每 2s 输出 `videoDecoderLatencyMsec`/`networkLatencyMsec` 到 Logcattag `RtspVideoPlayer`
- 本地修改(相对上游):
- **CSD 参数集顺序修正**`vps+sps+pps`(上游为 `sps+pps+vps`H.265 起播黑屏根因)
- **codecType 按实际 MIME 标记**(上游硬编码 H264,H.265 无法解析宽高)
- **`FrameQueue(60)``FrameQueue(20)`**(压低积压延迟)
- 剔除 camera 依赖(删除 `RtspImageView`/bitmap 渲染路径,仅保留 SurfaceView 路径)
### 延迟问题与决策(2026-08 调研结论)
- 现象:RTSP/TCP 延迟高、缓冲大;H.265 解码不高效
- **根因**:不是解码器也不是 TCP 协议本身,而是 ExoPlayer 播放器架构(缓冲水位 + presentationTime pacing 渲染同步)为"平滑播放"设计,非实时
- **决策**:弃用 ExoPlayer 的视频渲染路径,集成第三方库 **rtsp-client-android**alexeyvasilyevv5.6.5,纯 Kotlin 零缓冲直出架构)
- 上游源码备份:`/tmp/rtsp-client-android`(工作区外临时目录);本地拷贝见 `rtspclientlibrary/`
### rtsp-client-android 关键技术要点(集成时必读)
- 架构:RTSP **仅 TCP interleaved**(无 UDP)→ RTP 解析(H.264/H.265)→ `FrameQueue` → MediaCodec 直解 → 立即渲染。**无播放时钟 pacing,帧到即解即渲染** = 零缓冲低延迟(宣传 20ms 解码延迟)
- H.265`RtpH265Parser` 支持 single NAL + FU 分片重组;**AP 聚合包未实现**(有 TODO
- 硬解优先,`MediaCodecUtils.getLowLatencyDecoder` 挑专用低延迟解码器,失败自动回退软解;`MediaCodecHelper.setDecoderLowLatencyOptions``KEY_LOW_LATENCY`
- 实验性 SPS 改写(仅 H.264):`maxDecFrameBuffering=1, numReorderFrames=0`,可砍半部分硬解器延迟(已在 Composable 层开启)
- **内置延迟统计**`RtspSurfaceView.statistics``videoDecoderLatencyMsec`(解码渲染)+ `networkLatencyMsec`(网络),无需自写埋点
- **已知坑(均已本地修复)**`RtspProcessor` 拼 CSD 顺序为 `sps+pps+vps`H.265 标准应为 VPS→SPS→PPS,本地已改);`codecType` 硬编码 H264(本地已按 MIME 修正)
- 依赖:`androidx.media3:media3-exoplayer`(仅用工具类 NalUnitUtil/MediaCodecUtil)、`org.jcodec`SPS 改写);已剔除 `androidx.camera`(YUV→BMP 路径,本项目用 SurfaceView 直出无需)
- 库活跃:2026-06 更新(v5.6.5),全库约 6300 行,无 JNI/无预编译 so,全量可改
---
@@ -117,6 +159,7 @@ val y = power / 100.0 * sin(angle) // 水平分量
| `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_resize_mode` | Enum | `ZOOM` | `FIT` / `ZOOM` / `FILL` |
| `hand_style` | Enum | `STANDARD` | `STANDARD` / `SINGLE_LEFT` / `SINGLE_RIGHT` / `TANK` |
### 持久化
@@ -147,7 +190,7 @@ gradlew installDebug
- 虚拟摇杆库:本地模块 `joysticklibrary/`Compose 原生,Kotlin DSL
- JSON 序列化:`kotlinx-serialization-json:1.7.3`
- RTSP 播放:`androidx.media3:media3-exoplayer-rtsp:1.5.1`
- RTSP 播放:本地模块 `rtspclientlibrary/`rtsp-client-android,内部依赖 `media3-exoplayer:1.5.1` + `jcodec:0.2.5`
- 持久化:`androidx.datastore:datastore-preferences:1.1.3`
- 导航:`androidx.navigation:navigation-compose:2.8.6`
@@ -168,10 +211,9 @@ gradlew installDebug
## 已完成 / 待办
### 已完成
## 已完成 / 待办
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
- [x] 协议层:Header.kt(16 字节小端编解码)
见 [TODO.md](./TODO.md)
- [x] 协议层:ApduMessage.ktHeader + ASDU 封装)
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
@@ -188,9 +230,9 @@ gradlew installDebug
- [x] 导航框架:NavRoutes.kt`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`NavHoststartDestination=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] 状态上报 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] 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 下发)
@@ -201,10 +243,10 @@ gradlew installDebug
- [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
- [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
- [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
- [x] UI 布局:功能按钮均布在四周——左上角模式选择+步态切换+起立/趴下,右上角灯光+休眠+充电,充分利用屏幕空间,优化单手操作体验
- [x] UI 布局:功能按钮均布在四周——左上角模式选择 + 步态切换 + 起立/趴下,右上角灯光 + 休眠 + 充电,充分利用屏幕空间,优化单手操作体验
- [x] UI 布局:起立/趴下合并为切换按钮(琥珀色=站立时显示"趴下",暗色=非站立时显示"起立"),移至左上角 StatusBar 下方
- [x] UI 布局:模式选择器(常规/导航/辅助,3个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
- [x] UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL控制时可点击
- [x] UI 布局:模式选择器(常规/导航/辅助,3 个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
- [x] UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4 个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL 控制时可点击
- [x] UI 布局:照明/休眠/充电移至右上角,紧凑排列(前灯后灯无缝隙一行,休眠/唤醒切换,充电/充电中切换)
- [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
- [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
@@ -219,7 +261,8 @@ gradlew installDebug
- [x] 休眠按钮状态约束:仅非站立状态(空闲/趴下/阻尼等)可切换休眠,站立/RL 控制时禁用
- [x] RTSP 自动重连:`RtspVideoPlayer` 播放失败后自动重试,指数退避(初始 1s,最大 30s,倍率 2),URL/解码器变化时重置退避状态;覆盖层显示重试次数和倒计时
- [x] 订阅请求清理:删除 `ProtocolClient.sendSubscriptionRequests()` 方法及 `connect()` 中的注释调用;同步删除 `ControlCommands.subscribeStatus()``SUB_*` 常量,彻底清除死代码
- [x] 摇杆手型支持:`HandStyle` 枚举(标准/单摇杆左/单摇杆右/坦克式),映射到 `JoystickController.dualStickToAxisCommand()`,设置界面持久化存储,实时生效
### 待办
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
见 [TODO.md](./TODO.md)
+70
View File
@@ -0,0 +1,70 @@
# TODO
## 待办
### 视频低延迟改造(进行中,分支 feature/rtsp-low-latency
- [x] 集成 rtsp-client-android:将 `/tmp/rtsp-client-android/library-client-rtsp` 拷贝为本地模块(`rtspclientlibrary/`),替换 ExoPlayer 视频渲染(含 CSD 顺序 vps+sps+pps 修复、codecType 按 MIME 修正、FrameQueue 60→20、剔除 camera 依赖)
- [x] Compose 封装:`RtspSurfaceView``AndroidView`,替换 `video/RtspVideoPlayer.kt``PlayerView``VideoCodec` 枚举映射到 `DecoderType.HARDWARE/SOFTWARE`,设置界面 `video_codec` 选项复用
- [x] 保留 RTSP 自动重连:移植现有指数退避逻辑(1s→30s,URL/codec 变化重置)
- [x] 接入延迟统计:`RtspSurfaceView.statistics``videoDecoderLatencyMsec`/`networkLatencyMsec` 每 2s 输出 Logcattag `RtspVideoPlayer`)验证延迟改善
- [x] 起播黑屏排查:`RtspProcessor` CSD 拼装顺序已修正为 VPS→SPS→PPScodecType 已按实际 MIME 标记
- [x] 可选:`FrameQueue(60)` 改小(20)进一步压缓冲;剔除 camera 依赖(删除 bitmap 渲染路径,保留 media3/jcodec
- [ ] 删除 ExoPlayer RTSP 依赖与 `RtspVideoPlayer.kt` 旧实现(保留 `VideoCodec`/`VideoResizeMode` 枚举迁移)——已删除 app 侧 media3 依赖与旧实现,待实机验证后合并
### 其他
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView)——已由上方 rtsp-client-android 集成方案承接
## 已完成
- [x] Gradle 配置:版本目录、摇杆本地模块、全部依赖
- [x] 协议层:Header.kt(16 字节小端编解码)
- [x] 协议层:ApduMessage.ktHeader + ASDU 封装)
- [x] 协议层:ControlCommands.kt(全部指令构建函数 + 常量)
- [x] 协议层:StatusReports.kt(全部状态上报数据类 + JSON 解析器)
- [x] 协议层:PacketEncoder.ktMsgId 管理 + APDU 编码)
- [x] 协议层:PacketDecoder.ktAPDU 解码 + 缓冲区搜索)
- [x] 网络层:ProtocolClient.ktUDP socket、心跳 1Hz、指令发送通道、接收循环、断线检测 3s、订阅触发)
- [x] 摇杆映射:JoystickController.ktangle/power -> X/Y/Yaw
- [x] 状态管理:MainViewModel.ktProtocolClient 生命周期、20Hz 轴指令循环、状态收集)
- [x] 视频播放:RtspVideoPlayer.ktMedia3 ExoPlayer RTSPComposable 包装)
- [x] 主界面:MainScreen.kt(视频背景 + 双摇杆 + 状态栏 + 控制按钮)
- [x] 入口:MainActivity.kt + INTERNET 权限
- [x] 持久化层:AppSettings.kt(配置数据类 + 默认值 + 校验)、SettingsRepository.ktDataStore Preferences 封装,`settings: Flow<AppSettings>` + 单项 setter + `setAll()`)、M20App.ktApplication 子类,进程级 `settingsRepository` 单例)
- [x] 设置界面 UISettingsScreen.kt(主机/端口/RTSP 输入 + 编码器 ExposedDropdownMenu,保存按钮调用 `setAll` 后回调 `onSaved`
- [x] 导航框架:NavRoutes.kt`MAIN`/`SETTINGS` 路由常量)、MainActivity.kt 改写为 `AppNavGraph()`NavHoststartDestination=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 -3
View File
@@ -42,6 +42,7 @@ android {
dependencies {
implementation(project(":joysticklibrary"))
implementation(project(":rtspclientlibrary"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -54,9 +55,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.rtsp)
implementation(libs.androidx.media3.ui)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
@@ -1,5 +1,6 @@
package com.example.m20_gamepad.data
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec
/**
@@ -25,7 +26,8 @@ data class AppSettings(
val robotPort: Int = DEFAULT_ROBOT_PORT,
val rtspUrl: String = DEFAULT_RTSP_URL,
val videoCodec: VideoCodec = VideoCodec.AUTO,
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM
val videoResizeMode: VideoResizeMode = VideoResizeMode.ZOOM,
val handStyle: HandStyle = HandStyle.STANDARD
) {
companion object {
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.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -35,7 +36,8 @@ class SettingsRepository(context: Context) {
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 },
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 }
}
suspend fun setHandStyle(handStyle: HandStyle) {
dataStore.edit { it[KEY_HAND_STYLE] = handStyle.ordinal }
}
/** 一次性写入全部设置(用于设置界面保存按钮) */
suspend fun setAll(settings: AppSettings) {
dataStore.edit { prefs ->
@@ -63,6 +69,7 @@ class SettingsRepository(context: Context) {
prefs[KEY_RTSP_URL] = settings.rtspUrl.trim()
prefs[KEY_CODEC] = settings.videoCodec.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_CODEC = intPreferencesKey("video_codec")
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 状态)
timeoutJob = scope.launch {
timeoutCheckLoop()
@@ -195,25 +192,6 @@ class ProtocolClient(
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) {
val buffer = ByteArray(65535)
@@ -122,10 +122,6 @@ object ControlCommands {
// ---- 2.10 休眠状态查询 ----
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_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.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(前后) = sin(angle) * power/100
* - 左摇杆 Y(左右) = cos(angle) * power/100
* - 右摇杆 Yaw = cos(angle) * power/100
*/
object JoystickController {
/** 左摇杆角度/强度 -> 轴指令 JSONX=前后, Y=左右) */
fun leftStickToAxisCommand(angle: Double, power: Double): String {
val scale = power / 100.0
val x = sin(angle) * scale
val y = cos(angle) * scale
return ControlCommands.axisCommand(x = x, y = y)
}
/** 右摇杆角度/强度 -> 轴指令 JSONYaw=偏航) */
fun rightStickToAxisCommand(angle: Double, power: Double): String {
val yaw = cos(angle) * power / 100.0
return ControlCommands.axisCommand(yaw = yaw)
}
/** 双摇杆合并 -> 轴指令 JSON(左摇杆控制 X/Y,右摇杆控制 Yaw) */
/**
* 根据手型将双摇杆输入映射为轴指令 JSON。
*
* @param handStyle 当前手型
* @param leftAngle 左摇杆角度(弧度)
* @param leftPower 左摇杆强度(0-100
* @param rightAngle 右摇杆角度(弧度)
* @param rightPower 右摇杆强度(0-100
*/
fun dualStickToAxisCommand(
handStyle: HandStyle,
leftAngle: Double, leftPower: Double,
rightAngle: Double, rightPower: Double
): String {
val leftScale = leftPower / 100.0
val rightScale = rightPower / 100.0
return when (handStyle) {
HandStyle.STANDARD -> {
val x = sin(leftAngle) * leftScale
val y = cos(leftAngle) * leftScale
val yaw = cos(rightAngle) * rightScale
return ControlCommands.axisCommand(x = x, y = y, yaw = yaw)
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) {
val cmd = if (robot.canSendAxisCommand) {
JoystickController.dualStickToAxisCommand(
settings.value.handStyle,
leftAngle, leftPower,
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.SettingsRepository
import com.example.m20_gamepad.data.VideoResizeMode
import com.example.m20_gamepad.service.HandStyle
import com.example.m20_gamepad.video.VideoCodec
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -67,6 +68,7 @@ fun SettingsScreen(
var rtspUrl by remember { mutableStateOf(AppSettings.DEFAULT_RTSP_URL) }
var codec by remember { mutableStateOf(VideoCodec.AUTO) }
var resizeMode by remember { mutableStateOf(VideoResizeMode.ZOOM) }
var handStyle by remember { mutableStateOf(HandStyle.STANDARD) }
var hostError by remember { mutableStateOf<String?>(null) }
var portError by remember { mutableStateOf<String?>(null) }
var initialized by remember { mutableStateOf(false) }
@@ -79,6 +81,7 @@ fun SettingsScreen(
rtspUrl = s.rtspUrl
codec = s.videoCodec
resizeMode = s.videoResizeMode
handStyle = s.handStyle
initialized = true
}
}
@@ -170,6 +173,12 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth()
)
HandStyleDropdown(
selected = handStyle,
onSelect = { handStyle = it },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.padding(top = 8.dp))
// ---- 操作按钮 ----
@@ -193,7 +202,8 @@ fun SettingsScreen(
robotPort = port,
rtspUrl = rtspUrl,
videoCodec = codec,
videoResizeMode = resizeMode
videoResizeMode = resizeMode,
handStyle = handStyle
)
)
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,17 @@
package com.example.m20_gamepad.video
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -14,18 +19,18 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.DefaultLoadControl
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 androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.alexvas.rtsp.codec.VideoDecodeThread
import com.alexvas.rtsp.widget.RtspStatusListener
import com.alexvas.rtsp.widget.RtspSurfaceView
import com.example.m20_gamepad.data.VideoResizeMode
import kotlinx.coroutines.delay
/**
* 视频解码器选择策略。
@@ -44,21 +49,32 @@ enum class VideoCodec {
/** RTSP 播放状态 */
sealed class RtspState {
/** 连接中 */
object Connecting : RtspState()
data class Connecting(val retryCount: Int = 0) : 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
/** 延迟统计日志 TAG */
private const val TAG = "RtspVideoPlayer"
/**
* RTSP 视频流播放 Composable。
*
* 使用 Media3 ExoPlayer + RTSP 扩展拉取 RTSP 流,作为全屏背景层。
* [codec] 变化时重建播放器并应用新的解码器策略。
* 连接失败时显示错误提示
* 使用 rtspclientlibraryrtsp-client-android)的 [RtspSurfaceView] 拉取 RTSP 流,
* 作为全屏背景层。该库为纯 TCP interleaved + 零缓冲架构(无播放时钟 pacing),
* 帧到达即解码渲染,替代原 Media3 ExoPlayer 播放器缓冲架构以降低延迟
*
* @param url RTSP 流地址,如 `rtsp://10.21.31.103:554/stream`
* [codec] 变化时重建连接并应用新的解码器策略。
* 连接失败时按指数退避自动重试,URL/codec 变化时重置退避。
*
* @param url RTSP 流地址,如 `rtsp://10.21.41.1:8554/video1`
* @param codec 解码器选择策略
* @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏)
* @param modifier 布局修饰符
@@ -71,172 +87,205 @@ fun RtspVideoPlayer(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.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) }
// 生命周期主动停止标志:ON_STOP 调用 stop() 触发的断开/失败不应进入重连
var stoppedByLifecycle by remember { mutableStateOf(false) }
// 最近一次生效的 url/codec:用于区分「参数变化(重置退避立即连接)」与「重试」
var lastParams by remember { mutableStateOf<Pair<String, VideoCodec>?>(null) }
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
// 同时使用最小缓冲策略降低延迟
val player: ExoPlayer = remember(codec) {
val selector = codec.toMediaCodecSelector()
val renderersFactory = DefaultRenderersFactory(context)
renderersFactory.setMediaCodecSelector(selector)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
200, // minBufferMs — 必须 >= bufferForPlaybackAfterRebufferMs
500, // maxBufferMs
100, // bufferForPlaybackMs — 起播所需缓冲
200 // bufferForPlaybackAfterRebufferMs — 重缓冲后所需缓冲
// codec 变化时重建 RtspSurfaceView(在 factory 中映射为 DecoderType)。
// RtspSurfaceView 持有 RtspProcessor,内部线程在 dispose 时由 stop() 停止。
val rtspView = remember(codec, context) {
RtspSurfaceView(context).apply {
videoDecoderType = codec.toDecoderType()
// 实验性 SPS 低延迟参数重写(仅 H.264num_ref_frames -> 0,部分硬件
// 解码器可降低约 2 倍延迟;H.265 流自动跳过)
experimentalUpdateSpsFrameWithLowLatencyParams = true
setStatusListener(object : RtspStatusListener {
override fun onRtspStatusConnected() {
state = RtspState.Playing
}
override fun onRtspStatusFailed(message: String?) {
if (stoppedByLifecycle) return
state = RtspState.Error(
message = message ?: "连接失败",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
.setBackBuffer(0, false)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
ExoPlayer.Builder(context, renderersFactory)
.setLoadControl(loadControl)
.build()
.apply {
repeatMode = Player.REPEAT_MODE_OFF
playWhenReady = true
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
state = when (playbackState) {
Player.STATE_BUFFERING -> RtspState.Connecting
Player.STATE_READY -> RtspState.Playing
Player.STATE_IDLE -> RtspState.Connecting
else -> state
}
// 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++
}
override fun onPlayerError(error: PlaybackException) {
state = RtspState.Error(error.message ?: "连接失败")
override fun onRtspStatusDisconnected() {
if (stoppedByLifecycle) return
// 非主动停止时视为连接丢失,进入重试
state = RtspState.Error(
message = "连接断开",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
retrySignal++
}
})
}
}
// URL 或 codec 变化时重新加载媒体源
LaunchedEffect(url, codec) {
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
LaunchedEffect(url, codec, retrySignal) {
if (url.isNotBlank()) {
state = RtspState.Connecting
val mediaItem = MediaItem.fromUri(url)
player.setMediaItem(mediaItem)
player.prepare()
val params = url to codec
if (lastParams != params) {
// 首次连接 / URL 或 codec 变更:重置退避并立即连接
lastParams = params
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)
rtspView.init(Uri.parse(url))
rtspView.start(requestVideo = true, requestAudio = false)
}
}
// codec 变化时释放旧播放器
DisposableEffect(codec) {
// 延迟统计:轮询库内统计(读取 statistics 会激活测量),输出到 Logcat。
// 播放中每 2s 上报一次解码/网络延迟,用于验证低延迟改造效果。
LaunchedEffect(rtspView) {
while (true) {
delay(2000)
val s = rtspView.statistics
val decLat = s.videoDecoderLatencyMsec
val netLat = s.networkLatencyMsec
if (decLat >= 0 || netLat >= 0) {
Log.i(
TAG,
"decoder=${s.videoDecoderName} (${s.videoDecoderType}), " +
"videoDecoderLatency=${decLat}ms, networkLatency=${netLat}ms"
)
}
}
}
// 界面生命周期:onStop 时停止播放(后台停止拉流/解码),onStart 时恢复
DisposableEffect(lifecycleOwner, rtspView) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
if (rtspView.isStarted()) {
stoppedByLifecycle = true
rtspView.stop()
}
}
Lifecycle.Event.ON_START -> {
stoppedByLifecycle = false
// 重新连接;retrySignal 递增触发 LaunchedEffect(重置退避)
retrySignal++
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
player.release()
lifecycleOwner.lifecycle.removeObserver(observer)
stoppedByLifecycle = true
rtspView.stop()
}
}
Box(modifier = modifier) {
// 仅在视频播放中时渲染 PlayerView,否则透出底层背景
if (state is RtspState.Playing) {
// 始终渲染 SurfaceView:首帧未到前为透明,SurfaceView 在 Compose 之上的
// 层级需保持存在,否则解码渲染目标 surface 会被销毁。
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
this.resizeMode = resizeMode.toMedia3ResizeMode()
this.player = player
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
}
},
update = { view ->
view.player = player
view.resizeMode = resizeMode.toMedia3ResizeMode()
}
factory = { rtspView }
)
}
// 连接中 / 错误时的提示覆盖层
when (val s = state) {
is RtspState.Connecting -> StatusOverlay("连接 RTSP 流中...")
is RtspState.Error -> StatusOverlay("RTSP 连接失败: ${s.message}")
is RtspState.Connecting -> {
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 -> { /* 正常播放,不显示覆盖层 */ }
}
}
}
@Composable
private fun StatusOverlay(text: String) {
private fun StatusOverlay(text: String, subtitle: String = "") {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = text,
color = Color.White
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)
)
}
}
}
}
// ==================== 解码器选择器 ====================
/** MIME 类型常量 */
private const val MIME_H264 = "video/avc"
private const val MIME_HEVC = "video/hevc"
// ==================== 解码器映射 ====================
/**
* 将 [VideoCodec] 枚举映射为 [MediaCodecSelector]。
* 将 [VideoCodec] 枚举映射为 rtspclientlibrary 的 [VideoDecodeThread.DecoderType]。
*
* - AUTO: 默认选择器(优先硬件解码)
* - FORCE_HW: 强制硬件解码,所有 MIME 类型仅保留硬件加速解码器
* - FORCE_SW_H264: 对 H.264 仅保留软件解码器,其余 MIME 走默认
* - FORCE_SW_H265: 针对 H.265/HEVC 仅保留软件解码器,其余 MIME 走默认
* - AUTO: 硬件解码(库默认,解码失败自动回退软件
* - FORCE_HW: 强制硬件解码
* - FORCE_SW_H264: 强制软件解码(对 H.264 流有效)
* - FORCE_SW_H265: 强制软件解码(对 H.265 流有效)
*
* 注意:库的 DecoderType 仅区分 HARDWARE/SOFTWARE,不支持按 MIME 分别指定,
* 因此 FORCE_SW_* 在码流与所选 MIME 不符时仍可能走硬件(与上游行为一致)。
*/
private fun VideoCodec.toMediaCodecSelector(): MediaCodecSelector = when (this) {
VideoCodec.AUTO -> MediaCodecSelector.DEFAULT
VideoCodec.FORCE_HW -> forceHardware()
VideoCodec.FORCE_SW_H264 -> forceSoftwareForMime(MIME_H264)
VideoCodec.FORCE_SW_H265 -> forceSoftwareForMime(MIME_HEVC)
private fun VideoCodec.toDecoderType(): VideoDecodeThread.DecoderType = when (this) {
VideoCodec.AUTO,
VideoCodec.FORCE_HW -> VideoDecodeThread.DecoderType.HARDWARE
VideoCodec.FORCE_SW_H264,
VideoCodec.FORCE_SW_H265 -> VideoDecodeThread.DecoderType.SOFTWARE
}
// ==================== 缩放模式 ====================
/**
* 创建一个 [MediaCodecSelector],仅对指定 [mimeType] 强制软件解码。
* 其余 MIME 类型使用默认行为。
* 缩放模式说明:
*
* rtspclientlibrary 的 [RtspSurfaceView] 不做等比缩放/裁剪,surface 填满其布局
* 区域,视频渲染为全幅铺满(与旧 ExoPlayer 的 FILL 行为一致)。本应用视频作为
* 全屏背景层,默认 ZOOM 与 FILL 视觉近似,因此当前三种模式统一按全幅铺满处理;
* 若需精确 FIT(留黑边)或 ZOOM(按视频宽高比裁剪),需在 [RtspStatusListener]
* 的 onRtspFrameSizeChanged 中拿到视频尺寸后覆写视图测量,留待后续按需实现。
*/
private fun forceSoftwareForMime(targetMime: String): MediaCodecSelector = object : MediaCodecSelector {
override fun getDecoderInfos(
mimeType: String,
requiresSecureDecoder: Boolean,
requiresTunnelingDecoder: Boolean
): List<MediaCodecInfo> {
val all = MediaCodecSelector.DEFAULT.getDecoderInfos(
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
)
// 只对目标 MIME 类型过滤,其余保持默认
return if (mimeType == targetMime) {
all.filter { !it.hardwareAccelerated }
} else {
all
}
}
}
/**
* 创建一个 [MediaCodecSelector],所有 MIME 类型仅保留硬件加速解码器。
* 用于强制硬件解码模式(延迟优先)。
*/
private fun forceHardware(): MediaCodecSelector = object : MediaCodecSelector {
override fun getDecoderInfos(
mimeType: String,
requiresSecureDecoder: Boolean,
requiresTunnelingDecoder: Boolean
): List<MediaCodecInfo> {
return MediaCodecSelector.DEFAULT.getDecoderInfos(
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
}
+4
View File
@@ -12,6 +12,8 @@ datastore = "1.1.3"
navigationCompose = "2.8.6"
media3 = "1.5.1"
kotlinxSerialization = "1.7.3"
annotation = "1.10.0"
jcodec = "0.2.5"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -36,6 +38,8 @@ androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.re
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
jcodec = { group = "org.jcodec", name = "jcodec", version = "0.2.5" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+35
View File
@@ -0,0 +1,35 @@
// rtspclientlibrary:从 rtsp-client-android 拷贝的本地模块(library-client-rtsp
// 源码与上游保持同步,仅改写构建脚本为 Kotlin DSL + 版本目录。
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.alexvas.rtsp"
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 36
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.txt")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(libs.androidx.annotation)
// media3 仅用于 MediaCodecUtil 解码器枚举 / NalUnitUtil SPS 解析(编译期必需)
implementation(libs.androidx.media3.exoplayer)
// jcodec 用于 SPS 低延迟参数重写
implementation(libs.jcodec)
}
+2
View File
@@ -0,0 +1,2 @@
# Proguard rules.
@@ -0,0 +1,4 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
package com.alexvas.rtsp.codec
import android.media.*
import android.os.Process
import android.util.Log
import java.nio.ByteBuffer
class AudioDecodeThread (
private val mimeType: String,
private val sampleRate: Int,
private val channelCount: Int,
private val codecConfig: ByteArray?,
private val audioFrameQueue: AudioFrameQueue) : Thread() {
private var isRunning = true
fun stopAsync() {
if (DEBUG) Log.v(TAG, "stopAsync()")
isRunning = false
// Wake up sleep() code
interrupt()
}
override fun run() {
if (DEBUG) Log.d(TAG, "$name started")
Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)
// Creating audio decoder
val decoder = MediaCodec.createDecoderByType(mimeType)
val format = MediaFormat.createAudioFormat(mimeType, sampleRate, channelCount)
if (mimeType == MediaFormat.MIMETYPE_AUDIO_AAC) {
val csd0 = codecConfig ?: getAacDecoderConfigData(MediaCodecInfo.CodecProfileLevel.AACObjectLC, sampleRate, channelCount)
format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0))
format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
} else if (mimeType == MediaFormat.MIMETYPE_AUDIO_OPUS) {
// TODO: Add Opus support
// val OPUS_IDENTIFICATION_HEADER = "OpusHead".toByteArray()
// val OPUS_PRE_SKIP_NSEC = ByteBuffer.allocate(8).putLong(11971).array()
// val OPUS_SEEK_PRE_ROLL_NSEC = ByteBuffer.allocate(8).putLong(80000000).array()
// val csd0 = ByteBuffer.allocate(8+1+1+2+4+2+1)
// csd0.put("OpusHead".toByteArray())
// // Version
// csd0.put(1)
// // Number of channels
// csd0.put(2)
// // Pre-skip
// csd0.putShort(0)
// csd0.putInt(sampleRate)
// // Output Gain
// csd0.putShort(0)
// // Channel Mapping Family
// csd0.put(0)
// Buffer buf = new Buffer();
// // Magic Signature:固定头,占8个字节,为字符串OpusHead
// buf.write("OpusHead".getBytes(StandardCharsets.UTF_8));
// // Version:版本号,占1字节,固定为0x01
// buf.writeByte(1);
// // Channel Count:通道数,占1字节,根据音频流通道自行设置,如0x02
// buf.writeByte(1);
// // Pre-skip:回放的时候从解码器中丢弃的samples数量,占2字节,为小端模式,默认设置0x00,
// buf.writeShortLe(0);
// // Input Sample Rate (Hz):音频流的Sample Rate,占4字节,为小端模式,根据实际情况自行设置
// buf.writeIntLe(currentFormat.HZ);
// //Output Gain:输出增益,占2字节,为小端模式,没有用到默认设置0x00, 0x00就好
// buf.writeShortLe(0);
// // Channel Mapping Family:通道映射系列,占1字节,默认设置0x00就好
// buf.writeByte(0);
// //Channel Mapping Table:可选参数,上面的Family默认设置0x00的时候可忽略
// format.setByteBuffer("csd-0", ByteBuffer.wrap(OPUS_IDENTIFICATION_HEADER).order(ByteOrder.BIG_ENDIAN))
// format.setByteBuffer("csd-1", ByteBuffer.wrap(OPUS_PRE_SKIP_NSEC).order(ByteOrder.BIG_ENDIAN))
// format.setByteBuffer("csd-2", ByteBuffer.wrap(OPUS_SEEK_PRE_ROLL_NSEC).order(ByteOrder.LITTLE_ENDIAN))
val csd0 = byteArrayOf(
0x4f, 0x70, 0x75, 0x73, // "Opus"
0x48, 0x65, 0x61, 0x64, // "Head"
0x01, // Version
0x02, // Channel Count
0x00, 0x00, // Pre skip
0x80.toByte(), 0xbb.toByte(), 0x00, 0x00, // Sample rate 48000
0x00, 0x00, // Output Gain (Q7.8 in dB)
0x00, // Mapping Family
)
val csd1 = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val csd2 = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0))
format.setByteBuffer("csd-1", ByteBuffer.wrap(csd1))
format.setByteBuffer("csd-2", ByteBuffer.wrap(csd2))
}
decoder.configure(format, null, null, 0)
decoder.start()
// Creating audio playback device
val outChannel = if (channelCount > 1) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val outAudio = AudioFormat.ENCODING_PCM_16BIT
val bufferSize = AudioTrack.getMinBufferSize(sampleRate, outChannel, outAudio)
// Log.i(TAG, "sampleRate: $sampleRate, bufferSize: $bufferSize".format(sampleRate, bufferSize))
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
AudioFormat.Builder()
.setEncoding(outAudio)
.setChannelMask(outChannel)
.setSampleRate(sampleRate)
.build(),
bufferSize,
AudioTrack.MODE_STREAM,
0)
audioTrack.play()
val bufferInfo = MediaCodec.BufferInfo()
while (isRunning) {
val inIndex: Int = decoder.dequeueInputBuffer(10000L)
if (inIndex >= 0) {
// fill inputBuffers[inputBufferIndex] with valid data
var byteBuffer: ByteBuffer?
try {
byteBuffer = decoder.getInputBuffer(inIndex)
} catch (e: Exception) {
e.printStackTrace()
break
}
byteBuffer?.rewind()
// Preventing BufferOverflowException
// if (length > byteBuffer.limit()) throw DecoderFatalException("Error")
val audioFrame: FrameQueue.Frame?
try {
audioFrame = audioFrameQueue.pop()
if (audioFrame == null) {
Log.d(TAG, "Empty audio frame")
// Release input buffer
decoder.queueInputBuffer(inIndex, 0, 0, 0L, 0)
} else {
byteBuffer?.put(audioFrame.data, audioFrame.offset, audioFrame.length)
decoder.queueInputBuffer(inIndex, audioFrame.offset, audioFrame.length, audioFrame.timestampMs, 0)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// Log.i(TAG, "inIndex: ${inIndex}")
try {
// Log.w(TAG, "outIndex: ${outIndex}")
if (!isRunning) break
when (val outIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000L)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Log.d(TAG, "Decoder format changed: ${decoder.outputFormat}")
MediaCodec.INFO_TRY_AGAIN_LATER -> if (DEBUG) Log.d(TAG, "No output from decoder available")
else -> {
if (outIndex >= 0) {
val byteBuffer: ByteBuffer? = decoder.getOutputBuffer(outIndex)
val chunk = ByteArray(bufferInfo.size)
byteBuffer?.get(chunk)
byteBuffer?.clear()
if (chunk.isNotEmpty()) {
audioTrack.write(chunk, 0, chunk.size)
}
decoder.releaseOutputBuffer(outIndex, false)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
// All decoded frames have been rendered, we can stop playing now
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
Log.d(TAG, "OutputBuffer BUFFER_FLAG_END_OF_STREAM")
break
}
}
audioTrack.flush()
audioTrack.release()
try {
decoder.stop()
decoder.release()
} catch (_: InterruptedException) {
} catch (e: Exception) {
e.printStackTrace()
}
audioFrameQueue.clear()
if (DEBUG) Log.d(TAG, "$name stopped")
}
companion object {
private val TAG: String = AudioDecodeThread::class.java.simpleName
private const val DEBUG = false
fun getAacDecoderConfigData(audioProfile: Int, sampleRate: Int, channels: Int): ByteArray {
// AOT_LC = 2
// 0001 0000 0000 0000
var extraDataAac = audioProfile shl 11
// Sample rate
when (sampleRate) {
7350 -> extraDataAac = extraDataAac or (0xC shl 7)
8000 -> extraDataAac = extraDataAac or (0xB shl 7)
11025 -> extraDataAac = extraDataAac or (0xA shl 7)
12000 -> extraDataAac = extraDataAac or (0x9 shl 7)
16000 -> extraDataAac = extraDataAac or (0x8 shl 7)
22050 -> extraDataAac = extraDataAac or (0x7 shl 7)
24000 -> extraDataAac = extraDataAac or (0x6 shl 7)
32000 -> extraDataAac = extraDataAac or (0x5 shl 7)
44100 -> extraDataAac = extraDataAac or (0x4 shl 7)
48000 -> extraDataAac = extraDataAac or (0x3 shl 7)
64000 -> extraDataAac = extraDataAac or (0x2 shl 7)
88200 -> extraDataAac = extraDataAac or (0x1 shl 7)
96000 -> extraDataAac = extraDataAac or (0x0 shl 7)
}
// Channels
extraDataAac = extraDataAac or (channels shl 3)
val extraData = ByteArray(2)
extraData[0] = (extraDataAac and 0xff00 shr 8).toByte() // high byte
extraData[1] = (extraDataAac and 0xff).toByte() // low byte
return extraData
}
}
}
@@ -0,0 +1,95 @@
package com.alexvas.rtsp.codec
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit
enum class VideoCodecType {
H264, H265, UNKNOWN
}
enum class AudioCodecType {
AAC_LC, G711_ALAW, G711_MLAW, UNKNOWN
}
class VideoFrameQueue(frameQueueCapacity: Int): FrameQueue<FrameQueue.VideoFrame>(frameQueueCapacity)
class AudioFrameQueue(frameQueueCapacity: Int): FrameQueue<FrameQueue.AudioFrame>(frameQueueCapacity)
/**
* Queue for concurrent adding/removing audio/video frames.
*/
open class FrameQueue<T>(private val frameQueueCapacity: Int) {
interface Frame {
val data: ByteArray
val offset: Int
val length: Int
val timestampMs: Long // presentation time in msec
}
data class VideoFrame(
/** Only H264 codec supported */
val codecType: VideoCodecType,
/** Indicates whether it is a keyframe or not */
val isKeyframe: Boolean,
override val data: ByteArray,
override val offset: Int,
override val length: Int,
/** Video frame timestamp (msec) generated by camera */
override val timestampMs: Long,
/** Captured (received) video frame timestamp (msec). If -1, not supported. */
val capturedTimestampMs: Long = -1
) : Frame
data class AudioFrame(
val codecType: AudioCodecType,
// val sampleRate: Int,
override val data: ByteArray,
override val offset: Int,
override val length: Int,
override val timestampMs: Long,
) : Frame
private val queue = ArrayBlockingQueue<T>(frameQueueCapacity)
val size: Int
get() = queue.size
val capacity: Int
get() = frameQueueCapacity
@Throws(InterruptedException::class)
fun push(frame: T): Boolean {
if (queue.offer(frame, 5, TimeUnit.MILLISECONDS)) {
return true
}
// Log.w(TAG, "Cannot add frame, queue is full")
return false
}
@Throws(InterruptedException::class)
open fun pop(timeout: Long = 1000): T? {
try {
val frame: T? = queue.poll(timeout, TimeUnit.MILLISECONDS)
// if (frame == null) {
// Log.w(TAG, "Cannot get frame within 1 sec, queue is empty")
// }
return frame
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
return null
}
fun clear() {
queue.clear()
}
fun copyInto(dstFrameQueue: FrameQueue<T>) {
dstFrameQueue.queue.addAll(queue)
}
companion object {
private val TAG: String = FrameQueue::class.java.simpleName
}
}
@@ -0,0 +1,530 @@
package com.alexvas.rtsp.codec
import android.annotation.SuppressLint
import android.media.MediaCodec
import android.media.MediaCodec.OnFrameRenderedListener
import android.media.MediaFormat
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import com.alexvas.utils.MediaCodecUtils
import com.alexvas.utils.capabilitiesToString
import androidx.media3.common.util.Util
import com.alexvas.utils.VideoCodecUtils
import com.limelight.binding.video.MediaCodecHelper
import java.lang.Integer.min
import java.nio.ByteBuffer
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
abstract class VideoDecodeThread (
protected val mimeType: String,
protected val width: Int,
protected val height: Int,
protected val rotation: Int, // 0, 90, 180, 270
protected val videoFrameQueue: VideoFrameQueue,
protected val videoDecoderListener: VideoDecoderListener,
protected var videoDecoderType: DecoderType
) : Thread() {
enum class DecoderType {
HARDWARE,
SOFTWARE // fallback
}
interface VideoDecoderListener {
/** Video decoder successfully started */
fun onVideoDecoderStarted() {}
/** Video decoder successfully stopped */
fun onVideoDecoderStopped() {}
/** Fatal error occurred */
fun onVideoDecoderFailed(message: String?) {}
/** Resolution changed */
fun onVideoDecoderFormatChanged(width: Int, height: Int) {}
/** First video frame rendered */
fun onVideoDecoderFirstFrameRendered() {}
}
protected val uiHandler = Handler(Looper.getMainLooper())
protected var exitFlag = AtomicBoolean(false)
protected var firstFrameRendered = false
/** Decoder latency used for statistics */
@Volatile private var decoderLatency = -1
/** Flag for allowing calculating latency */
private var decoderLatencyRequested = false
/** Network latency used for statistics */
@Volatile private var networkLatency = -1
private var videoDecoderName: String? = null
private var firstFrameDecoded = false
@Volatile private var videoFrameRateStabilization = false
fun stopAsync() {
if (DEBUG) Log.v(TAG, "stopAsync()")
exitFlag.set(true)
// Wake up sleep() code
interrupt()
}
/**
* Currently used video decoder. Video decoder can be changed on runtime.
* If videoDecoderType set to HARDWARE, it can be switched to SOFTWARE in case of decoding issue
* (e.g. hardware decoder does not support the stream resolution).
* If videoDecoderType set to SOFTWARE, it will always remain SOFTWARE (no any changes).
*/
fun getCurrentVideoDecoderType(): DecoderType {
return videoDecoderType
}
fun getCurrentVideoDecoderName(): String? {
return videoDecoderName
}
/**
* Get frames decoding/rendering latency in msec. Returns -1 if not supported.
*/
fun getCurrentVideoDecoderLatencyMsec(): Int {
decoderLatencyRequested = true
return decoderLatency
}
/**
* Get network latency in msec. Returns -1 if not supported.
*/
fun getCurrentNetworkLatencyMsec(): Int {
return networkLatency
}
fun setVideoFrameRateStabilization(enable: Boolean) {
if (DEBUG) Log.v(TAG, "setVideoFrameRateStabilization(enable=$enable)")
videoFrameRateStabilization = enable
}
fun hasVideoFrameRateStabilization(): Boolean {
return videoFrameRateStabilization
}
@SuppressLint("UnsafeOptInUsageError")
private fun getDecoderSafeWidthHeight(decoder: MediaCodec): Pair<Int, Int> {
if (DEBUG) Log.v(TAG, "getDecoderSafeWidthHeight()")
val capabilities = decoder.codecInfo.getCapabilitiesForType(mimeType).videoCapabilities
return if (capabilities == null) {
Log.e(TAG, "Not a video decoder")
Pair(-1, -1)
} else if (capabilities.isSizeSupported(width, height)) {
Log.i(TAG, "Video decoder frame size ${width}x${height} supported")
Pair(width, height)
} else {
Log.w(TAG, "Video decoder frame size ${width}x${height} is not supported")
val widthAlignment = capabilities.widthAlignment
val heightAlignment = capabilities.heightAlignment
val w = Util.ceilDivide(width, widthAlignment) * widthAlignment
val h = Util.ceilDivide(height, heightAlignment) * heightAlignment
if (capabilities.isSizeSupported(w, h)) {
Log.i(TAG, "Video decoder frame size ${w}x${h} calculated from alignment ${widthAlignment}x${heightAlignment} and original size ${width}x${height}]")
Pair(w, h)
} else {
val p = Pair(capabilities.supportedWidths.upper, capabilities.supportedHeights.upper)
Log.i(TAG, "Video decoder max supported frame size ${w}x${h}")
p
}
}
}
@SuppressLint("InlinedApi")
private fun getWidthHeight(mediaFormat: MediaFormat): Pair<Int, Int> {
// Sometimes height obtained via KEY_HEIGHT is not valid, e.g. can be 1088 instead 1080
// (no problems with width though). Use crop parameters to correctly determine height.
val hasCrop =
mediaFormat.containsKey(MediaFormat.KEY_CROP_RIGHT) && mediaFormat.containsKey(MediaFormat.KEY_CROP_LEFT) &&
mediaFormat.containsKey(MediaFormat.KEY_CROP_BOTTOM) && mediaFormat.containsKey(MediaFormat.KEY_CROP_TOP)
val width =
if (hasCrop)
mediaFormat.getInteger(MediaFormat.KEY_CROP_RIGHT) - mediaFormat.getInteger(MediaFormat.KEY_CROP_LEFT) + 1
else
mediaFormat.getInteger(MediaFormat.KEY_WIDTH)
var height =
if (hasCrop)
mediaFormat.getInteger(MediaFormat.KEY_CROP_BOTTOM) - mediaFormat.getInteger(MediaFormat.KEY_CROP_TOP) + 1
else
mediaFormat.getInteger(MediaFormat.KEY_HEIGHT)
// Fix for 1080p resolution for Samsung S21
// {crop-right=1919, max-height=4320, sar-width=1, color-format=2130708361, mime=video/raw,
// hdr-static-info=java.nio.HeapByteBuffer[pos=0 lim=25 cap=25],
// priority=0, color-standard=1, feature-secure-playback=0, color-transfer=3, sar-height=1,
// crop-bottom=1087, max-width=8192, crop-left=0, width=1920, color-range=2, crop-top=0,
// rotation-degrees=0, frame-rate=30, height=1088}
height = height / 16 * 16 // 1088 -> 1080
// if (height == 1088)
// height = 1080
return Pair(width, height)
}
private fun getDecoderMediaFormat(decoder: MediaCodec): MediaFormat {
if (DEBUG) Log.v(TAG, "getDecoderMediaFormat()")
val safeWidthHeight = getDecoderSafeWidthHeight(decoder)
val format = MediaFormat.createVideoFormat(mimeType, safeWidthHeight.first, safeWidthHeight.second)
if (DEBUG)
Log.d(TAG, "Configuring surface ${safeWidthHeight.first}x${safeWidthHeight.second} w/ '$mimeType'")
else
Log.i(TAG, "Configuring surface ${safeWidthHeight.first}x${safeWidthHeight.second} w/ '$mimeType'")
format.setInteger(MediaFormat.KEY_ROTATION, rotation)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// // format.setFeatureEnabled(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_LowLatency, true)
// // Request low-latency for the decoder. Not all of the decoders support that.
// format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1)
// }
val succeeded = MediaCodecHelper.setDecoderLowLatencyOptions(format, decoder.codecInfo, 1)
Log.i(TAG, "Low-latency: $succeeded")
return format
}
/** Decoder created */
abstract fun decoderCreated(mediaCodec: MediaCodec, mediaFormat: MediaFormat)
/** Frame processed */
abstract fun releaseOutputBuffer(
mediaCodec: MediaCodec,
outIndex: Int,
bufferInfo: MediaCodec.BufferInfo,
render: Boolean
)
/** Decoder stopped and released */
abstract fun decoderDestroyed(mediaCodec: MediaCodec)
private fun createVideoDecoderAndStart(decoderType: DecoderType): MediaCodec {
if (DEBUG) Log.v(TAG, "createVideoDecoderAndStart(decoderType=$decoderType)")
@SuppressLint("UnsafeOptInUsageError")
val decoder = when (decoderType) {
DecoderType.HARDWARE -> {
val hwDecoders = MediaCodecUtils.getHardwareDecoders(mimeType)
if (hwDecoders.isEmpty()) {
Log.w(TAG, "Cannot get hardware video decoders for mime type '$mimeType'. Using default one.")
MediaCodec.createDecoderByType(mimeType)
} else {
val lowLatencyDecoder = MediaCodecUtils.getLowLatencyDecoder(hwDecoders)
val name = lowLatencyDecoder?.let {
Log.i(TAG, "[$name] Dedicated low-latency decoder found '${lowLatencyDecoder.name}'")
lowLatencyDecoder.name
} ?: hwDecoders[0].name
MediaCodec.createByCodecName(name)
}
}
DecoderType.SOFTWARE -> {
val swDecoders = MediaCodecUtils.getSoftwareDecoders(mimeType)
if (swDecoders.isEmpty()) {
Log.w(TAG, "Cannot get software video decoders for mime type '$mimeType'. Using default one .")
MediaCodec.createDecoderByType(mimeType)
} else {
val name = swDecoders[0].name
MediaCodec.createByCodecName(name)
}
}
}
this.videoDecoderType = decoderType
this.videoDecoderName = decoder.name
val frameRenderedListener = OnFrameRenderedListener { _, _, _ ->
if (!firstFrameRendered) {
firstFrameRendered = true
uiHandler.post {
videoDecoderListener.onVideoDecoderFirstFrameRendered()
}
}
}
decoder.setOnFrameRenderedListener(frameRenderedListener, null)
val format = getDecoderMediaFormat(decoder)
decoderCreated(decoder, format)
decoder.start()
val capabilities = decoder.codecInfo.getCapabilitiesForType(mimeType)
val lowLatencySupport = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
capabilities.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_LowLatency)
} else {
false
}
Log.i(TAG, "[$name] Video decoder '${decoder.name}' started " +
"(${if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (decoder.codecInfo.isHardwareAccelerated) "hardware" else "software" } else ""}, " +
"${capabilities.capabilitiesToString()}, " +
"${if (lowLatencySupport) "w/" else "w/o"} low-latency support)")
return decoder
}
private fun stopAndReleaseVideoDecoder(decoder: MediaCodec) {
if (DEBUG) Log.v(TAG, "stopAndReleaseVideoDecoder()")
val type = videoDecoderType.toString().lowercase()
Log.i(TAG, "Stopping $type video decoder...")
try {
decoder.stop()
Log.i(TAG, "Decoder successfully stopped")
} catch (e3: Throwable) {
Log.e(TAG, "Failed to stop decoder", e3)
}
Log.i(TAG, "Releasing decoder...")
try {
decoder.release()
Log.i(TAG, "Decoder successfully released")
} catch (e3: Throwable) {
Log.e(TAG, "Failed to release decoder", e3)
}
videoFrameQueue.clear()
decoderDestroyed(decoder)
}
override fun run() {
if (DEBUG) Log.d(TAG, "$name started")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Process.setThreadPriority(Process.THREAD_PRIORITY_VIDEO)
}
videoDecoderListener.onVideoDecoderStarted()
try {
Log.i(TAG, "Starting hardware video decoder...")
var decoder = try {
createVideoDecoderAndStart(videoDecoderType)
} catch (e: Throwable) {
Log.e(TAG, "Failed to start $videoDecoderType video decoder (${e.message})", e)
Log.i(TAG, "Starting software video decoder...")
try {
createVideoDecoderAndStart(DecoderType.SOFTWARE)
} catch (e2: Throwable) {
Log.e(TAG, "Failed to start video software decoder. Exiting...", e2)
// Unexpected behavior
videoDecoderListener.onVideoDecoderFailed("Cannot initialize video decoder for mime type '$mimeType'")
return
}
}
val bufferInfo = MediaCodec.BufferInfo()
try {
var widthHeightFromStream: Pair<Int, Int>? = null
// Map for calculating decoder rendering latency.
// key - original frame timestamp, value - timestamp when frame was added to the map
val keyframesTimestamps = HashMap<Long, Long>()
var frameQueuedMsec = System.currentTimeMillis()
var frameAlreadyDequeued = false
// Main loop
while (!exitFlag.get()) {
try {
val inIndex: Int = decoder.dequeueInputBuffer(DEQUEUE_INPUT_TIMEOUT_US)
if (inIndex >= 0) {
// fill inputBuffers[inputBufferIndex] with valid data
val byteBuffer: ByteBuffer? = decoder.getInputBuffer(inIndex)
byteBuffer?.rewind()
// Preventing BufferOverflowException
// if (length > byteBuffer.limit()) throw DecoderFatalException("Error")
val frame = videoFrameQueue.pop()
if (frame == null) {
Log.d(TAG, "Empty video frame")
// Release input buffer
decoder.queueInputBuffer(inIndex, 0, 0, 0L, 0)
} else {
// Add timestamp for keyframe to calculating latency further.
if ((DEBUG || decoderLatencyRequested) && frame.isKeyframe) {
if (keyframesTimestamps.size > 5) {
// Something wrong with map. Allow only 5 map entries.
keyframesTimestamps.clear()
}
val l = System.currentTimeMillis()
keyframesTimestamps[frame.timestampMs] = l
// Log.d(TAG, "Added $l")
}
// Calculate network latency
networkLatency = if (frame.capturedTimestampMs > -1)
(frame.timestampMs - frame.capturedTimestampMs).toInt()
else
-1
byteBuffer?.put(frame.data, frame.offset, frame.length)
if (DEBUG) {
val l = System.currentTimeMillis()
Log.i(TAG, "\tFrame queued (${l - frameQueuedMsec}) ${if (frame.isKeyframe) "key frame" else ""}")
frameQueuedMsec = l
}
val flags = if (frame.isKeyframe)
(MediaCodec.BUFFER_FLAG_KEY_FRAME /*or MediaCodec.BUFFER_FLAG_CODEC_CONFIG*/) else 0
decoder.queueInputBuffer(inIndex, frame.offset, frame.length, frame.timestampMs, flags)
if (frame.isKeyframe) {
// Obtain width and height from stream
widthHeightFromStream = try {
VideoCodecUtils.getWidthHeightFromArray(
frame.data,
frame.offset,
// Check only first 100 bytes maximum. That's enough for finding SPS NAL unit.
min(frame.length, VideoCodecUtils.MAX_NAL_SPS_SIZE),
isH265 = frame.codecType == VideoCodecType.H265
)
} catch (_: Exception) {
// Log.e(TAG, "Failed to parse width/height from SPS frame. SPS frame seems to be corrupted.", e)
null
}
// Log.i(TAG, "width/height: ${widthHeightFromStream?.first}x${widthHeightFromStream?.second}")
}
}
}
if (exitFlag.get()) break
// Get all output buffer frames until no buffer from decoder available (INFO_TRY_AGAIN_LATER).
// Single input buffer frame can contain several frames, e.g. SPS + PPS + IDR.
// Thus dequeueOutputBuffer should be called several times.
// First time it obtains SPS + PPS, second one - IDR frame.
do {
// For the first time wait for a frame within 100 msec, next times no timeout
val timeout = if (frameAlreadyDequeued || !firstFrameDecoded) 0L else DEQUEUE_OUTPUT_BUFFER_TIMEOUT_US
val outIndex = decoder.dequeueOutputBuffer(bufferInfo, timeout)
when (outIndex) {
// Resolution changed
MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED, MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
Log.d(TAG, "Decoder format changed: ${decoder.outputFormat}")
// Decoder can contain different resolution (it can make downsampling).
// If resolution successfully obtained from SPS frame, use it.
val widthHeightFromDecoder = getWidthHeight(decoder.outputFormat)
val widthHeight = widthHeightFromStream ?: widthHeightFromDecoder
Log.i(TAG, "Video decoder resolution: ${widthHeightFromDecoder.first}x${widthHeightFromDecoder.second}, stream resolution: ${widthHeightFromStream?.first}x${widthHeightFromStream?.second}")
// val widthHeightFromDecoder = getWidthHeight(decoder.outputFormat)
val rotation = if (decoder.outputFormat.containsKey(MediaFormat.KEY_ROTATION)) {
decoder.outputFormat.getInteger(MediaFormat.KEY_ROTATION)
} else {
// Some devices like Samsung SM-A505U (Android 11) do not allow
// video stream rotation on decoding for hardware decoder
Log.w(TAG, "Video stream rotation is not supported by this Android device (${Build.MODEL} - ${Build.DEVICE}, codec: '${decoder.name}')")
0
}
uiHandler.post {
// Run in UI thread
when (rotation) {
90, 270 -> videoDecoderListener.onVideoDecoderFormatChanged(widthHeight.second, widthHeight.first)
else -> videoDecoderListener.onVideoDecoderFormatChanged(widthHeight.first, widthHeight.second)
}
}
frameAlreadyDequeued = true
}
// No any frames in queue
MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (DEBUG) Log.d(TAG, "No output from decoder available")
frameAlreadyDequeued = true
}
// Frame decoded
else -> {
if (outIndex >= 0) {
if (DEBUG || decoderLatencyRequested) {
val ts = bufferInfo.presentationTimeUs
keyframesTimestamps.remove(ts)?.apply {
decoderLatency = (System.currentTimeMillis() - this).toInt()
// Log.d(TAG, "Removed $this")
}
}
val render = bufferInfo.size != 0 && !exitFlag.get()
if (DEBUG) Log.i(TAG, "\tFrame decoded [outIndex=$outIndex, render=$render]")
releaseOutputBuffer(decoder, outIndex, bufferInfo, render)
if (!firstFrameDecoded && render) {
firstFrameDecoded = true
}
frameAlreadyDequeued = false
} else {
Log.e(TAG, "Obtaining frame failed w/ error code $outIndex")
}
}
}
// For SPS/PPS frame request another frame (IDR)
} while (outIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED || outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED)
// } while (outIndex != MediaCodec.INFO_TRY_AGAIN_LATER)
// All decoded frames have been rendered, we can stop playing now
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
if (DEBUG) Log.d(TAG, "OutputBuffer BUFFER_FLAG_END_OF_STREAM")
break
}
} catch (_: InterruptedException) {
} catch (e: IllegalStateException) {
// Restarting decoder in software mode
Log.e(TAG, "${e.message}", e)
stopAndReleaseVideoDecoder(decoder)
Log.i(TAG, "Starting software video decoder...")
decoder = createVideoDecoderAndStart(DecoderType.SOFTWARE)
Log.i(TAG, "Software video decoder '${decoder.name}' started (${decoder.codecInfo.getCapabilitiesForType(mimeType).capabilitiesToString()})")
} catch (e: MediaCodec.CodecException) {
Log.w(TAG, "${e.diagnosticInfo}\nisRecoverable: ${e.isRecoverable}, isTransient: ${e.isTransient}")
if (e.isRecoverable) {
// Recoverable error.
// Calling stop(), configure(), and start() to recover.
Log.i(TAG, "Recovering video decoder...")
try {
decoder.stop()
val format = getDecoderMediaFormat(decoder)
decoderCreated(decoder, format)
decoder.start()
Log.i(TAG, "Video decoder recovering succeeded")
} catch (e2: Throwable) {
Log.e(TAG, "Video decoder recovering failed")
Log.e(TAG, "${e2.message}", e2)
}
} else if (e.isTransient) {
// Transient error. Resources are temporarily unavailable and
// the method may be retried at a later time.
Log.w(TAG, "Video decoder resource temporarily unavailable")
} else {
// Fatal error. Restarting decoder in software mode.
stopAndReleaseVideoDecoder(decoder)
Log.i(TAG, "Starting video software decoder...")
decoder = createVideoDecoderAndStart(DecoderType.SOFTWARE)
Log.i(TAG, "Software video decoder '${decoder.name}' started (${decoder.codecInfo.getCapabilitiesForType(mimeType).capabilitiesToString()})")
}
} catch (e: Throwable) {
Log.e(TAG, "${e.message}", e)
}
} // while
// Drain decoder
val inIndex: Int = decoder.dequeueInputBuffer(DEQUEUE_INPUT_TIMEOUT_US)
if (inIndex >= 0) {
decoder.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
} else {
Log.w(TAG, "Not able to signal end of stream")
}
} catch (e2: Throwable) {
Log.e(TAG, "${e2.message}", e2)
} finally {
stopAndReleaseVideoDecoder(decoder)
}
} catch (e: Throwable) {
Log.e(TAG, "$name stopped due to '${e.message}'")
videoDecoderListener.onVideoDecoderFailed(e.message)
// While configuring stopAsync can be called and surface released. Just exit.
if (!exitFlag.get()) e.printStackTrace()
return
}
videoDecoderListener.onVideoDecoderStopped()
if (DEBUG) Log.d(TAG, "$name stopped")
}
companion object {
internal val TAG: String = VideoDecodeThread::class.java.simpleName
internal const val DEBUG = false
private val DEQUEUE_INPUT_TIMEOUT_US = TimeUnit.MILLISECONDS.toMicros(500)
private val DEQUEUE_OUTPUT_BUFFER_TIMEOUT_US = TimeUnit.MILLISECONDS.toMicros(100)
}
}
@@ -0,0 +1,164 @@
package com.alexvas.rtsp.codec
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import android.view.Surface
import java.util.concurrent.TimeUnit
import kotlin.math.max
class VideoDecoderSurfaceThread(
private val surface: Surface,
mimeType: String,
width: Int,
height: Int,
rotation: Int, // 0, 90, 180, 270
videoFrameQueue: VideoFrameQueue,
videoDecoderListener: VideoDecoderListener,
videoDecoderType: DecoderType = DecoderType.HARDWARE,
videoFrameRateStabilization: Boolean = false,
) : VideoDecodeThread(
mimeType, width, height, rotation, videoFrameQueue, videoDecoderListener, videoDecoderType
) {
/**
* Presentation time (in RTP units converted to microseconds) of the first frame used as the
* PTS baseline.
*/
private var streamStartPtsUs: Long? = null
/**
* Monotonic clock timestamp corresponding to streamStartPtsUs, used to map future frames
* to real time.
*/
private var playbackStartRealtimeNs: Long? = null
/**
* Timestamp of the most recently released frame to enforce minimum spacing between consecutive
* frames.
*/
private var lastFrameReleaseTimeNs: Long = Long.MIN_VALUE
/**
* Last presentation timestamp we processed; used to detect wrap-around or backwards jumps.
*/
private var lastPresentationTimeUs: Long = Long.MIN_VALUE
init {
setVideoFrameRateStabilization(videoFrameRateStabilization)
}
override fun decoderCreated(mediaCodec: MediaCodec, mediaFormat: MediaFormat) {
if (DEBUG) Log.v(TAG, "decoderCreated()")
if (!surface.isValid) {
Log.e(TAG, "Surface invalid")
}
mediaCodec.configure(mediaFormat, surface, null, 0)
resetFrameTiming()
}
private fun releaseOutputBufferWithFrameRateStabilization(
mediaCodec: MediaCodec,
outIndex: Int,
bufferInfo: MediaCodec.BufferInfo
) {
if (DEBUG) Log.v(TAG, "releaseOutputBufferWithFrameRateStabilization(outIndex=$outIndex)")
val ptsUs = bufferInfo.presentationTimeUs
val nowNs = System.nanoTime()
if (streamStartPtsUs == null || playbackStartRealtimeNs == null) {
// First frame (or after a reset): initialize all timing anchors.
streamStartPtsUs = ptsUs
playbackStartRealtimeNs = nowNs
lastFrameReleaseTimeNs = nowNs
lastPresentationTimeUs = ptsUs
mediaCodec.releaseOutputBuffer(outIndex, nowNs)
return
}
var targetNs = playbackStartRealtimeNs!! + (ptsUs - streamStartPtsUs!!) * 1000L
var adjustedNowNs = System.nanoTime()
if (lastPresentationTimeUs != Long.MIN_VALUE && ptsUs < lastPresentationTimeUs) {
// PTS went backwards (e.g. codec reordering). Re-base the clock to avoid negative deltas.
streamStartPtsUs = ptsUs
playbackStartRealtimeNs = adjustedNowNs
targetNs = adjustedNowNs
}
if (lastFrameReleaseTimeNs != Long.MIN_VALUE) {
// Ensure we never schedule two frames closer together than the min spacing.
targetNs = max(targetNs, lastFrameReleaseTimeNs + MIN_FRAME_SPACING_NS)
}
adjustedNowNs = System.nanoTime()
val latenessNs = adjustedNowNs - targetNs
if (latenessNs >= FRAME_DROP_THRESHOLD_NS) {
// Frame is critically late; drop to keep playback responsive.
mediaCodec.releaseOutputBuffer(outIndex, false)
lastFrameReleaseTimeNs = adjustedNowNs
return
}
var correctedTargetNs = targetNs
if (latenessNs > 0) {
// For mild lateness, shift the playback baseline forward so future frames stay aligned.
val correction = minOf(latenessNs, FRAME_DROP_THRESHOLD_NS)
playbackStartRealtimeNs = playbackStartRealtimeNs?.plus(correction)
correctedTargetNs += correction
}
if (correctedTargetNs <= adjustedNowNs + RENDER_EARLY_MARGIN_NS) {
// Already at/behind the target time: render immediately using the current VSYNC.
mediaCodec.releaseOutputBuffer(outIndex, true)
lastFrameReleaseTimeNs = adjustedNowNs
} else {
// Still early enough: hand the desired release timestamp to MediaCodec for VSYNC alignment.
mediaCodec.releaseOutputBuffer(outIndex, correctedTargetNs)
lastFrameReleaseTimeNs = correctedTargetNs
}
lastPresentationTimeUs = ptsUs
}
override fun releaseOutputBuffer(
mediaCodec: MediaCodec,
outIndex: Int,
bufferInfo: MediaCodec.BufferInfo,
render: Boolean
) {
if (DEBUG) Log.v(TAG, "releaseOutputBuffer(outIndex=$outIndex, render=$render)")
if (!render || !surface.isValid) {
mediaCodec.releaseOutputBuffer(outIndex, false)
return
}
if (!hasVideoFrameRateStabilization()) {
mediaCodec.releaseOutputBuffer(outIndex, true)
} else {
releaseOutputBufferWithFrameRateStabilization(mediaCodec, outIndex, bufferInfo)
}
}
override fun decoderDestroyed(mediaCodec: MediaCodec) {
if (DEBUG) Log.v(TAG, "decoderDestroyed()")
resetFrameTiming()
}
private fun resetFrameTiming() {
if (DEBUG) Log.v(TAG, "resetFrameTiming()")
streamStartPtsUs = null
playbackStartRealtimeNs = null
lastFrameReleaseTimeNs = Long.MIN_VALUE
lastPresentationTimeUs = Long.MIN_VALUE
}
companion object {
private val FRAME_DROP_THRESHOLD_NS = TimeUnit.MILLISECONDS.toNanos(80)
private val MIN_FRAME_SPACING_NS = TimeUnit.MILLISECONDS.toNanos(1)
private val RENDER_EARLY_MARGIN_NS = TimeUnit.MILLISECONDS.toNanos(2)
}
}
@@ -0,0 +1,189 @@
package com.alexvas.rtsp.parser;
import android.annotation.SuppressLint;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media3.common.util.ParsableBitArray;
import androidx.media3.common.util.ParsableByteArray;
// https://tools.ietf.org/html/rfc3640
// +---------+-----------+-----------+---------------+
// | RTP | AU Header | Auxiliary | Access Unit |
// | Header | Section | Section | Data Section |
// +---------+-----------+-----------+---------------+
//
// <----------RTP Packet Payload----------->
@SuppressLint("UnsafeOptInUsageError")
public class AacParser extends AudioParser {
private static final String TAG = AacParser.class.getSimpleName();
private static final boolean DEBUG = false;
private final ParsableBitArray headerScratchBits;
private final ParsableByteArray headerScratchBytes;
private static final int MODE_LBR = 0;
private static final int MODE_HBR = 1;
// Number of bits for AAC AU sizes, indexed by mode (LBR and HBR)
private static final int[] NUM_BITS_AU_SIZES = {6, 13};
// Number of bits for AAC AU index(-delta), indexed by mode (LBR and HBR)
private static final int[] NUM_BITS_AU_INDEX = {2, 3};
// Frame Sizes for AAC AU fragments, indexed by mode (LBR and HBR)
private static final int[] FRAME_SIZES = {63, 8191};
private final int _aacMode;
private boolean completeFrameIndicator = true;
public AacParser(@NonNull String aacMode) {
_aacMode = aacMode.equalsIgnoreCase("AAC-lbr") ? MODE_LBR : MODE_HBR;
headerScratchBits = new ParsableBitArray();
headerScratchBytes = new ParsableByteArray();
}
@Override
@Nullable
public byte[] processRtpPacketAndGetSample(@NonNull byte[] data, int length) {
if (DEBUG)
Log.v(TAG, "processRtpPacketAndGetSample(length=" + length + ")");
int auHeadersCount = 1;
int numBitsAuSize = NUM_BITS_AU_SIZES[_aacMode];
int numBitsAuIndex = NUM_BITS_AU_INDEX[_aacMode];
ParsableByteArray packet = new ParsableByteArray(data, length);
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
// |AU-headers-length|AU-header|AU-header| |AU-header|padding|
// | | (1) | (2) | | (n) | bits |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
int auHeadersLength = packet.readShort();//((data[0] & 0xFF) << 8) | (data[1] & 0xFF);
int auHeadersLengthBytes = (auHeadersLength + 7) / 8;
headerScratchBytes.reset(auHeadersLengthBytes);
packet.readBytes(headerScratchBytes.getData(), 0, auHeadersLengthBytes);
headerScratchBits.reset(headerScratchBytes.getData());
int bitsAvailable = auHeadersLength - (numBitsAuSize + numBitsAuIndex);
if (bitsAvailable > 0) {// && (numBitsAuSize + numBitsAuSize) > 0) {
auHeadersCount += bitsAvailable / (numBitsAuSize + numBitsAuIndex);
}
if (auHeadersCount == 1) {
int auSize = headerScratchBits.readBits(numBitsAuSize);
int auIndex = headerScratchBits.readBits(numBitsAuIndex);
if (completeFrameIndicator) {
if (auIndex == 0) {
if (packet.bytesLeft() == auSize) {
return handleSingleAacFrame(packet);
} else {
// handleFragmentationAacFrame(packet, auSize);
}
}
} else {
// handleFragmentationAacFrame(packet, auSize);
}
} else {
if (completeFrameIndicator) {
// handleMultipleAacFrames(packet, auHeadersLength);
}
}
// byte[] auHeader = new byte[length-2-auHeadersLengthBytes];
// System.arraycopy(data,2-auHeadersLengthBytes, auHeader,0, auHeader.length);
// if (DEBUG)
// Log.d(TAG, "AU headers size: " + auHeadersLengthBytes + ", AU headers: " + auHeadersCount + ", sample length: " + auHeader.length);
// return auHeader;
return new byte[0];
}
private byte[] handleSingleAacFrame(ParsableByteArray packet) {
int length = packet.bytesLeft();
byte[] data = new byte[length];
System.arraycopy(packet.getData(), packet.getPosition(), data,0, data.length);
return data;
}
// private static final class AUHeader {
// private int size;
// private int index;
//
// public AUHeader(int size, int index) {
// this.size = size;
// this.index = index;
// }
//
// public int size() { return size; }
//
// public int index() { return index; }
// }
// /**
// * Stores the consecutive fragment AU to reconstruct an AAC-Frame
// */
// private static final class FragmentedAacFrame {
// public byte[] auData;
// public int auLength;
// public int auSize;
//
// private int sequence;
//
// public FragmentedAacFrame(int frameSize) {
// // Initialize data
// auData = new byte[frameSize];
// sequence = -1;
// }
//
// /**
// * Resets the buffer, clearing any data that it holds.
// */
// public void reset() {
// auLength = 0;
// auSize = 0;
// sequence = -1;
// }
//
// public void sequence(int sequence) {
// this.sequence = sequence;
// }
//
// public int sequence() {
// return sequence;
// }
//
// /**
// * Called to add a fragment unit to fragmented AU.
// *
// * @param fragment Holds the data of fragment unit being passed.
// * @param offset The offset of the data in {@code fragment}.
// * @param limit The limit (exclusive) of the data in {@code fragment}.
// */
// public void appendFragment(byte[] fragment, int offset, int limit) {
// if (auSize == 0) {
// auSize = limit;
// } else if (auSize != limit) {
// reset();
// }
//
// if (auData.length < auLength + limit) {
// auData = Arrays.copyOf(auData, (auLength + limit) * 2);
// }
//
// System.arraycopy(fragment, offset, auData, auLength, limit);
// auLength += limit;
// }
//
// public boolean isCompleted() {
// return auSize == auLength;
// }
// }
}
@@ -0,0 +1,8 @@
package com.alexvas.rtsp.parser
abstract class AudioParser {
abstract fun processRtpPacketAndGetSample(
data: ByteArray,
length: Int
): ByteArray?
}
@@ -0,0 +1,11 @@
package com.alexvas.rtsp.parser
class G711Parser() : AudioParser() {
override fun processRtpPacketAndGetSample(
data: ByteArray,
length: Int
): ByteArray? {
val g711Payload = data.copyOfRange(0, length)
return g711Payload
}
}
@@ -0,0 +1,138 @@
package com.alexvas.rtsp.parser
import android.util.Log
import com.alexvas.utils.VideoCodecUtils
import com.alexvas.utils.VideoCodecUtils.getH264NalUnitTypeString
import java.io.ByteArrayOutputStream
class RtpH264Parser: RtpParser() {
private var stream = ByteArrayOutputStream()
override fun processRtpPacketAndGetNalUnit(data: ByteArray, length: Int, marker: Boolean): ByteArray? {
if (DEBUG) Log.v(TAG, "processRtpPacketAndGetNalUnit(data.size=${data.size}, length=$length, marker=$marker)")
val nalType = (data[0].toInt() and 0x1F).toByte()
val packFlag = data[1].toInt() and 0xC0
var nalUnit: ByteArray? = null
if (DEBUG)
Log.d(TAG, "\t\tNAL type: ${getH264NalUnitTypeString(nalType)}, pack flag: 0x${Integer.toHexString(packFlag).lowercase()}")
when (nalType) {
VideoCodecUtils.NAL_STAP_A, VideoCodecUtils.NAL_STAP_B -> {
// Not supported
}
VideoCodecUtils.NAL_MTAP16, VideoCodecUtils.NAL_MTAP24 -> {
// Not supported
}
VideoCodecUtils.NAL_FU_A -> {
when (packFlag) {
0x80 -> {
addStartFragmentedPacket(data, length)
}
0x00 -> {
if (marker) {
// Sometimes 0x40 end packet is not arrived. Use marker bit in this case
// to finish fragmented packet.
nalUnit = addEndFragmentedPacketAndCombine(data, length)
} else {
addMiddleFragmentedPacket(data, length)
}
}
0x40 -> {
nalUnit = addEndFragmentedPacketAndCombine(data, length)
}
}
}
VideoCodecUtils.NAL_FU_B -> {
// Not supported
}
else -> {
nalUnit = processSingleFramePacket(data, length)
clearFragmentedBuffer()
if (DEBUG) Log.d(TAG, "Single NAL (${nalUnit.size})")
}
}
nalUnit?.let { stream.write(it) }
if (marker) {
val result = stream.toByteArray()
stream = ByteArrayOutputStream()
return result
}
return null
}
private fun addStartFragmentedPacket(data: ByteArray, length: Int) {
if (DEBUG) Log.v(TAG, "addStartFragmentedPacket(data.size=${data.size}, length=$length)")
fragmentedPackets = 0
fragmentedBufferLength = length - 1
fragmentedBuffer[0] = ByteArray(fragmentedBufferLength).apply {
this[0] = ((data[0].toInt() and 0xE0) or (data[1].toInt() and 0x1F)).toByte()
}
System.arraycopy(data, 2, fragmentedBuffer[0]!!, 1, length - 2)
}
private fun addMiddleFragmentedPacket(data: ByteArray, length: Int) {
if (DEBUG) Log.v(TAG, "addMiddleFragmentedPacket(data.size=${data.size}, length=$length)")
fragmentedPackets++
if (fragmentedPackets >= fragmentedBuffer.size) {
Log.e(TAG, "Too many middle packets. No NAL FU_A end packet received. Skipped RTP packet.")
fragmentedBuffer[0] = null
} else {
fragmentedBufferLength += length - 2
fragmentedBuffer[fragmentedPackets] = ByteArray(length - 2)
System.arraycopy(data, 2, fragmentedBuffer[fragmentedPackets]!!, 0, length - 2)
}
}
private fun addEndFragmentedPacketAndCombine(data: ByteArray, length: Int): ByteArray? {
if (DEBUG) Log.v(TAG, "addEndFragmentedPacketAndCombine(data.size=${data.size}, length=$length)")
var nalUnit: ByteArray? = null
var tmpLen: Int
if (fragmentedBuffer[0] == null) {
Log.e(TAG, "No NAL FU_A start packet received. Skipped RTP packet.")
} else {
nalUnit = ByteArray(fragmentedBufferLength + length + 2)
writeNalPrefix0001(nalUnit)
tmpLen = 4
// Write start and middle packets
for (i in 0 until fragmentedPackets + 1) {
fragmentedBuffer[i]!!.apply {
System.arraycopy(
this,
0,
nalUnit,
tmpLen,
this.size
)
tmpLen += this.size
}
}
// Write end packet
System.arraycopy(data, 2, nalUnit, tmpLen, length - 2)
clearFragmentedBuffer()
if (DEBUG) Log.d(TAG, "Fragmented NAL (${nalUnit.size})")
}
return nalUnit
}
private fun clearFragmentedBuffer() {
if (DEBUG) Log.v(TAG, "clearFragmentedBuffer()")
for (i in 0 until fragmentedPackets + 1) {
fragmentedBuffer[i] = null
}
}
companion object {
private val TAG: String = RtpH264Parser::class.java.simpleName
private const val DEBUG = false
}
}
@@ -0,0 +1,139 @@
package com.alexvas.rtsp.parser
import android.util.Log
import java.io.ByteArrayOutputStream
class RtpH265Parser: RtpParser() {
private var stream = ByteArrayOutputStream()
override fun processRtpPacketAndGetNalUnit(data: ByteArray, length: Int, marker: Boolean): ByteArray? {
if (DEBUG) Log.v(TAG, "processRtpPacketAndGetNalUnit(length=$length, marker=$marker)")
// NAL Unit Header.type (RFC7798 Section 1.1.4).
val nalType = ((data[0].toInt() shr 1) and 0x3F).toByte()
var nalUnit: ByteArray? = null
// Log.d(TAG, "\t\tNAL type: ${VideoCodecUtils.getH265NalUnitTypeString(nalType)}")
if (nalType in 0..<RTP_PACKET_TYPE_AP) {
nalUnit = processSingleFramePacket(data, length)
clearFragmentedBuffer()
if (DEBUG) Log.d(TAG, "Single NAL (${nalUnit.size})")
} else if (nalType == RTP_PACKET_TYPE_AP) {
// TODO: Support AggregationPacket mode.
Log.e(TAG, "need to implement processAggregationPacket")
} else if (nalType == RTP_PACKET_TYPE_FU) {
nalUnit = processFragmentationUnitPacket(data, length, marker)
} else {
Log.e(TAG, "RTP H265 payload type [${nalType}] not supported.")
}
nalUnit?.let { stream.write(it) }
if (marker) {
val result = stream.toByteArray()
stream = ByteArrayOutputStream()
return result
}
return null
}
private fun processFragmentationUnitPacket(data: ByteArray, length: Int, marker: Boolean): ByteArray? {
if (DEBUG) Log.v(TAG, "processFragmentationUnitPacket(length=$length, marker=$marker)")
val fuHeader = data[2].toInt()
val isFirstFuPacket = (fuHeader and 0x80) > 0
val isLastFuPacket = (fuHeader and 0x40) > 0
if (isFirstFuPacket) {
addStartFragmentedPacket(data, length)
} else if (isLastFuPacket || marker) {
return addEndFragmentedPacketAndCombine(data, length)
} else {
addMiddleFragmentedPacket(data, length)
}
return null
}
private fun addStartFragmentedPacket(data: ByteArray, length: Int) {
if (DEBUG) Log.v(TAG, "addStartFragmentedPacket(data.size=${data.size}, length=$length)")
fragmentedPackets = 0
fragmentedBufferLength = length - 1
fragmentedBuffer[0] = ByteArray(fragmentedBufferLength).apply {
val tid = (data[1].toInt() and 0x7)
val fuHeader = data[2].toInt()
val nalUnitType = fuHeader and 0x3F
// Convert RTP header into HEVC NAL Unit header accoding to RFC7798 Section 1.1.4.
// RTP byte 0: ignored.
// RTP byte 1: repurposed as HEVC HALU byte 0, copy NALU type.
// RTP Byte 2: repurposed as HEVC HALU byte 1, layerId required to be zero, copying only tid.
// Set data position from byte 1 as byte 0 is ignored.
this[0] = (((nalUnitType shl 1) and 0x7F).toByte())
this[1] = tid.toByte()
}
System.arraycopy(data, 3, fragmentedBuffer[0]!!, 2, length - 3)
}
private fun addMiddleFragmentedPacket(data: ByteArray, length: Int) {
if (DEBUG) Log.v(TAG, "addMiddleFragmentedPacket(data.size=${data.size}, length=$length)")
fragmentedPackets++
if (fragmentedPackets >= fragmentedBuffer.size) {
Log.e(TAG, "Too many middle packets. No RTP_PACKET_TYPE_FU end packet received. Skipped RTP packet.")
fragmentedBuffer[0] = null
} else {
fragmentedBufferLength += length - 3
fragmentedBuffer[fragmentedPackets] = ByteArray(length - 3).apply {
System.arraycopy(data, 3, this, 0, length - 3)
}
}
}
private fun addEndFragmentedPacketAndCombine(data: ByteArray, length: Int): ByteArray? {
if (DEBUG) Log.v(TAG, "addEndFragmentedPacketAndCombine(data.size=${data.size}, length=$length)")
var nalUnit: ByteArray? = null
if (fragmentedBuffer[0] == null) {
Log.e(TAG, "No NAL FU_A start packet received. Skipped RTP packet.")
} else {
nalUnit = ByteArray(fragmentedBufferLength + length + 3)
writeNalPrefix0001(nalUnit)
var tmpLen = 4
// Write start and middle packets
for (i in 0 until fragmentedPackets + 1) {
fragmentedBuffer[i]!!.apply {
System.arraycopy(
this,
0,
nalUnit,
tmpLen,
this.size
)
tmpLen += this.size
}
}
// Write end packet
System.arraycopy(data, 3, nalUnit, tmpLen, length - 3)
clearFragmentedBuffer()
if (DEBUG) Log.d(TAG, "Fragmented NAL (${nalUnit.size})")
}
return nalUnit
}
private fun clearFragmentedBuffer() {
if (DEBUG) Log.v(TAG, "clearFragmentedBuffer()")
for (i in 0 until fragmentedPackets + 1) {
fragmentedBuffer[i] = null
}
}
companion object {
private val TAG: String = RtpH265Parser::class.java.simpleName
private const val DEBUG = false
/** Aggregation Packet. RFC7798 Section 4.4.2. */
private const val RTP_PACKET_TYPE_AP: Byte = 48
/** Fragmentation Unit. RFC7798 Section 4.4.3. */
private const val RTP_PACKET_TYPE_FU: Byte = 49
}
}
@@ -0,0 +1,145 @@
package com.alexvas.rtsp.parser;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.alexvas.utils.NetUtils;
import java.io.IOException;
import java.io.InputStream;
public class RtpHeaderParser {
private static final String TAG = RtpHeaderParser.class.getSimpleName();
private static final boolean DEBUG = false;
private final static int RTP_HEADER_SIZE = 12;
public static class RtpHeader {
public int version;
public int padding;
public int extension;
public int cc;
public int marker;
public int payloadType;
public int sequenceNumber;
public long timeStamp;
public long ssrc;
public int payloadSize;
public long getTimestampMsec() {
return (long)(timeStamp * 11.111111);
}
// If RTP header found, return 4 bytes of the header
private static boolean searchForNextRtpHeader(@NonNull InputStream inputStream, @NonNull byte[] header /*out*/) throws IOException {
if (header.length < 4)
throw new IOException("Invalid allocated buffer size");
int bytesRemaining = 100000; // 100 KB max to check
boolean foundFirstByte = false;
boolean foundSecondByte = false;
byte[] oneByte = new byte[1];
// Search for {0x24, 0x00}
do {
if (bytesRemaining-- < 0)
return false;
// Read 1 byte
NetUtils.readData(inputStream, oneByte, 0, 1);
if (foundFirstByte) {
// Found 0x24. Checking for 0x00-0x02.
if (oneByte[0] == 0x00)
foundSecondByte = true;
else
foundFirstByte = false;
}
if (!foundFirstByte && oneByte[0] == 0x24) {
// Found 0x24
foundFirstByte = true;
}
} while (!foundSecondByte);
header[0] = 0x24;
header[1] = oneByte[0];
// Read 2 bytes more (packet size)
NetUtils.readData(inputStream, header, 2, 2);
return true;
}
@Nullable
private static RtpHeader parseData(@NonNull byte[] header, int packetSize) {
RtpHeader rtpHeader = new RtpHeader();
rtpHeader.version = (header[0] & 0xFF) >> 6;
if (rtpHeader.version != 2) {
if (DEBUG)
Log.e(TAG,"Not a RTP packet (" + rtpHeader.version + ")");
return null;
}
// 80 60 40 91 fd ab d4 2a
// 80 c8 00 06
rtpHeader.padding = (header[0] & 0x20) >> 5; // 0b00100100
rtpHeader.extension = (header[0] & 0x10) >> 4;
rtpHeader.marker = (header[1] & 0x80) >> 7;
rtpHeader.payloadType = header[1] & 0x7F;
rtpHeader.sequenceNumber = (header[3] & 0xFF) + ((header[2] & 0xFF) << 8);
rtpHeader.timeStamp = (header[7] & 0xFF) + ((header[6] & 0xFF) << 8) + ((header[5] & 0xFF) << 16) + ((header[4] & 0xFF) << 24) & 0xffffffffL;
rtpHeader.ssrc = (header[7] & 0xFF) + ((header[6] & 0xFF) << 8) + ((header[5] & 0xFF) << 16) + ((header[4] & 0xFF) << 24) & 0xffffffffL;
rtpHeader.payloadSize = packetSize - RTP_HEADER_SIZE;
return rtpHeader;
}
private static int getPacketSize(@NonNull byte[] header) {
int packetSize = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF);
if (DEBUG)
Log.d(TAG, "Packet size: " + packetSize);
return packetSize;
}
public void dumpHeader() {
Log.d("RTP","\t\tRTP header version: " + version
+ ", padding: " + padding
+ ", ext: " + extension
+ ", cc: " + cc
+ ", marker: " + marker
+ ", payload type: " + payloadType
+ ", seq num: " + sequenceNumber
+ ", ts: " + timeStamp
+ ", ssrc: " + ssrc
+ ", payload size: " + payloadSize);
}
}
@Nullable
public static RtpHeader readHeader(@NonNull InputStream inputStream) throws IOException {
// 24 01 00 1c 80 c8 00 06 7f 1d d2 c4
// 24 01 00 1c 80 c8 00 06 13 9b cf 60
// 24 02 01 12 80 e1 01 d2 00 07 43 f0
byte[] header = new byte[RTP_HEADER_SIZE];
// Skip 4 bytes (TCP only). No those bytes in UDP.
NetUtils.readData(inputStream, header, 0, 4);
if (DEBUG && header[0] == 0x24)
Log.d(TAG, header[1] == 0 ? "RTP packet" : "RTCP packet");
int packetSize = RtpHeader.getPacketSize(header);
if (DEBUG)
Log.d(TAG, "Packet size: " + packetSize);
if (NetUtils.readData(inputStream, header, 0, header.length) == header.length) {
RtpHeader rtpHeader = RtpHeader.parseData(header, packetSize);
if (rtpHeader == null) {
// Header not found. Possible keep-alive response. Search for another RTP header.
boolean foundHeader = RtpHeader.searchForNextRtpHeader(inputStream, header);
if (foundHeader) {
packetSize = RtpHeader.getPacketSize(header);
if (NetUtils.readData(inputStream, header, 0, header.length) == header.length)
return RtpHeader.parseData(header, packetSize);
}
} else {
return rtpHeader;
}
}
return null;
}
}
@@ -0,0 +1,27 @@
package com.alexvas.rtsp.parser
abstract class RtpParser {
abstract fun processRtpPacketAndGetNalUnit(data: ByteArray, length: Int, marker: Boolean): ByteArray?
// TODO Use already allocated buffer with RtpPacket.MAX_SIZE = 65507
// Used only for fragmented packets
protected val fragmentedBuffer = arrayOfNulls<ByteArray>(1024)
protected var fragmentedBufferLength = 0
protected var fragmentedPackets = 0
protected fun writeNalPrefix0001(buffer: ByteArray) {
buffer[0] = 0x00
buffer[1] = 0x00
buffer[2] = 0x00
buffer[3] = 0x01
}
protected fun processSingleFramePacket(data: ByteArray, length: Int): ByteArray {
return ByteArray(4 + length).apply {
writeNalPrefix0001(this)
System.arraycopy(data, 0, this, 4, length)
}
}
}
@@ -0,0 +1,24 @@
package com.alexvas.rtsp.widget
/**
* Listener for getting RTSP status update.
*/
interface RtspStatusListener {
fun onRtspStatusConnecting() {}
fun onRtspStatusConnected() {}
fun onRtspStatusDisconnecting() {}
fun onRtspStatusDisconnected() {}
fun onRtspStatusFailedUnauthorized() {}
fun onRtspStatusFailed(message: String?) {}
fun onRtspFirstFrameRendered() {}
fun onRtspFrameSizeChanged(width: Int, height: Int) {}
}
/**
* Listener for getting RTSP raw data, e.g. for recording.
*/
interface RtspDataListener {
fun onRtspDataVideoNalUnitReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {}
fun onRtspDataAudioSampleReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {}
fun onRtspDataApplicationDataReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {}
}
@@ -0,0 +1,623 @@
package com.alexvas.rtsp.widget
import android.annotation.SuppressLint
import android.media.MediaFormat
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.container.NalUnitUtil
import com.alexvas.rtsp.RtspClient
import com.alexvas.rtsp.RtspClient.SdpInfo
import com.alexvas.rtsp.codec.AudioCodecType
import com.alexvas.rtsp.codec.AudioDecodeThread
import com.alexvas.rtsp.codec.AudioFrameQueue
import com.alexvas.rtsp.codec.FrameQueue
import com.alexvas.rtsp.codec.VideoCodecType
import com.alexvas.rtsp.codec.VideoDecodeThread
import com.alexvas.rtsp.codec.VideoDecodeThread.DecoderType
import com.alexvas.rtsp.codec.VideoDecodeThread.VideoDecoderListener
import com.alexvas.rtsp.codec.VideoFrameQueue
import com.alexvas.utils.NetUtils
import com.alexvas.utils.VideoCodecUtils
import org.jcodec.codecs.h264.io.model.SeqParameterSet
import org.jcodec.codecs.h264.io.model.VUIParameters
import java.net.Socket
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
class RtspProcessor(
private var onVideoDecoderCreateRequested: ((
videoMimeType: String,
videoRotation: Int, // 0, 90, 180, 270
videoFrameQueue: VideoFrameQueue,
videoDecoderListener: VideoDecoderListener,
videoDecoderType: DecoderType,
videoFrameRateStabilization: Boolean,
) -> VideoDecodeThread)
) {
class Statistics {
var videoDecoderType = DecoderType.HARDWARE
var videoDecoderName: String? = null
var videoDecoderLatencyMsec = -1
var networkLatencyMsec = -1
}
private lateinit var uri: Uri
private var username: String? = null
private var password: String? = null
private var userAgent: String? = null
private var requestVideo = true
private var requestAudio = true
private var requestApplication = false
private var rtspThread: RtspThread? = null
// 本地修改(相对上游):队列容量 60 -> 20,压缩解码侧缓冲。解码线程无播放时钟、
// 按最快速度取帧,队列正常情况下近乎为空,容量仅作抖动兜底,调小不丢帧但压低积压延迟。
private var videoFrameQueue = VideoFrameQueue(20)
private var audioFrameQueue = AudioFrameQueue(10)
private var videoDecodeThread: VideoDecodeThread? = null
private var audioDecodeThread: AudioDecodeThread? = null
private val uiHandler = Handler(Looper.getMainLooper())
private var videoMimeType: String = "video/avc"
private var audioMimeType: String = ""
private var audioSampleRate: Int = 0
private var audioChannelCount: Int = 0
private var audioCodecConfig: ByteArray? = null
private var firstFrameRendered = false
var statistics = Statistics()
get() {
videoDecodeThread?.let { decoder ->
field.apply {
networkLatencyMsec = decoder.getCurrentNetworkLatencyMsec()
videoDecoderLatencyMsec = decoder.getCurrentVideoDecoderLatencyMsec()
videoDecoderType = decoder.getCurrentVideoDecoderType()
videoDecoderName = decoder.getCurrentVideoDecoderName()
}
}
return field
}
private set
/** Read and connect timeout for socket in msec. */
private var socketTimeoutMsec: Int = 5000
/**
* Show more debug info on console on runtime.
*/
var debug = false
/**
* Video rotation in degrees. Allowed values: 0, 90, 180, 270.
* Note that not all hardware video decoders support rotation.
*/
var videoRotation = 0
set(value) {
if (value == 0 || value == 90 || value == 180 || value == 270)
field = value
}
/**
* Requested video decoder type.
*/
var videoDecoderType = DecoderType.HARDWARE
/**
* Try to modify SPS frame coming from camera with low-latency parameters to decrease video
* decoding latency.
* If SPS frame param num_ref_frames is equal to 1 or more, set it to 0. That should decrease
* decoder latency by 2x times on some hardware decoders.
*/
var experimentalUpdateSpsFrameWithLowLatencyParams = false
/**
* Enables the playback smoothing logic inside the video decoder.
*/
var videoFrameRateStabilization: Boolean = false
set(value) {
field = value
videoDecodeThread?.setVideoFrameRateStabilization(value)
}
/**
* Status listener for getting RTSP event updates.
*/
var statusListener: RtspStatusListener? = null
/**
* Listener for getting raw data, e.g. for recording.
*/
var dataListener: RtspDataListener? = null
private val proxyClientListener = object: RtspClient.RtspClientListener {
override fun onRtspConnecting() {
if (DEBUG) Log.v(TAG, "onRtspConnecting()")
uiHandler.post {
statusListener?.onRtspStatusConnecting()
}
}
override fun onRtspConnected(sdpInfo: SdpInfo) {
if (DEBUG) Log.v(TAG, "onRtspConnected()")
if (sdpInfo.videoTrack != null) {
videoFrameQueue.clear()
when (sdpInfo.videoTrack?.videoCodec) {
RtspClient.VIDEO_CODEC_H264 -> videoMimeType = MediaFormat.MIMETYPE_VIDEO_AVC
RtspClient.VIDEO_CODEC_H265 -> videoMimeType = MediaFormat.MIMETYPE_VIDEO_HEVC
}
when (sdpInfo.audioTrack?.audioCodec) {
RtspClient.AUDIO_CODEC_AAC -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_AAC
RtspClient.AUDIO_CODEC_OPUS -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_OPUS
RtspClient.AUDIO_CODEC_G711_ULAW -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_G711_MLAW
RtspClient.AUDIO_CODEC_G711_ALAW -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_G711_ALAW
}
val sps: ByteArray? = sdpInfo.videoTrack?.sps
val pps: ByteArray? = sdpInfo.videoTrack?.pps
// Initialize decoder
@SuppressLint("UnsafeOptInUsageError")
if (sps != null && pps != null) {
// 本地修改(相对上游):H.265 的 CSD 参数集顺序必须为 VPS→SPS→PPSISO/IEC 14496-15),
// 上游拼接顺序 sps+pps+vps 会导致 H.265 起播黑屏。H.264 无 VPS,顺序不变。
val vps: ByteArray = sdpInfo.videoTrack?.vps ?: ByteArray(0)
val data = ByteArray(sps.size + pps.size + vps.size)
var offset = 0
vps.copyInto(data, offset, 0, vps.size)
offset += vps.size
sps.copyInto(data, offset, 0, sps.size)
offset += sps.size
pps.copyInto(data, offset, 0, pps.size)
offset += pps.size
videoFrameQueue.push(
FrameQueue.VideoFrame(
// 本地修改(相对上游):按实际 MIME 标记 codecType,上游硬编码 H264
// 会导致 H.265 流无法从 SPS 解析宽高(解码线程按 codecType 决定解析方式)。
if (videoMimeType == MediaFormat.MIMETYPE_VIDEO_HEVC) VideoCodecType.H265 else VideoCodecType.H264,
isKeyframe = true,
data,
0,
data.size,
0
)
)
try {
val startNalOffset = if (sps[3] == 1.toByte()) 5 else 4
val spsData = NalUnitUtil.parseSpsNalUnitPayload(
data, startNalOffset, data.size - startNalOffset)
if (spsData.maxNumReorderFrames > 0) {
Log.w(
TAG, "SPS frame param max_num_reorder_frames=" +
"${spsData.maxNumReorderFrames} is too high" +
" for low latency decoding (expecting 0)."
)
}
if (debug) {
Log.d(TAG, "SPS frame: ${sps.toHexString(0, sps.size)}")
Log.d(TAG, "\t${spsData.spsDataToString()}")
Log.d(TAG, "PPS frame: ${pps.toHexString(0, pps.size)}")
if (vps.isNotEmpty())
Log.d(TAG, "VPS frame: ${vps.toHexString(0, vps.size)}")
}
} catch (e: Exception) {
e.printStackTrace()
}
} else {
if (DEBUG) Log.d(TAG, "RTSP SPS and PPS NAL units missed in SDP")
}
}
if (sdpInfo.audioTrack != null) {
audioFrameQueue.clear()
when (sdpInfo.audioTrack?.audioCodec) {
RtspClient.AUDIO_CODEC_AAC -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_AAC
RtspClient.AUDIO_CODEC_OPUS -> audioMimeType = MediaFormat.MIMETYPE_AUDIO_OPUS
}
audioSampleRate = sdpInfo.audioTrack?.sampleRateHz!!
audioChannelCount = sdpInfo.audioTrack?.channels!!
audioCodecConfig = sdpInfo.audioTrack?.config
}
onRtspClientConnected()
uiHandler.post {
statusListener?.onRtspStatusConnected()
}
}
private var framesPerGop = 0
override fun onRtspVideoNalUnitReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
if (DEBUG) Log.v(TAG, "onRtspVideoNalUnitReceived(data.size=${data.size}, length=$length, timestamp=$timestamp)")
val isH265 = videoMimeType == MediaFormat.MIMETYPE_VIDEO_HEVC
// Search for NAL_IDR_SLICE within first 1KB maximum
val isKeyframe = VideoCodecUtils.isAnyKeyFrame(data, offset, min(length, 1000), isH265)
var videoFrame = FrameQueue.VideoFrame(
VideoCodecType.H264,
isKeyframe,
data,
offset,
length,
timestamp,
capturedTimestampMs = System.currentTimeMillis()
)
if (isKeyframe && experimentalUpdateSpsFrameWithLowLatencyParams) {
videoFrame = getNewLowLatencyFrameFromKeyFrame(videoFrame)
}
if (debug) {
nalUnitsFound.clear()
VideoCodecUtils.getNalUnits(videoFrame.data, videoFrame.offset, videoFrame.length, nalUnitsFound, isH265)
var b = StringBuilder()
for (nal in nalUnitsFound) {
b
.append(if (isH265)
VideoCodecUtils.getH265NalUnitTypeString(nal.type)
else
VideoCodecUtils.getH264NalUnitTypeString(nal.type))
.append(" (${nal.length}), ")
}
if (b.length > 2)
b = b.removeRange(b.length - 2, b.length) as StringBuilder
Log.d(TAG, "NALs: $b")
@SuppressLint("UnsafeOptInUsageError")
if (isKeyframe) {
val sps = VideoCodecUtils.getSpsNalUnitFromArray(
videoFrame.data,
videoFrame.offset,
// Check only first 100 bytes maximum. That's enough for finding SPS NAL unit.
Integer.min(videoFrame.length, VideoCodecUtils.MAX_NAL_SPS_SIZE),
isH265
)
Log.d(TAG,
"\tKey frame received (${videoFrame.length} bytes, ts=$timestamp," +
" ${sps?.width}x${sps?.height}," +
" GoP=$framesPerGop," +
" profile=${sps?.profileIdc}, level=${sps?.levelIdc})")
framesPerGop = 0
} else {
framesPerGop++
}
}
videoFrameQueue.push(videoFrame)
dataListener?.onRtspDataVideoNalUnitReceived(
videoFrame.data,
videoFrame.offset,
videoFrame.length,
timestamp)
}
override fun onRtspAudioSampleReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
if (DEBUG) Log.v(TAG, "onRtspAudioSampleReceived(length=$length, timestamp=$timestamp)")
if (length > 0) {
audioFrameQueue.push(
FrameQueue.AudioFrame(
AudioCodecType.AAC_LC,
data, offset,
length,
timestamp
)
)
}
dataListener?.onRtspDataAudioSampleReceived(data, offset, length, timestamp)
}
override fun onRtspApplicationDataReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
if (DEBUG) Log.v(TAG, "onRtspApplicationDataReceived(length=$length, timestamp=$timestamp)")
dataListener?.onRtspDataApplicationDataReceived(data, offset, length, timestamp)
}
override fun onRtspDisconnecting() {
if (DEBUG) Log.v(TAG, "onRtspDisconnecting()")
uiHandler.post {
statusListener?.onRtspStatusDisconnecting()
}
}
override fun onRtspDisconnected() {
if (DEBUG) Log.v(TAG, "onRtspDisconnected()")
uiHandler.post {
statusListener?.onRtspStatusDisconnected()
}
}
override fun onRtspFailedUnauthorized() {
if (DEBUG) Log.v(TAG, "onRtspFailedUnauthorized()")
uiHandler.post {
statusListener?.onRtspStatusFailedUnauthorized()
}
}
override fun onRtspFailed(message: String?) {
if (DEBUG) Log.v(TAG, "onRtspFailed(message='$message')")
uiHandler.post {
statusListener?.onRtspStatusFailed(message)
}
}
}
inner class RtspThread: Thread() {
private var rtspStopped = AtomicBoolean(false)
fun stopAsync() {
if (DEBUG) Log.v(TAG, "stopAsync()")
rtspStopped.set(true)
// Wake up sleep() code
interrupt()
}
override fun run() {
onRtspClientStarted()
val port = if (uri.port == -1) DEFAULT_RTSP_PORT else uri.port
var socket: Socket? = null
try {
if (DEBUG) Log.d(TAG, "Connecting to ${uri.host.toString()}:$port...")
socket = if (uri.scheme?.lowercase() == "rtsps")
NetUtils.createSslSocketAndConnect(
uri.host.toString(),
port,
socketTimeoutMsec
)
else
NetUtils.createSocketAndConnect(
uri.host.toString(),
port,
socketTimeoutMsec
)
// Blocking call until stopped variable is true or connection failed
val rtspClient = RtspClient.Builder(socket, uri.toString(), rtspStopped, proxyClientListener)
.requestVideo(requestVideo)
.requestAudio(requestAudio)
.requestApplication(requestApplication)
.withDebug(debug)
.withUserAgent(userAgent)
.withCredentials(username, password)
.build()
rtspClient.execute()
} catch (e: Exception) {
e.printStackTrace()
uiHandler.post { proxyClientListener.onRtspFailed(e.message) }
} finally {
NetUtils.closeSocket(socket)
}
onRtspClientStopped()
}
}
private val videoDecoderListener = object: VideoDecoderListener {
override fun onVideoDecoderStarted() {
if (DEBUG) Log.v(TAG, "onVideoDecoderStarted()")
}
override fun onVideoDecoderStopped() {
if (DEBUG) Log.v(TAG, "onVideoDecoderStopped()")
}
override fun onVideoDecoderFailed(message: String?) {
if (DEBUG) Log.e(TAG, "onVideoDecoderFailed(message='$message')")
}
override fun onVideoDecoderFormatChanged(width: Int, height: Int) {
if (DEBUG) Log.v(TAG, "onVideoDecoderFormatChanged(width=$width, height=$height)")
statusListener?.onRtspFrameSizeChanged(width, height)
}
override fun onVideoDecoderFirstFrameRendered() {
if (DEBUG) Log.v(TAG, "onVideoDecoderFirstFrameDecoded()")
if (!firstFrameRendered) statusListener?.onRtspFirstFrameRendered()
firstFrameRendered = true
}
}
private fun onRtspClientStarted() {
if (DEBUG) Log.v(TAG, "onRtspClientStarted()")
// uiHandler.post { statusListener?.onRtspStatusConnected() }
}
private fun onRtspClientConnected() {
if (DEBUG) Log.v(TAG, "onRtspClientConnected()")
if (videoMimeType.isNotEmpty()) {
firstFrameRendered = false
Log.i(TAG, "Starting video decoder with mime type \"$videoMimeType\"")
videoDecodeThread = onVideoDecoderCreateRequested.invoke(
videoMimeType,
videoRotation,
videoFrameQueue,
videoDecoderListener,
videoDecoderType,
videoFrameRateStabilization,
)
videoDecodeThread!!.apply {
name = "RTSP video thread [${getUriName()}]"
start()
}
}
if (audioMimeType.isNotEmpty() /*&& checkAudio!!.isChecked*/) {
Log.i(TAG, "Starting audio decoder with mime type \"$audioMimeType\"")
audioDecodeThread = AudioDecodeThread(
audioMimeType, audioSampleRate, audioChannelCount, audioCodecConfig, audioFrameQueue)
audioDecodeThread!!.apply {
name = "RTSP audio thread [${getUriName()}]"
start()
}
}
}
private fun onRtspClientStopped() {
if (DEBUG) Log.v(TAG, "onRtspClientStopped()")
stopDecoders()
rtspThread = null
// uiHandler.post { statusListener?.onRtspStatusDisconnected() }
}
fun init(uri: Uri, username: String?, password: String?, userAgent: String? = null, socketTimeout: Int = DEFAULT_SOCKET_TIMEOUT) {
if (DEBUG) Log.v(TAG, "init(uri='$uri', username='$username', password='$password', userAgent='$userAgent', socketTimeout=$socketTimeout)")
this.uri = uri
this.username = username
this.password = password
this.userAgent = userAgent
this.socketTimeoutMsec = socketTimeout
}
fun start(requestVideo: Boolean, requestAudio: Boolean, requestApplication: Boolean = false) {
if (DEBUG) Log.v(TAG, "start(requestVideo=$requestVideo, requestAudio=$requestAudio, requestApplication=$requestApplication)")
if (rtspThread != null) rtspThread?.stopAsync()
this.requestVideo = requestVideo
this.requestAudio = requestAudio
this.requestApplication = requestApplication
rtspThread = RtspThread().apply {
name = "RTSP IO thread [${getUriName()}]"
start()
}
}
fun stop() {
if (DEBUG) Log.v(TAG, "stop()")
rtspThread?.stopAsync()
rtspThread = null
}
fun isStarted(): Boolean {
return rtspThread != null
}
fun stopDecoders() {
if (DEBUG) Log.v(TAG, "stopDecoders()")
videoDecodeThread?.stopAsync()
videoDecodeThread = null
audioDecodeThread?.stopAsync()
audioDecodeThread = null
}
// Cached values
private val nalUnitsFound = ArrayList<VideoCodecUtils.NalUnit>()
private val spsBufferReadFrame = ByteBuffer.allocate(VideoCodecUtils.MAX_NAL_SPS_SIZE)
private val spsBufferWriteFrame = ByteBuffer.allocate(VideoCodecUtils.MAX_NAL_SPS_SIZE)
/**
* Try to get a new frame keyframe (SPS+PPS+IDR) with low latency modified SPS frame.
* If modification failed, original frame will be returned.
* Inspired by https://webrtc.googlesource.com/src/+/refs/heads/main/common_video/h264/sps_vui_rewriter.cc#400
*/
private fun getNewLowLatencyFrameFromKeyFrame(frame: FrameQueue.VideoFrame): FrameQueue.VideoFrame {
try {
// Support only H264 for now
if (frame.codecType == VideoCodecType.H265)
return frame
nalUnitsFound.clear()
VideoCodecUtils.getNalUnits(frame.data, frame.offset, frame.length, nalUnitsFound, isH265 = false)
val oldSpsNalUnit = nalUnitsFound.firstOrNull { it.type == VideoCodecUtils.NAL_SPS }
// SPS frame not found. Return original frame.
if (oldSpsNalUnit == null)
return frame
spsBufferReadFrame.apply {
rewind()
put(frame.data, oldSpsNalUnit.offset + 5,
Integer.min(oldSpsNalUnit.length, VideoCodecUtils.MAX_NAL_SPS_SIZE)
)
rewind()
}
// Read SPS frame
val spsSet = SeqParameterSet.read(spsBufferReadFrame)
// adding VUI might decrease latency for some streams, if max_dec_frame_buffering is set properly
// https://community.intel.com/t5/Media-Intel-oneAPI-Video/h-264-decoder-gives-two-frames-latency-while-decoding-a-stream/td-p/1099694
// https://github.com/Consti10/LiveVideo10ms/blob/master/VideoCore/src/main/cpp/NALU/H26X.hpp
fun modifyVui() {
// spsSet.vuiParams = VUIParameters()
spsSet.vuiParams.apply {
// videoSignalTypePresentFlag = true
// videoFormat = 5
// colourDescriptionPresentFlag = true
// matrixCoefficients = 5
// timingInfoPresentFlag = true
// numUnitsInTick = 1
// timeScale = 120
// fixedFrameRateFlag = true
bitstreamRestriction = VUIParameters.BitstreamRestriction().apply {
// motionVectorsOverPicBoundariesFlag = true
// log2MaxMvLengthHorizontal = 16
// log2MaxMvLengthVertical = 16
maxDecFrameBuffering = 1
numReorderFrames = 0
}
}
}
modifyVui()
// Write SPS frame
spsBufferWriteFrame.rewind()
spsSet.write(spsBufferWriteFrame)
val newSpsNalUnitSize = spsBufferWriteFrame.position()
if (oldSpsNalUnit.length > -1) {
val newSize = frame.length - oldSpsNalUnit.length + newSpsNalUnitSize
val newData = ByteArray(newSize + 5)
var newDataOffset = 0
for (nalUnit in nalUnitsFound) {
when (nalUnit.type) {
VideoCodecUtils.NAL_SPS -> {
// Write NAL header + SPS frame type
val b = byteArrayOf(0x00, 0x00, 0x00, 0x01, 0x27)
b.copyInto(newData, newDataOffset, 0, b.size)
newDataOffset += b.size
// Write SPS frame body
spsBufferWriteFrame.apply {
rewind()
get(newData, newDataOffset, newSpsNalUnitSize)
}
newDataOffset += newSpsNalUnitSize
}
else -> {
frame.data.copyInto(
newData,
newDataOffset,
nalUnit.offset,
nalUnit.offset + nalUnit.length
)
newDataOffset += nalUnit.length
}
}
}
// Create SPS+PPS+IDR frame with newly modified SPS frame data
return FrameQueue.VideoFrame(
frame.codecType,
frame.isKeyframe,
newData,
0,
newData.size,
frame.timestampMs,
frame.capturedTimestampMs
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create low-latency keyframe", e)
}
return frame
}
private fun getUriName(): String {
val port = if (uri.port == -1) DEFAULT_RTSP_PORT else uri.port
return "${uri.host.toString()}:$port"
}
companion object {
private val TAG: String = RtspProcessor::class.java.simpleName
private const val DEBUG = false
private const val DEFAULT_RTSP_PORT = 554
const val DEFAULT_SOCKET_TIMEOUT = 5000
}
}
@@ -0,0 +1,190 @@
package com.alexvas.rtsp.widget
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.container.NalUnitUtil
import com.alexvas.rtsp.codec.VideoDecodeThread.DecoderType
import com.alexvas.rtsp.codec.VideoDecoderSurfaceThread
import com.alexvas.rtsp.widget.RtspProcessor.Statistics
import com.limelight.binding.video.MediaCodecHelper
/**
* Low latency RTSP stream playback on surface view.
*/
open class RtspSurfaceView: SurfaceView {
private var surfaceWidth = 1920
private var surfaceHeight = 1080
private var rtspProcessor = RtspProcessor(
onVideoDecoderCreateRequested = {
videoMimeType,
videoRotation,
videoFrameQueue,
videoDecoderListener,
videoDecoderType,
videoFrameRateStabilization,
->
VideoDecoderSurfaceThread(
holder.surface,
videoMimeType,
surfaceWidth,
surfaceHeight,
videoRotation,
videoFrameQueue,
videoDecoderListener,
videoDecoderType,
videoFrameRateStabilization,
)
}
)
var statistics = Statistics()
get() = rtspProcessor.statistics
private set
var videoRotation: Int
get() = rtspProcessor.videoRotation
set(value) { rtspProcessor.videoRotation = value }
var videoDecoderType: DecoderType
get() = rtspProcessor.videoDecoderType
set(value) { rtspProcessor.videoDecoderType = value }
var experimentalUpdateSpsFrameWithLowLatencyParams: Boolean
get() = rtspProcessor.experimentalUpdateSpsFrameWithLowLatencyParams
set(value) { rtspProcessor.experimentalUpdateSpsFrameWithLowLatencyParams = value }
var debug: Boolean
get() = rtspProcessor.debug
set(value) { rtspProcessor.debug = value }
/** Enables decoder-side playback smoothing. Disabled by default. */
var videoFrameRateStabilization: Boolean
get() = rtspProcessor.videoFrameRateStabilization
set(value) { rtspProcessor.videoFrameRateStabilization = value }
private val surfaceCallback = object: SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
if (DEBUG) Log.v(TAG, "surfaceCreated()")
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (DEBUG) Log.v(TAG, "surfaceChanged(format=$format, width=$width, height=$height)")
surfaceWidth = width
surfaceHeight = height
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (DEBUG) Log.v(TAG, "surfaceDestroyed()")
rtspProcessor.stopDecoders()
}
}
constructor(context: Context) : super(context) {
initView(context, null, 0)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
initView(context, attrs, 0)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initView(context, attrs, defStyleAttr)
}
private fun initView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
if (DEBUG) Log.v(TAG, "initView()")
MediaCodecHelper.initialize(context, /*glRenderer*/ "")
holder.addCallback(surfaceCallback)
}
fun init(
uri: Uri,
username: String? = null,
password: String? = null,
userAgent: String? = null,
socketTimeout: Int? = null
) {
if (DEBUG) Log.v(TAG, "init(uri='$uri', username='$username', password='$password', userAgent='$userAgent', socketTimeout=$socketTimeout)")
rtspProcessor.init(
uri,
username,
password,
userAgent,
socketTimeout ?: RtspProcessor.DEFAULT_SOCKET_TIMEOUT
)
}
/**
* Start RTSP client.
*
* @param requestVideo request video track
* @param requestAudio request audio track
* @param requestApplication request application track
* @see https://datatracker.ietf.org/doc/html/rfc4566#section-5.14
*/
fun start(requestVideo: Boolean, requestAudio: Boolean, requestApplication: Boolean = false) {
if (DEBUG) Log.v(TAG, "start(requestVideo=$requestVideo, requestAudio=$requestAudio, requestApplication=$requestApplication)")
rtspProcessor.start(requestVideo, requestAudio, requestApplication)
}
/**
* Stop RTSP client.
*/
fun stop() {
if (DEBUG) Log.v(TAG, "stop()")
rtspProcessor.stop()
}
fun isStarted(): Boolean {
return rtspProcessor.isStarted()
}
fun setStatusListener(listener: RtspStatusListener?) {
if (DEBUG) Log.v(TAG, "setStatusListener()")
rtspProcessor.statusListener = listener
}
fun setDataListener(listener: RtspDataListener?) {
if (DEBUG) Log.v(TAG, "setDataListener()")
rtspProcessor.dataListener = listener
}
companion object {
private val TAG: String = RtspSurfaceView::class.java.simpleName
private const val DEBUG = false
}
}
@OptIn(UnstableApi::class)
fun NalUnitUtil.SpsData.spsDataToString(): String {
return "" +
"width=${this.width}, " +
"height=${this.height}, " +
"profile_idc=${this.profileIdc}, " +
"constraint_set_flags=${this.constraintsFlagsAndReservedZero2Bits}, " +
"level_idc=${this.levelIdc}, " +
"max_num_ref_frames=${this.maxNumRefFrames}, " +
"frame_mbs_only_flag=${this.frameMbsOnlyFlag}, " +
"log2_max_frame_num=${this.frameNumLength}, " +
"pic_order_cnt_type=${this.picOrderCountType}, " +
"log2_max_pic_order_cnt_lsb=${this.picOrderCntLsbLength}, " +
"delta_pic_order_always_zero_flag=${this.deltaPicOrderAlwaysZeroFlag}, " +
"max_reorder_frames=${this.maxNumReorderFrames}"
}
fun ByteArray.toHexString(offset: Int, maxLength: Int): String {
val length = minOf(maxLength, size - offset)
return sliceArray(offset until (offset + length))
.joinToString(separator = "") { byte ->
"%02x ".format(byte).uppercase()
}
}
@@ -0,0 +1,34 @@
package com.alexvas.utils;
import androidx.annotation.NonNull;
import java.io.File;
import java.io.FileOutputStream;
public class ByteUtils {
// int memcmp ( const void * ptr1, const void * ptr2, size_t num );
public static boolean memcmp(
@NonNull byte[] source1,
int offsetSource1,
@NonNull byte[] source2,
int offsetSource2,
int num) {
if (source1.length - offsetSource1 < num)
return false;
if (source2.length - offsetSource2 < num)
return false;
for (int i = 0; i < num; i++) {
if (source1[offsetSource1 + i] != source2[offsetSource2 + i])
return false;
}
return true;
}
public static byte[] copy(@NonNull byte[] src) {
byte[] dest = new byte[src.length];
System.arraycopy(src, 0, dest, 0, src.length);
return dest;
}
}
@@ -0,0 +1,97 @@
package com.alexvas.utils
import android.annotation.SuppressLint
import android.util.Log
import android.util.Range
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
import java.lang.Exception
@SuppressLint("UnsafeOptInUsageError")
object MediaCodecUtils {
// key - codecs mime type
// value - list of codecs able to handle this mime type
private val decoderInfosMap = HashMap<String, List<MediaCodecInfo>>()
private val TAG: String = MediaCodecUtils::class.java.simpleName
private fun getDecoderInfos(mimeType: String): List<MediaCodecInfo> {
val list = decoderInfosMap[mimeType]
return if (list.isNullOrEmpty()) {
val decoderInfos = try {
MediaCodecUtil.getDecoderInfos(mimeType, false, false)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize '$mimeType' decoders list (${e.message})", e)
ArrayList()
}
decoderInfosMap[mimeType] = decoderInfos
decoderInfos
} else {
list
}
}
/**
* Get software decoders list. Usually used as fallback.
*/
@Synchronized
fun getSoftwareDecoders(mimeType: String): List<MediaCodecInfo> {
val decoderInfos = getDecoderInfos(mimeType)
val list = ArrayList<MediaCodecInfo>()
for (codec in decoderInfos) {
if (codec.softwareOnly)
list.add(codec)
}
return list
}
/**
* Get hardware accelerated decoders list. Used as default.
*/
@Synchronized
fun getHardwareDecoders(mimeType: String): List<MediaCodecInfo> {
val decoderInfos = getDecoderInfos(mimeType)
val list = ArrayList<MediaCodecInfo>()
for (codec in decoderInfos) {
if (codec.hardwareAccelerated)
list.add(codec)
}
return list
}
/**
* Look through all decoders (if there are multiple)
* and select the one which supports low-latency.
*/
@OptIn(UnstableApi::class)
fun getLowLatencyDecoder(decoders: List<MediaCodecInfo>): MediaCodecInfo? {
// Some devices can have several decoders, e.g.
// Samsung Fold 5:
// "c2.qti.avc.decoder"
// "c2.qti.avc.decoder.low_latency"
for (decoder in decoders) {
if (decoder.name.contains("low_latency"))
return decoder
}
// Another approach to find decoder with low-latency is to call
// MediaCodec.createByCodecName(name) for every decoder to get decoder instance and then call
// decoder.codecInfo.getCapabilitiesForType(mimeType).isFeatureSupported(MediaCodecInfo.CodecCapabilities.FEATURE_LowLatency)
// No low-latency decoder found.
return null
}
}
fun android.media.MediaCodecInfo.CodecCapabilities.capabilitiesToString(): String {
var heights = videoCapabilities?.supportedHeights
if (heights == null)
heights = Range(-1, -1)
var widths = videoCapabilities?.supportedWidths
if (widths == null)
widths = Range(-1, -1)
return "max instances: ${maxSupportedInstances}, max resolution: ${heights.upper}x${widths.upper}"
}
@@ -0,0 +1,238 @@
package com.alexvas.utils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
public class NetUtils {
private static final String TAG = NetUtils.class.getSimpleName();
private static final boolean DEBUG = false;
private final static int MAX_LINE_SIZE = 4098;
public static final class FakeX509TrustManager implements X509TrustManager {
/**
* Accepted issuers for fake trust manager
*/
final static private X509Certificate[] mAcceptedIssuers = new X509Certificate[]{};
/**
* Constructor for FakeX509TrustManager.
*/
public FakeX509TrustManager() {
}
/**
* @see javax.net.ssl.X509TrustManager#checkClientTrusted(X509Certificate[],String authType)
*/
public void checkClientTrusted(X509Certificate[] certificates, String authType)
throws CertificateException {
}
/**
* @see javax.net.ssl.X509TrustManager#checkServerTrusted(X509Certificate[],String authType)
*/
public void checkServerTrusted(X509Certificate[] certificates, String authType)
throws CertificateException {
}
// https://github.com/square/okhttp/issues/4669
// Called by Android via reflection in X509TrustManagerExtensions.
@SuppressWarnings("unused")
public List<X509Certificate> checkServerTrusted(X509Certificate[] chain, String authType, String host) throws CertificateException {
return Arrays.asList(chain);
}
/**
* @see javax.net.ssl.X509TrustManager#getAcceptedIssuers()
*/
public X509Certificate[] getAcceptedIssuers() {
return mAcceptedIssuers;
}
}
@NonNull
public static SSLSocket createSslSocketAndConnect(@NonNull String dstName, int dstPort, int timeout) throws Exception {
if (DEBUG)
Log.v(TAG, "createSslSocketAndConnect(dstName=" + dstName + ", dstPort=" + dstPort + ", timeout=" + timeout + ")");
// TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
// trustManagerFactory.init((KeyStore) null);
// TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
// if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
// throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers));
// }
// X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[] { new FakeX509TrustManager() }, null);
SSLSocket sslSocket = (SSLSocket) sslContext.getSocketFactory().createSocket();
sslSocket.connect(new InetSocketAddress(dstName, dstPort), timeout);
sslSocket.setSoLinger(false, 1);
sslSocket.setSoTimeout(timeout);
return sslSocket;
}
@NonNull
public static Socket createSocketAndConnect(@NonNull String dstName, int dstPort, int timeout) throws IOException {
if (DEBUG)
Log.v(TAG, "createSocketAndConnect(dstName=" + dstName + ", dstPort=" + dstPort + ", timeout=" + timeout + ")");
Socket socket = new Socket();
socket.connect(new InetSocketAddress(dstName, dstPort), timeout);
socket.setSoLinger(false, 1);
socket.setSoTimeout(timeout);
return socket;
}
@NonNull
public static Socket createSocket(int timeout) throws IOException {
Socket socket = new Socket();
socket.setSoLinger(false, 1);// 1 sec for flush() before close()
socket.setSoTimeout(timeout); // 10 sec timeout for read(), not for write()
return socket;
}
public static void closeSocket(@Nullable Socket socket) throws IOException {
if (DEBUG)
Log.v(TAG, "closeSocket()");
if (socket != null) {
try {
socket.shutdownInput();
} catch (Exception ignored) {
}
try {
socket.shutdownOutput();
} catch (Exception ignored) {
}
socket.close();
}
}
@NonNull
public static ArrayList<String> readResponseHeaders(@NonNull InputStream inputStream) throws IOException {
// Assert.assertNotNull("Input stream should not be null", inputStream);
ArrayList<String> headers = new ArrayList<>();
String line;
while (true) {
line = readLine(inputStream);
if (line != null) {
if (line.equals("\r\n"))
return headers;
else
headers.add(line);
} else {
break;
}
}
return headers;
}
@Nullable
public static String readLine(@NonNull InputStream inputStream) throws IOException {
// Assert.assertNotNull("Input stream should not be null", inputStream);
byte[] bufferLine = new byte[MAX_LINE_SIZE];
int offset = 0;
int readBytes;
do {
// Didn't find "\r\n" within 4K bytes
if (offset >= MAX_LINE_SIZE) {
throw new IOException("Invalid headers");
}
// Read 1 byte
readBytes = inputStream.read(bufferLine, offset, 1);
if (readBytes == 1) {
// Check for EOL
// Some cameras like Linksys WVC200 do not send \n instead of \r\n
if (offset > 0 && /*bufferLine[offset-1] == '\r' &&*/ bufferLine[offset] == '\n') {
// Found empty EOL. End of header section
if (offset == 1)
break;
// Found EOL. Add to array.
return new String(bufferLine, 0, offset-1);
} else {
offset++;
}
}
} while (readBytes > 0);
return null;
}
public static int getResponseStatusCode(@NonNull ArrayList<String> headers) {
// Assert.assertNotNull("Headers should not be null", headers);
// Search for HTTP status code header
for (String header: headers) {
int indexHttp = header.indexOf("HTTP/1.1 "); // 9 characters
if (indexHttp == -1)
indexHttp = header.indexOf("HTTP/1.0 ");
if (indexHttp >= 0) {
int indexCode = header.indexOf(' ', 9);
String code = header.substring(9, indexCode);
try {
return Integer.parseInt(code);
} catch (NumberFormatException e) {
// Does not fulfill standard "HTTP/1.1 200 Ok" token
// Continue search for
}
}
}
// Not found
return -1;
}
// @Nullable
// static String readContentAsText(@Nullable InputStream inputStream) throws IOException {
// if (inputStream == null)
// return null;
// BufferedReader r = new BufferedReader(new InputStreamReader(inputStream));
// StringBuilder total = new StringBuilder();
// String line;
// while ((line = r.readLine()) != null) {
// total.append(line);
// total.append("\r\n");
// }
// return total.toString();
// }
@NonNull
public static String readContentAsText(@NonNull InputStream inputStream, int length) throws IOException {
// Assert.assertNotNull("Input stream should not be null", inputStream);
if (length <= 0)
return "";
byte[] b = new byte[length];
int read = readData(inputStream, b, 0, length);
return new String(b, 0, read);
}
public static int readData(@NonNull InputStream inputStream, @NonNull byte[] buffer, int offset, int length) throws IOException {
int readBytes;
int totalReadBytes = 0;
do {
readBytes = inputStream.read(buffer, offset + totalReadBytes, length - totalReadBytes);
if (readBytes == -1) {
throw new EOFException("Stream closed, read " + totalReadBytes + " of " + length + " bytes");
}
totalReadBytes += readBytes;
} while (readBytes >= 0 && totalReadBytes < length);
return totalReadBytes;
}
}
@@ -0,0 +1,398 @@
package com.alexvas.utils
import android.annotation.SuppressLint
import android.util.Log
import androidx.media3.container.NalUnitUtil
import androidx.media3.container.NalUnitUtil.SpsData
import java.util.concurrent.atomic.AtomicInteger
import kotlin.experimental.and
object VideoCodecUtils {
private val TAG = VideoCodecUtils::class.java.simpleName
/** Max possible NAL SPS size in bytes */
const val MAX_NAL_SPS_SIZE: Int = 500
const val NAL_SLICE: Byte = 1
const val NAL_DPA: Byte = 2
const val NAL_DPB: Byte = 3
const val NAL_DPC: Byte = 4
const val NAL_IDR_SLICE: Byte = 5
const val NAL_SEI: Byte = 6
const val NAL_SPS: Byte = 7
const val NAL_PPS: Byte = 8
const val NAL_AUD: Byte = 9
const val NAL_END_SEQUENCE: Byte = 10
const val NAL_END_STREAM: Byte = 11
const val NAL_FILLER_DATA: Byte = 12
const val NAL_SPS_EXT: Byte = 13
const val NAL_AUXILIARY_SLICE: Byte = 19
const val NAL_STAP_A: Byte = 24 // https://tools.ietf.org/html/rfc3984 5.7.1
const val NAL_STAP_B: Byte = 25 // 5.7.1
const val NAL_MTAP16: Byte = 26 // 5.7.2
const val NAL_MTAP24: Byte = 27 // 5.7.2
const val NAL_FU_A: Byte = 28 // 5.8 fragmented unit
const val NAL_FU_B: Byte = 29 // 5.8
// Table 7-3: NAL unit type codes
const val H265_NAL_TRAIL_N: Byte = 0
const val H265_NAL_TRAIL_R: Byte = 1
const val H265_NAL_TSA_N: Byte = 2
const val H265_NAL_TSA_R: Byte = 3
const val H265_NAL_STSA_N: Byte = 4
const val H265_NAL_STSA_R: Byte = 5
const val H265_NAL_RADL_N: Byte = 6
const val H265_NAL_RADL_R: Byte = 7
const val H265_NAL_RASL_N: Byte = 8
const val H265_NAL_RASL_R: Byte = 9
const val H265_NAL_BLA_W_LP: Byte = 16
const val H265_NAL_BLA_W_RADL: Byte = 17
const val H265_NAL_BLA_N_LP: Byte = 18
const val H265_NAL_IDR_W_RADL: Byte = 19
const val H265_NAL_IDR_N_LP: Byte = 20
const val H265_NAL_CRA_NUT: Byte = 21
const val H265_NAL_VPS: Byte = 32
const val H265_NAL_SPS: Byte = 33
const val H265_NAL_PPS: Byte = 34
const val H265_NAL_AUD: Byte = 35
const val H265_NAL_EOS_NUT: Byte = 36
const val H265_NAL_EOB_NUT: Byte = 37
const val H265_NAL_FD_NUT: Byte = 38
const val H265_NAL_SEI_PREFIX: Byte = 39
const val H265_NAL_SEI_SUFFIX: Byte = 40
private val NAL_PREFIX1 = byteArrayOf(0x00, 0x00, 0x00, 0x01)
private val NAL_PREFIX2 = byteArrayOf(0x00, 0x00, 0x01)
/**
* Search for 00 00 01 or 00 00 00 01 in byte stream.
* @return offset to the start of NAL unit if found, otherwise -1
*/
fun searchForNalUnitStart(
data: ByteArray,
offset: Int,
length: Int,
prefixSize: AtomicInteger
): Int {
if (offset >= data.size - 3) return -1
for (pos in 0 until length) {
val prefix: Int = getNalUnitStartCodePrefixSize(data, pos + offset, length)
if (prefix >= 0) {
prefixSize.set(prefix)
return pos + offset
}
}
return -1
}
fun searchForH264NalUnitByType(
data: ByteArray,
offset: Int,
length: Int,
byUnitType: Int
): Int {
var off = offset
val nalUnitPrefixSize = AtomicInteger(-1)
val timestamp = System.currentTimeMillis()
while (true) {
val nalUnitIndex = searchForNalUnitStart(data, off, length, nalUnitPrefixSize)
if (nalUnitIndex >= 0) {
val nalUnitOffset = nalUnitIndex + nalUnitPrefixSize.get()
if (nalUnitOffset >= data.size)
break
val nalUnitTypeOctet = data[nalUnitOffset]
if ((nalUnitTypeOctet and 0x1f).toInt() == byUnitType) {
return nalUnitIndex
}
off = nalUnitOffset
// Check that we are not too long here
if (System.currentTimeMillis() - timestamp > 100) {
Log.w(TAG, "Cannot process data within 100 msec in $length bytes")
break
}
} else {
break
}
}
return -1
}
fun getNalUnitType(data: ByteArray?, offset: Int, length: Int, isH265: Boolean): Byte {
if (data == null || length <= NAL_PREFIX1.size) return (-1).toByte()
var nalUnitTypeOctetOffset = -1
if (data[offset + NAL_PREFIX2.size - 1] == 1.toByte())
nalUnitTypeOctetOffset =
offset + NAL_PREFIX2.size - 1
else if (data[offset + NAL_PREFIX1.size - 1] == 1.toByte())
nalUnitTypeOctetOffset = offset + NAL_PREFIX1.size - 1
return if (nalUnitTypeOctetOffset != -1) {
val nalUnitTypeOctet = data[nalUnitTypeOctetOffset + 1]
if (isH265)
((nalUnitTypeOctet.toInt() shr 1) and 0x3F).toByte()
else
(nalUnitTypeOctet and 0x1f)
} else {
(-1).toByte()
}
}
private fun getNalUnitStartCodePrefixSize(
data: ByteArray,
offset: Int,
length: Int
): Int {
if (length < 4) return -1
return if (memcmp(data, offset, NAL_PREFIX1, 0, NAL_PREFIX1.size))
NAL_PREFIX1.size else
if (memcmp(data, offset, NAL_PREFIX2, 0, NAL_PREFIX2.size))
NAL_PREFIX2.size else
-1
}
private fun memcmp(
source1: ByteArray,
offsetSource1: Int,
source2: ByteArray,
offsetSource2: Int,
num: Int
): Boolean {
if (source1.size - offsetSource1 < num) return false
if (source2.size - offsetSource2 < num) return false
for (i in 0 until num) {
if (source1[offsetSource1 + i] != source2[offsetSource2 + i]) return false
}
return true
}
data class NalUnit (val type: Byte, val offset: Int, val length: Int)
fun getNalUnits(
data: ByteArray,
dataOffset: Int,
length: Int,
foundNals: ArrayList<NalUnit>,
isH265: Boolean
): Int {
foundNals.clear()
var nalUnits = 0
val nextNalOffset = 0
val nalUnitPrefixSize = AtomicInteger(-1)
val timestamp = System.currentTimeMillis()
var offset = dataOffset
var stopped = false
while (!stopped) {
// Search for first NAL unit
val nalUnitIndex = searchForNalUnitStart(
data,
offset + nextNalOffset,
length - nextNalOffset,
nalUnitPrefixSize
)
// NAL unit found
if (nalUnitIndex >= 0) {
nalUnits++
val nalUnitOffset = offset + nextNalOffset + nalUnitPrefixSize.get()
val nalUnitTypeOctet = data[nalUnitOffset]
val nalUnitType = if (isH265)
((nalUnitTypeOctet.toInt() shr 1) and 0x3F).toByte()
else
(nalUnitTypeOctet and 0x1F)
// Search for second NAL unit (optional)
var nextNalUnitStartIndex = searchForNalUnitStart(
data,
nalUnitOffset,
length - nalUnitOffset,
nalUnitPrefixSize
)
// Second NAL unit not found. Use till the end.
if (nextNalUnitStartIndex < 0) {
// Not found next NAL unit. Use till the end.
// nextNalUnitStartIndex = length - nextNalOffset + dataOffset;
nextNalUnitStartIndex = length + dataOffset
stopped = true
}
val l = nextNalUnitStartIndex - offset
// if (DEBUG) Log.d(
// TAG,
// "NAL unit type: " + getH264NalUnitTypeString(nalUnitType.toInt()) +
// " (" + nalUnitType + ") - " + l + " bytes, offset " + offset
// )
foundNals.add(NalUnit(nalUnitType, offset, l))
offset = nextNalUnitStartIndex
// Check that we are not too long here
if (System.currentTimeMillis() - timestamp > 200) {
Log.w(TAG, "Cannot process data within 200 msec in $length bytes (NALs found: " + foundNals.size + ")")
break
}
} else {
stopped = true
}
}
return nalUnits
}
private fun getNalUnitStartLengthFromArray(
src: ByteArray, offset: Int, length: Int,
isH265: Boolean,
nalUnitType: Byte
): Pair<Int, Int>? {
val nalUnitsFound = ArrayList<NalUnit>()
if (getNalUnits(src, offset, length, nalUnitsFound, isH265) > 0) {
for (nalUnit in nalUnitsFound) {
if (nalUnit.type == nalUnitType) {
val prefixSize = AtomicInteger()
val nalUnitIndex = searchForNalUnitStart(
src,
nalUnit.offset,
nalUnit.length,
prefixSize
)
val nalOffset = nalUnitIndex + prefixSize.get() + 1 /* NAL unit type */
return Pair(nalOffset, nalUnit.length)
}
}
}
return null
}
@SuppressLint("UnsafeOptInUsageError")
fun getSpsNalUnitFromArray(src: ByteArray, offset: Int, length: Int, isH265: Boolean): SpsData? {
val spsStartLength = getNalUnitStartLengthFromArray(src, offset, length, isH265, NAL_SPS)
spsStartLength?.let {
return NalUnitUtil.parseSpsNalUnitPayload(
src, spsStartLength.first, spsStartLength.first + spsStartLength.second)
}
return null
}
@SuppressLint("UnsafeOptInUsageError")
fun getWidthHeightFromArray(src: ByteArray, offset: Int, length: Int, isH265: Boolean): Pair<Int, Int>? {
val sps = getSpsNalUnitFromArray(src, offset, length, isH265)
sps?.let {
return Pair(sps.width, sps.height)
}
return null
}
// private fun isH265IRAP(nalUnitType: Byte): Boolean {
// return nalUnitType in 16..23
// }
fun isAnyKeyFrame(data: ByteArray?, offset: Int, length: Int, isH265: Boolean): Boolean {
if (data == null || length <= 0) return false
var currOffset = offset
val nalUnitPrefixSize = AtomicInteger(-1)
val timestamp = System.currentTimeMillis()
while (true) {
val nalUnitIndex = searchForNalUnitStart(
data,
currOffset,
length,
nalUnitPrefixSize
)
if (nalUnitIndex >= 0) {
val nalUnitOffset = nalUnitIndex + nalUnitPrefixSize.get()
if (nalUnitOffset >= data.size)
return false
val nalUnitTypeOctet = data[nalUnitOffset]
if (isH265) {
val nalUnitType = ((nalUnitTypeOctet.toInt() and 0x7E) shr 1).toByte()
// Treat SEI_PREFIX as key frame.
if (nalUnitType == H265_NAL_IDR_W_RADL || nalUnitType == H265_NAL_IDR_N_LP)
return true
} else {
val nalUnitType = (nalUnitTypeOctet.toInt() and 0x1f).toByte()
when (nalUnitType) {
NAL_IDR_SLICE -> return true
NAL_SLICE -> return false
}
}
// Continue searching
currOffset = nalUnitOffset
// Check that we are not too long here
if (System.currentTimeMillis() - timestamp > 100) {
Log.w(TAG, "Cannot process data within 100 msec in $length bytes (index=$nalUnitIndex)")
break
}
} else {
break
}
}
return false
}
fun getH264NalUnitTypeString(nalUnitType: Byte): String {
return when (nalUnitType) {
NAL_SLICE -> "NAL_SLICE"
NAL_DPA -> "NAL_DPA"
NAL_DPB -> "NAL_DPB"
NAL_DPC -> "NAL_DPC"
NAL_IDR_SLICE -> "NAL_IDR_SLICE"
NAL_SEI -> "NAL_SEI"
NAL_SPS -> "NAL_SPS"
NAL_PPS -> "NAL_PPS"
NAL_AUD -> "NAL_AUD"
NAL_END_SEQUENCE -> "NAL_END_SEQUENCE"
NAL_END_STREAM -> "NAL_END_STREAM"
NAL_FILLER_DATA -> "NAL_FILLER_DATA"
NAL_SPS_EXT -> "NAL_SPS_EXT"
NAL_AUXILIARY_SLICE -> "NAL_AUXILIARY_SLICE"
NAL_STAP_A -> "NAL_STAP_A"
NAL_STAP_B -> "NAL_STAP_B"
NAL_MTAP16 -> "NAL_MTAP16"
NAL_MTAP24 -> "NAL_MTAP24"
NAL_FU_A -> "NAL_FU_A"
NAL_FU_B -> "NAL_FU_B"
else -> "unknown - $nalUnitType"
}
}
fun getH265NalUnitTypeString(nalUnitType: Byte): String {
return when (nalUnitType) {
H265_NAL_TRAIL_N -> "NAL_TRAIL_N"
H265_NAL_TRAIL_R -> "NAL_TRAIL_R"
H265_NAL_TSA_N -> "NAL_TSA_N"
H265_NAL_TSA_R -> "NAL_TSA_R"
H265_NAL_STSA_N -> "NAL_STSA_N"
H265_NAL_STSA_R -> "NAL_STSA_R"
H265_NAL_RADL_N -> "NAL_RADL_N"
H265_NAL_RADL_R -> "NAL_RADL_R"
H265_NAL_RASL_N -> "NAL_RASL_N"
H265_NAL_RASL_R -> "NAL_RASL_R"
H265_NAL_BLA_W_LP -> "NAL_BLA_W_LP"
H265_NAL_BLA_W_RADL -> "NAL_BLA_W_RADL"
H265_NAL_BLA_N_LP -> "NAL_BLA_N_LP"
H265_NAL_IDR_W_RADL -> "NAL_IDR_W_RADL"
H265_NAL_IDR_N_LP -> "NAL_IDR_N_LP"
H265_NAL_CRA_NUT -> "NAL_CRA_NUT"
H265_NAL_VPS -> "NAL_VPS"
H265_NAL_SPS -> "NAL_SPS"
H265_NAL_PPS -> "NAL_PPS"
H265_NAL_AUD -> "NAL_AUD"
H265_NAL_EOS_NUT -> "NAL_EOS_NUT"
H265_NAL_EOB_NUT -> "NAL_EOB_NUT"
H265_NAL_FD_NUT -> "NAL_FD_NUT"
H265_NAL_SEI_PREFIX -> "NAL_SEI_PREFIX"
H265_NAL_SEI_SUFFIX -> "NAL_SEI_SUFFIX"
else -> "unknown - $nalUnitType"
}
}
}
+1
View File
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
rootProject.name = "m20_gamepad"
include(":app")
include(":joysticklibrary")
include(":rtspclientlibrary")