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 由库模块提供)
This commit is contained in:
@@ -42,6 +42,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":joysticklibrary"))
|
||||
implementation(project(":rtspclientlibrary"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -54,9 +55,6 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.exoplayer.rtsp)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.m20_gamepad.video
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -21,16 +23,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.alexvas.rtsp.codec.VideoDecodeThread
|
||||
import com.alexvas.rtsp.widget.RtspStatusListener
|
||||
import com.alexvas.rtsp.widget.RtspSurfaceView
|
||||
import com.example.m20_gamepad.data.VideoResizeMode
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -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_MULTIPLIER = 2
|
||||
|
||||
/** 延迟统计日志 TAG */
|
||||
private const val TAG = "RtspVideoPlayer"
|
||||
|
||||
/**
|
||||
* RTSP 视频流播放 Composable。
|
||||
*
|
||||
* 使用 Media3 ExoPlayer + RTSP 扩展拉取 RTSP 流,作为全屏背景层。
|
||||
* [codec] 变化时重建播放器并应用新的解码器策略。
|
||||
* 连接失败时显示错误提示。
|
||||
* 使用 rtspclientlibrary(rtsp-client-android)的 [RtspSurfaceView] 拉取 RTSP 流,
|
||||
* 作为全屏背景层。该库为纯 TCP interleaved + 零缓冲架构(无播放时钟 pacing),
|
||||
* 帧到达即解码渲染,替代原 Media3 ExoPlayer 播放器缓冲架构以降低延迟。
|
||||
*
|
||||
* @param url RTSP 流地址,如 `rtsp://10.21.31.103:554/stream`
|
||||
* [codec] 变化时重建连接并应用新的解码器策略。
|
||||
* 连接失败时按指数退避自动重试,URL/codec 变化时重置退避。
|
||||
*
|
||||
* @param url RTSP 流地址,如 `rtsp://10.21.41.1:8554/video1`
|
||||
* @param codec 解码器选择策略
|
||||
* @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏)
|
||||
* @param modifier 布局修饰符
|
||||
@@ -83,62 +87,62 @@ fun RtspVideoPlayer(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting()) }
|
||||
var retryCount by remember { mutableIntStateOf(0) }
|
||||
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
|
||||
var retrySignal by remember { mutableIntStateOf(0) }
|
||||
// 生命周期主动停止标志:ON_STOP 调用 stop() 触发的断开/失败不应进入重连
|
||||
var stoppedByLifecycle by remember { mutableStateOf(false) }
|
||||
// 最近一次生效的 url/codec:用于区分「参数变化(重置退避立即连接)」与「重试」
|
||||
var lastParams by remember { mutableStateOf<Pair<String, VideoCodec>?>(null) }
|
||||
|
||||
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
|
||||
// 同时使用最小缓冲策略降低延迟
|
||||
val player: ExoPlayer = remember(codec) {
|
||||
val selector = codec.toMediaCodecSelector()
|
||||
val renderersFactory = DefaultRenderersFactory(context)
|
||||
renderersFactory.setMediaCodecSelector(selector)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
200, // minBufferMs — 必须 >= bufferForPlaybackAfterRebufferMs
|
||||
500, // maxBufferMs
|
||||
100, // bufferForPlaybackMs — 起播所需缓冲
|
||||
200 // bufferForPlaybackAfterRebufferMs — 重缓冲后所需缓冲
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
// codec 变化时重建 RtspSurfaceView(在 factory 中映射为 DecoderType)。
|
||||
// RtspSurfaceView 持有 RtspProcessor,内部线程在 dispose 时由 stop() 停止。
|
||||
val rtspView = remember(codec, context) {
|
||||
RtspSurfaceView(context).apply {
|
||||
videoDecoderType = codec.toDecoderType()
|
||||
// 实验性 SPS 低延迟参数重写(仅 H.264:num_ref_frames -> 0,部分硬件
|
||||
// 解码器可降低约 2 倍延迟;H.265 流自动跳过)
|
||||
experimentalUpdateSpsFrameWithLowLatencyParams = true
|
||||
setStatusListener(object : RtspStatusListener {
|
||||
override fun onRtspStatusConnected() {
|
||||
state = RtspState.Playing
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
state = RtspState.Error(
|
||||
message = error.message ?: "连接失败",
|
||||
retryCount = retryCount,
|
||||
nextRetryMs = retryDelayMs
|
||||
)
|
||||
// 递增 retrySignal 触发 LaunchedEffect 自动重试
|
||||
retrySignal++
|
||||
}
|
||||
})
|
||||
}
|
||||
override fun onRtspStatusFailed(message: String?) {
|
||||
if (stoppedByLifecycle) return
|
||||
state = RtspState.Error(
|
||||
message = message ?: "连接失败",
|
||||
retryCount = retryCount,
|
||||
nextRetryMs = retryDelayMs
|
||||
)
|
||||
// 递增 retrySignal 触发 LaunchedEffect 自动重试
|
||||
retrySignal++
|
||||
}
|
||||
|
||||
override fun onRtspStatusDisconnected() {
|
||||
if (stoppedByLifecycle) return
|
||||
// 非主动停止时视为连接丢失,进入重试
|
||||
state = RtspState.Error(
|
||||
message = "连接断开",
|
||||
retryCount = retryCount,
|
||||
nextRetryMs = retryDelayMs
|
||||
)
|
||||
retrySignal++
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
|
||||
LaunchedEffect(url, codec, retrySignal) {
|
||||
if (url.isNotBlank()) {
|
||||
if (retrySignal == 0) {
|
||||
// 首次连接 / URL 或 codec 变更:重置退避
|
||||
val params = url to codec
|
||||
if (lastParams != params) {
|
||||
// 首次连接 / URL 或 codec 变更:重置退避并立即连接
|
||||
lastParams = params
|
||||
retryCount = 0
|
||||
retryDelayMs = RETRY_DELAY_INITIAL_MS
|
||||
} else {
|
||||
@@ -148,40 +152,62 @@ fun RtspVideoPlayer(
|
||||
retryCount++
|
||||
}
|
||||
state = RtspState.Connecting(retryCount)
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
rtspView.init(Uri.parse(url))
|
||||
rtspView.start(requestVideo = true, requestAudio = false)
|
||||
}
|
||||
}
|
||||
|
||||
// codec 变化时释放旧播放器
|
||||
DisposableEffect(codec) {
|
||||
// 延迟统计:轮询库内统计(读取 statistics 会激活测量),输出到 Logcat。
|
||||
// 播放中每 2s 上报一次解码/网络延迟,用于验证低延迟改造效果。
|
||||
LaunchedEffect(rtspView) {
|
||||
while (true) {
|
||||
delay(2000)
|
||||
val s = rtspView.statistics
|
||||
val decLat = s.videoDecoderLatencyMsec
|
||||
val netLat = s.networkLatencyMsec
|
||||
if (decLat >= 0 || netLat >= 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"decoder=${s.videoDecoderName} (${s.videoDecoderType}), " +
|
||||
"videoDecoderLatency=${decLat}ms, networkLatency=${netLat}ms"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 界面生命周期:onStop 时停止播放(后台停止拉流/解码),onStart 时恢复
|
||||
DisposableEffect(lifecycleOwner, rtspView) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
if (rtspView.isStarted()) {
|
||||
stoppedByLifecycle = true
|
||||
rtspView.stop()
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_START -> {
|
||||
stoppedByLifecycle = false
|
||||
// 重新连接;retrySignal 递增触发 LaunchedEffect(重置退避)
|
||||
retrySignal++
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
player.release()
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
stoppedByLifecycle = true
|
||||
rtspView.stop()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// 仅在视频播放中时渲染 PlayerView,否则透出底层背景
|
||||
if (state is RtspState.Playing) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
this.resizeMode = resizeMode.toMedia3ResizeMode()
|
||||
this.player = player
|
||||
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.player = player
|
||||
view.resizeMode = resizeMode.toMedia3ResizeMode()
|
||||
}
|
||||
)
|
||||
}
|
||||
// 始终渲染 SurfaceView:首帧未到前为透明,SurfaceView 在 Compose 之上的
|
||||
// 层级需保持存在,否则解码渲染目标 surface 会被销毁。
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { rtspView }
|
||||
)
|
||||
|
||||
// 连接中 / 错误时的提示覆盖层
|
||||
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: 默认选择器(优先硬件解码)
|
||||
* - FORCE_HW: 强制硬件解码,所有 MIME 类型仅保留硬件加速解码器
|
||||
* - FORCE_SW_H264: 针对 H.264 仅保留软件解码器,其余 MIME 走默认
|
||||
* - FORCE_SW_H265: 针对 H.265/HEVC 仅保留软件解码器,其余 MIME 走默认
|
||||
* - AUTO: 硬件解码(库默认,解码失败自动回退软件)
|
||||
* - FORCE_HW: 强制硬件解码
|
||||
* - FORCE_SW_H264: 强制软件解码(对 H.264 流有效)
|
||||
* - FORCE_SW_H265: 强制软件解码(对 H.265 流有效)
|
||||
*
|
||||
* 注意:库的 DecoderType 仅区分 HARDWARE/SOFTWARE,不支持按 MIME 分别指定,
|
||||
* 因此 FORCE_SW_* 在码流与所选 MIME 不符时仍可能走硬件(与上游行为一致)。
|
||||
*/
|
||||
private fun VideoCodec.toMediaCodecSelector(): MediaCodecSelector = when (this) {
|
||||
VideoCodec.AUTO -> MediaCodecSelector.DEFAULT
|
||||
|
||||
VideoCodec.FORCE_HW -> forceHardware()
|
||||
VideoCodec.FORCE_SW_H264 -> forceSoftwareForMime(MIME_H264)
|
||||
VideoCodec.FORCE_SW_H265 -> forceSoftwareForMime(MIME_HEVC)
|
||||
private fun VideoCodec.toDecoderType(): VideoDecodeThread.DecoderType = when (this) {
|
||||
VideoCodec.AUTO,
|
||||
VideoCodec.FORCE_HW -> VideoDecodeThread.DecoderType.HARDWARE
|
||||
VideoCodec.FORCE_SW_H264,
|
||||
VideoCodec.FORCE_SW_H265 -> VideoDecodeThread.DecoderType.SOFTWARE
|
||||
}
|
||||
|
||||
// ==================== 缩放模式 ====================
|
||||
|
||||
/**
|
||||
* 创建一个 [MediaCodecSelector],仅对指定 [mimeType] 强制软件解码。
|
||||
* 其余 MIME 类型使用默认行为。
|
||||
* 缩放模式说明:
|
||||
*
|
||||
* rtspclientlibrary 的 [RtspSurfaceView] 不做等比缩放/裁剪,surface 填满其布局
|
||||
* 区域,视频渲染为全幅铺满(与旧 ExoPlayer 的 FILL 行为一致)。本应用视频作为
|
||||
* 全屏背景层,默认 ZOOM 与 FILL 视觉近似,因此当前三种模式统一按全幅铺满处理;
|
||||
* 若需精确 FIT(留黑边)或 ZOOM(按视频宽高比裁剪),需在 [RtspStatusListener]
|
||||
* 的 onRtspFrameSizeChanged 中拿到视频尺寸后覆写视图测量,留待后续按需实现。
|
||||
*/
|
||||
private fun forceSoftwareForMime(targetMime: String): MediaCodecSelector = object : MediaCodecSelector {
|
||||
override fun getDecoderInfos(
|
||||
mimeType: String,
|
||||
requiresSecureDecoder: Boolean,
|
||||
requiresTunnelingDecoder: Boolean
|
||||
): List<MediaCodecInfo> {
|
||||
val all = MediaCodecSelector.DEFAULT.getDecoderInfos(
|
||||
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
|
||||
)
|
||||
// 只对目标 MIME 类型过滤,其余保持默认
|
||||
return if (mimeType == targetMime) {
|
||||
all.filter { !it.hardwareAccelerated }
|
||||
} else {
|
||||
all
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 [MediaCodecSelector],所有 MIME 类型仅保留硬件加速解码器。
|
||||
* 用于强制硬件解码模式(延迟优先)。
|
||||
*/
|
||||
private fun forceHardware(): MediaCodecSelector = object : MediaCodecSelector {
|
||||
override fun getDecoderInfos(
|
||||
mimeType: String,
|
||||
requiresSecureDecoder: Boolean,
|
||||
requiresTunnelingDecoder: Boolean
|
||||
): List<MediaCodecInfo> {
|
||||
return MediaCodecSelector.DEFAULT.getDecoderInfos(
|
||||
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
|
||||
).filter { it.hardwareAccelerated }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 缩放模式映射 ====================
|
||||
|
||||
/** 将 [VideoResizeMode] 映射为 [AspectRatioFrameLayout] 的 resizeMode 常量。 */
|
||||
private fun VideoResizeMode.toMedia3ResizeMode(): Int = when (this) {
|
||||
VideoResizeMode.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
VideoResizeMode.ZOOM -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
VideoResizeMode.FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
}
|
||||
Reference in New Issue
Block a user