chore: 清理订阅请求死代码 + RTSP 自动重连指数退避

- ControlCommands.kt: 删除 subscribeStatus() 及 SUB_* 常量
- ProtocolClient.kt: 删除 sendSubscriptionRequests() 方法及调用
- RtspVideoPlayer.kt: 播放失败后自动重试,指数退避(1s~30s,倍率2),覆盖层显示重试次数和倒计时

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-24 01:20:56 +08:00
parent 9883eb4b44
commit b4712a782c
3 changed files with 77 additions and 48 deletions
@@ -130,9 +130,6 @@ class ProtocolClient(
}
}
// 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests()
// 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
timeoutJob = scope.launch {
timeoutCheckLoop()
@@ -195,25 +192,6 @@ class ProtocolClient(
socket = null
}
/** 发送订阅请求 */
private fun sendSubscriptionRequests() {
val subscriptions: List<Pair<Int, Int>> = listOf(
ControlCommands.SUB_BASIC,
ControlCommands.SUB_MOTION_CONTROL,
ControlCommands.SUB_DEVICE,
ControlCommands.SUB_ERROR
)
val sock = socket ?: return
val addr = remoteAddress ?: return
for ((type, cmd) in subscriptions) {
try {
val json = ControlCommands.subscribeStatus(type, cmd)
val packet = encoder.encode(json)
sock.send(DatagramPacket(packet, packet.size, addr, port))
} catch (_: Exception) { }
}
}
/** 接收循环 */
private fun CoroutineScope.receiveLoop(sock: DatagramSocket) {
val buffer = ByteArray(65535)
@@ -122,10 +122,6 @@ object ControlCommands {
// ---- 2.10 休眠状态查询 ----
fun querySleepStatus(time: String = now()): String = buildAsdu(type = 1101, command = 7, time = time)
// ---- 订阅状态上报 ----
fun subscribeStatus(type: Int, command: Int, time: String = now()): String =
buildAsdu(type = type, command = command, time = time)
// ========== 常量 ==========
// 运动状态常量
@@ -147,9 +143,4 @@ object ControlCommands {
const val MODE_NAVIGATION = 1
const val MODE_ASSIST = 2
// 订阅 Type/Command — 用于 subscribeStatus()
val SUB_ERROR = Pair(1002, 3)
val SUB_MOTION_CONTROL = Pair(1002, 4)
val SUB_DEVICE = Pair(1002, 5)
val SUB_BASIC = Pair(1002, 6)
}
@@ -1,12 +1,15 @@
package com.example.m20_gamepad.video
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -14,6 +17,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
@@ -26,6 +32,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.example.m20_gamepad.data.VideoResizeMode
import kotlinx.coroutines.delay
/**
* 视频解码器选择策略。
@@ -44,13 +51,18 @@ enum class VideoCodec {
/** RTSP 播放状态 */
sealed class RtspState {
/** 连接中 */
object Connecting : RtspState()
data class Connecting(val retryCount: Int = 0) : RtspState()
/** 播放中 */
object Playing : RtspState()
/** 连接失败 */
data class Error(val message: String) : RtspState()
/** 连接失败(自动重试中) */
data class Error(val message: String, val retryCount: Int = 0, val nextRetryMs: Long = 0) : RtspState()
}
/** 指数退避参数 */
private const val RETRY_DELAY_INITIAL_MS = 1000L
private const val RETRY_DELAY_MAX_MS = 30_000L
private const val RETRY_DELAY_MULTIPLIER = 2
/**
* RTSP 视频流播放 Composable。
*
@@ -72,7 +84,10 @@ fun RtspVideoPlayer(
) {
val context = LocalContext.current
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting) }
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting()) }
var retryCount by remember { mutableIntStateOf(0) }
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
var retrySignal by remember { mutableIntStateOf(0) }
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
// 同时使用最小缓冲策略降低延迟
@@ -99,24 +114,42 @@ fun RtspVideoPlayer(
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
state = when (playbackState) {
Player.STATE_BUFFERING -> RtspState.Connecting
Player.STATE_BUFFERING -> RtspState.Connecting(retryCount)
Player.STATE_READY -> RtspState.Playing
Player.STATE_IDLE -> RtspState.Connecting
Player.STATE_IDLE -> RtspState.Connecting(retryCount)
else -> state
}
}
override fun onPlayerError(error: PlaybackException) {
state = RtspState.Error(error.message ?: "连接失败")
state = RtspState.Error(
message = error.message ?: "连接失败",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
// 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++
}
})
}
}
// URL 或 codec 变化时重新加载媒体源
LaunchedEffect(url, codec) {
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
LaunchedEffect(url, codec, retrySignal) {
if (url.isNotBlank()) {
state = RtspState.Connecting
if (retrySignal == 0) {
// 首次连接 / URL 或 codec 变更:重置退避
retryCount = 0
retryDelayMs = RETRY_DELAY_INITIAL_MS
} else {
// 重试:指数退避等待
delay(retryDelayMs)
retryDelayMs = (retryDelayMs * RETRY_DELAY_MULTIPLIER).coerceAtMost(RETRY_DELAY_MAX_MS)
retryCount++
}
state = RtspState.Connecting(retryCount)
player.stop()
player.clearMediaItems()
val mediaItem = MediaItem.fromUri(url)
player.setMediaItem(mediaItem)
player.prepare()
@@ -152,23 +185,50 @@ fun RtspVideoPlayer(
// 连接中 / 错误时的提示覆盖层
when (val s = state) {
is RtspState.Connecting -> StatusOverlay("连接 RTSP 流中...")
is RtspState.Error -> StatusOverlay("RTSP 连接失败: ${s.message}")
is RtspState.Connecting -> {
val msg = if (s.retryCount > 0) {
"RTSP 重连中... (第 ${s.retryCount} 次)"
} else {
"连接 RTSP 流中..."
}
StatusOverlay(msg)
}
is RtspState.Error -> {
val retryHint = if (s.nextRetryMs > 0) {
"${s.nextRetryMs / 1000}s 后自动重试"
} else ""
StatusOverlay(
text = "RTSP 连接失败: ${s.message}",
subtitle = retryHint
)
}
is RtspState.Playing -> { /* 正常播放,不显示覆盖层 */ }
}
}
}
@Composable
private fun StatusOverlay(text: String) {
private fun StatusOverlay(text: String, subtitle: String = "") {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = text,
color = Color.White
color = Color.White,
fontSize = 16.sp
)
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
color = Color.White.copy(alpha = 0.7f),
fontSize = 13.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}