From b4712a782c3fb9d4f5fa526c98017e27da842981 Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Fri, 24 Jul 2026 01:20:56 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=AD=BB=E4=BB=A3=E7=A0=81=20+=20RTSP=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BF=9E=E6=8C=87=E6=95=B0=E9=80=80?= =?UTF-8?q?=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ControlCommands.kt: 删除 subscribeStatus() 及 SUB_* 常量 - ProtocolClient.kt: 删除 sendSubscriptionRequests() 方法及调用 - RtspVideoPlayer.kt: 播放失败后自动重试,指数退避(1s~30s,倍率2),覆盖层显示重试次数和倒计时 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../m20_gamepad/network/ProtocolClient.kt | 22 ----- .../network/models/ControlCommands.kt | 9 -- .../m20_gamepad/video/RtspVideoPlayer.kt | 94 +++++++++++++++---- 3 files changed, 77 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt b/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt index ccab4cd..530a28c 100644 --- a/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt +++ b/app/src/main/java/com/example/m20_gamepad/network/ProtocolClient.kt @@ -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> = 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) diff --git a/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt index eb95cc6..ae6c255 100644 --- a/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt +++ b/app/src/main/java/com/example/m20_gamepad/network/models/ControlCommands.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt b/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt index 7596169..f1343fc 100644 --- a/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt +++ b/app/src/main/java/com/example/m20_gamepad/video/RtspVideoPlayer.kt @@ -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.Connecting) } + var state by remember { mutableStateOf(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 ) { - Text( - text = text, - color = Color.White - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = text, + 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) + ) + } + } } }