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:
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user