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