Compare commits

..

2 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
30 changed files with 6225 additions and 151 deletions
+1
View File
@@ -13,3 +13,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
build/
+34 -3
View File
@@ -37,7 +37,7 @@ com.example.m20_gamepad/
│ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定) │ ├── RobotConnection.kt # 连接状态机管理(连接/订阅/心跳/断线判定)
│ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持 │ └── JoystickController.kt # 摇杆输入 → 协议指令映射,含 HandStyle 多手型支持
├── video/ ├── video/
│ └── RtspVideoPlayer.kt # RTSP 视频流播放(ExoPlayer + Composable 包装) │ └── RtspVideoPlayer.kt # RTSP 视频流播放(rtspclientlibrary + Composable 包装)
├── data/ ├── data/
│ ├── SettingsRepository.kt # 设置项读写(DataStore 封装) │ ├── SettingsRepository.kt # 设置项读写(DataStore 封装)
│ └── AppSettings.kt # 配置数据类定义 + 默认值 │ └── AppSettings.kt # 配置数据类定义 + 默认值
@@ -113,7 +113,38 @@ Yaw = (左履带 - 右履带) / 2
## RTSP 视频流 ## 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,全量可改
--- ---
@@ -159,7 +190,7 @@ gradlew installDebug
- 虚拟摇杆库:本地模块 `joysticklibrary/`Compose 原生,Kotlin DSL - 虚拟摇杆库:本地模块 `joysticklibrary/`Compose 原生,Kotlin DSL
- JSON 序列化:`kotlinx-serialization-json:1.7.3` - 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.datastore:datastore-preferences:1.1.3`
- 导航:`androidx.navigation:navigation-compose:2.8.6` - 导航:`androidx.navigation:navigation-compose:2.8.6`
+13 -1
View File
@@ -2,7 +2,19 @@
## 待办 ## 待办
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(跳过 Media3 ExoPlayer,直接使用 MediaCodec + SurfaceView ### 视频低延迟改造(进行中,分支 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 集成方案承接
## 已完成 ## 已完成
+1 -3
View File
@@ -42,6 +42,7 @@ android {
dependencies { dependencies {
implementation(project(":joysticklibrary")) implementation(project(":joysticklibrary"))
implementation(project(":rtspclientlibrary"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -54,9 +55,6 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.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) implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
@@ -1,5 +1,7 @@
package com.example.m20_gamepad.video 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -21,16 +23,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem import androidx.lifecycle.Lifecycle
import androidx.media3.common.PlaybackException import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.Player import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.exoplayer.DefaultLoadControl import com.alexvas.rtsp.codec.VideoDecodeThread
import androidx.media3.exoplayer.DefaultRenderersFactory import com.alexvas.rtsp.widget.RtspStatusListener
import androidx.media3.exoplayer.ExoPlayer import com.alexvas.rtsp.widget.RtspSurfaceView
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.example.m20_gamepad.data.VideoResizeMode import com.example.m20_gamepad.data.VideoResizeMode
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -63,14 +61,20 @@ private const val RETRY_DELAY_INITIAL_MS = 1000L
private const val RETRY_DELAY_MAX_MS = 30_000L private const val RETRY_DELAY_MAX_MS = 30_000L
private const val RETRY_DELAY_MULTIPLIER = 2 private const val RETRY_DELAY_MULTIPLIER = 2
/** 延迟统计日志 TAG */
private const val TAG = "RtspVideoPlayer"
/** /**
* RTSP 视频流播放 Composable。 * RTSP 视频流播放 Composable。
* *
* 使用 Media3 ExoPlayer + RTSP 扩展拉取 RTSP 流,作为全屏背景层。 * 使用 rtspclientlibraryrtsp-client-android)的 [RtspSurfaceView] 拉取 RTSP 流,
* [codec] 变化时重建播放器并应用新的解码器策略。 * 作为全屏背景层。该库为纯 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 codec 解码器选择策略
* @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏) * @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏)
* @param modifier 布局修饰符 * @param modifier 布局修饰符
@@ -83,53 +87,51 @@ fun RtspVideoPlayer(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current 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 retryCount by remember { mutableIntStateOf(0) }
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) } var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
var retrySignal by remember { mutableIntStateOf(0) } 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 中注入, // codec 变化时重建 RtspSurfaceView(在 factory 中映射为 DecoderType)。
// 同时使用最小缓冲策略降低延迟 // RtspSurfaceView 持有 RtspProcessor,内部线程在 dispose 时由 stop() 停止。
val player: ExoPlayer = remember(codec) { val rtspView = remember(codec, context) {
val selector = codec.toMediaCodecSelector() RtspSurfaceView(context).apply {
val renderersFactory = DefaultRenderersFactory(context) videoDecoderType = codec.toDecoderType()
renderersFactory.setMediaCodecSelector(selector) // 实验性 SPS 低延迟参数重写(仅 H.264num_ref_frames -> 0,部分硬件
val loadControl = DefaultLoadControl.Builder() // 解码器可降低约 2 倍延迟;H.265 流自动跳过)
.setBufferDurationsMs( experimentalUpdateSpsFrameWithLowLatencyParams = true
200, // minBufferMs — 必须 >= bufferForPlaybackAfterRebufferMs setStatusListener(object : RtspStatusListener {
500, // maxBufferMs override fun onRtspStatusConnected() {
100, // bufferForPlaybackMs — 起播所需缓冲 state = RtspState.Playing
200 // bufferForPlaybackAfterRebufferMs — 重缓冲后所需缓冲
)
.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(retryCount)
Player.STATE_READY -> RtspState.Playing
Player.STATE_IDLE -> RtspState.Connecting(retryCount)
else -> state
}
} }
override fun onPlayerError(error: PlaybackException) { override fun onRtspStatusFailed(message: String?) {
if (stoppedByLifecycle) return
state = RtspState.Error( state = RtspState.Error(
message = error.message ?: "连接失败", message = message ?: "连接失败",
retryCount = retryCount, retryCount = retryCount,
nextRetryMs = retryDelayMs nextRetryMs = retryDelayMs
) )
// 递增 retrySignal 触发 LaunchedEffect 自动重试 // 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++ retrySignal++
} }
override fun onRtspStatusDisconnected() {
if (stoppedByLifecycle) return
// 非主动停止时视为连接丢失,进入重试
state = RtspState.Error(
message = "连接断开",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
retrySignal++
}
}) })
} }
} }
@@ -137,8 +139,10 @@ fun RtspVideoPlayer(
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试 // 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
LaunchedEffect(url, codec, retrySignal) { LaunchedEffect(url, codec, retrySignal) {
if (url.isNotBlank()) { if (url.isNotBlank()) {
if (retrySignal == 0) { val params = url to codec
// 首次连接 / URL 或 codec 变更:重置退避 if (lastParams != params) {
// 首次连接 / URL 或 codec 变更:重置退避并立即连接
lastParams = params
retryCount = 0 retryCount = 0
retryDelayMs = RETRY_DELAY_INITIAL_MS retryDelayMs = RETRY_DELAY_INITIAL_MS
} else { } else {
@@ -148,40 +152,62 @@ fun RtspVideoPlayer(
retryCount++ retryCount++
} }
state = RtspState.Connecting(retryCount) state = RtspState.Connecting(retryCount)
player.stop() rtspView.init(Uri.parse(url))
player.clearMediaItems() rtspView.start(requestVideo = true, requestAudio = false)
val mediaItem = MediaItem.fromUri(url)
player.setMediaItem(mediaItem)
player.prepare()
} }
} }
// codec 变化时释放旧播放器 // 延迟统计:轮询库内统计(读取 statistics 会激活测量),输出到 Logcat。
DisposableEffect(codec) { // 播放中每 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 { onDispose {
player.release() lifecycleOwner.lifecycle.removeObserver(observer)
stoppedByLifecycle = true
rtspView.stop()
} }
} }
Box(modifier = modifier) { Box(modifier = modifier) {
// 仅在视频播放中时渲染 PlayerView,否则透出底层背景 // 始终渲染 SurfaceView:首帧未到前为透明,SurfaceView 在 Compose 之上的
if (state is RtspState.Playing) { // 层级需保持存在,否则解码渲染目标 surface 会被销毁。
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { ctx -> factory = { rtspView }
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()
}
) )
}
// 连接中 / 错误时的提示覆盖层 // 连接中 / 错误时的提示覆盖层
when (val s = state) { when (val s = state) {
@@ -232,71 +258,34 @@ private fun StatusOverlay(text: String, subtitle: String = "") {
} }
} }
// ==================== 解码器选择器 ==================== // ==================== 解码器映射 ====================
/** MIME 类型常量 */
private const val MIME_H264 = "video/avc"
private const val MIME_HEVC = "video/hevc"
/** /**
* 将 [VideoCodec] 枚举映射为 [MediaCodecSelector]。 * 将 [VideoCodec] 枚举映射为 rtspclientlibrary 的 [VideoDecodeThread.DecoderType]。
* *
* - AUTO: 默认选择器(优先硬件解码) * - AUTO: 硬件解码(库默认,解码失败自动回退软件
* - FORCE_HW: 强制硬件解码,所有 MIME 类型仅保留硬件加速解码器 * - FORCE_HW: 强制硬件解码
* - FORCE_SW_H264: 对 H.264 仅保留软件解码器,其余 MIME 走默认 * - FORCE_SW_H264: 强制软件解码(对 H.264 流有效)
* - FORCE_SW_H265: 针对 H.265/HEVC 仅保留软件解码器,其余 MIME 走默认 * - FORCE_SW_H265: 强制软件解码(对 H.265 流有效)
*
* 注意:库的 DecoderType 仅区分 HARDWARE/SOFTWARE,不支持按 MIME 分别指定,
* 因此 FORCE_SW_* 在码流与所选 MIME 不符时仍可能走硬件(与上游行为一致)。
*/ */
private fun VideoCodec.toMediaCodecSelector(): MediaCodecSelector = when (this) { private fun VideoCodec.toDecoderType(): VideoDecodeThread.DecoderType = when (this) {
VideoCodec.AUTO -> MediaCodecSelector.DEFAULT VideoCodec.AUTO,
VideoCodec.FORCE_HW -> VideoDecodeThread.DecoderType.HARDWARE
VideoCodec.FORCE_HW -> forceHardware() VideoCodec.FORCE_SW_H264,
VideoCodec.FORCE_SW_H264 -> forceSoftwareForMime(MIME_H264) VideoCodec.FORCE_SW_H265 -> VideoDecodeThread.DecoderType.SOFTWARE
VideoCodec.FORCE_SW_H265 -> forceSoftwareForMime(MIME_HEVC)
} }
// ==================== 缩放模式 ====================
/** /**
* 创建一个 [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" navigationCompose = "2.8.6"
media3 = "1.5.1" media3 = "1.5.1"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
annotation = "1.10.0"
jcodec = "0.2.5"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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" } 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" rootProject.name = "m20_gamepad"
include(":app") include(":app")
include(":joysticklibrary") include(":joysticklibrary")
include(":rtspclientlibrary")