diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index 34352cd..091d060 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -301,6 +301,11 @@ public class ScreenCaptureService extends Service { currentCaptureHeight = targetH; currentCaptureFps = targetFps; + // 关键:分辨率切换后,如果是自编码模式,同样需要触发扰动确保产生首帧,避免静态画面黑屏。 + if (currentStreamMode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC) { + updateNotification("正在切换分辨率..."); + } + Log.i(TAG, "Capture resolution changed -> " + targetW + "x" + targetH + " @ " + targetFps + "fps (remote=" + fromRemote + ")"); showToast(getString(R.string.resolution_changed_message, targetW, targetH)); @@ -354,7 +359,19 @@ public class ScreenCaptureService extends Service { if (webRtcClient != null) { webRtcClient.stopCapture(); } - startSelfCodecEncoder(); + if (!startSelfCodecEncoder()) { + // 启动失败(如 Android 14+ 不允许复用投屏授权令牌): + // 回退 WebRTC 模式并如实上报,避免两端都黑屏。 + Log.e(TAG, "switch to self codec failed, fallback to webrtc mode"); + if (webRtcClient != null) { + webRtcClient.startCapture(); + } + currentStreamMode = SelfCodecEncoder.STREAM_MODE_WEBRTC; + reportStreamMode(SelfCodecEncoder.STREAM_MODE_WEBRTC); + return; + } + // 关键:切换到自编码模式后,立即触发一次通知刷新(扰动),确保 MediaProjection 产生首帧并喂入编码器。 + updateNotification("正在使用自编码模式串流..."); } else { // 切回 WebRTC:停止自编码,恢复默认屏幕采集。 stopSelfCodecEncoder(); @@ -371,10 +388,11 @@ public class ScreenCaptureService extends Service { return currentStreamMode; } - private void startSelfCodecEncoder() { + /** 启动自编码通道。返回 false 表示启动失败(调用方应回退 WebRTC 模式)。 */ + private boolean startSelfCodecEncoder() { if (selfEncoder != null) { Log.w(TAG, "self codec encoder already running"); - return; + return true; } if (webRtcClient == null || !webRtcClient.isVideoDataChannelOpen()) { Log.w(TAG, "startSelfCodecEncoder: video channel not ready yet"); @@ -385,7 +403,7 @@ public class ScreenCaptureService extends Service { selfMediaProjection = mpm.getMediaProjection(resultCode, resultDataIntent); } catch (Exception e) { Log.e(TAG, "getMediaProjection for self codec failed", e); - return; + return false; } int dpi = getResources().getDisplayMetrics().densityDpi; int[] res = getCurrentCaptureResolution(); @@ -406,6 +424,7 @@ public class ScreenCaptureService extends Service { } }); selfEncoder.start(); + return true; } private void stopSelfCodecEncoder() { @@ -537,6 +556,16 @@ public class ScreenCaptureService extends Service { webRtcClient = new WebRtcClient(this, wsClient, deviceId); webRtcClient.initialize(eglBase); webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes)); + webRtcClient.setIceEventListener(() -> { + mainHandler.post(() -> { + if (currentStreamMode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC && selfEncoder != null) { + selfEncoder.requestKeyFrame(); + updateNotification("远程控制已连接 (自编码)"); + } else { + updateNotification("远程控制已连接"); + } + }); + }); // 注册远程控制断开回调:控制端断线时提示其用户名。 webRtcClient.setRemoteDisconnectCallback(controllerId -> mainHandler.post(() -> onControllerDisconnected(controllerId))); diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java index 57fe4cb..1d30813 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/SelfCodecEncoder.java @@ -48,6 +48,9 @@ public class SelfCodecEncoder { private boolean running = false; private int frameSeq = 0; private final Object sendLock = new Object(); + // 缓存 SPS/PPS 等参数集:video 通道不可靠(可能丢包),在每个关键帧前重发, + // 保证控制端即使错过首次 CONFIG 也能在下一个 IDR 前完成解码器配置。 + private final java.util.ArrayList cachedConfigs = new java.util.ArrayList<>(); public interface Callback { void onEncoderStarted(int w, int h, int fps); @@ -79,9 +82,10 @@ public class SelfCodecEncoder { format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); + // 异步回调需在 configure() 之前设置(部分系统版本在 configure 后设置会抛异常) + encoder.setCallback(encoderCallback); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); inputSurface = encoder.createInputSurface(); - encoder.setCallback(encoderCallback); encoder.start(); virtualDisplay = mediaProjection.createVirtualDisplay( @@ -132,8 +136,14 @@ public class SelfCodecEncoder { mc.releaseOutputBuffer(index, false); if (isConfig) { + cacheConfig(data); sendUnit(UNIT_TYPE_CONFIG, 0, false, data); } else { + if (isKey) { + // 关键帧前重发参数集:解码端已配置时会忽略重复 CONFIG, + // 未配置(首次 CONFIG 丢失/通道未就绪)时借此完成配置。 + resendCachedConfigs(); + } sendUnit(UNIT_TYPE_FRAME, pts, isKey, data); } } catch (Exception e) { @@ -152,8 +162,39 @@ public class SelfCodecEncoder { if (format == null) return; ByteBuffer sps = format.getByteBuffer("csd-0"); ByteBuffer pps = format.getByteBuffer("csd-1"); - if (sps != null) sendUnit(UNIT_TYPE_CONFIG, 0, false, toBytes(sps)); - if (pps != null) sendUnit(UNIT_TYPE_CONFIG, 0, false, toBytes(pps)); + if (sps != null) { + byte[] d = toBytes(sps); + cacheConfig(d); + sendUnit(UNIT_TYPE_CONFIG, 0, false, d); + } + if (pps != null) { + byte[] d = toBytes(pps); + cacheConfig(d); + sendUnit(UNIT_TYPE_CONFIG, 0, false, d); + } + } + + /** 缓存参数集(去重),供关键帧前重发。 */ + private void cacheConfig(byte[] data) { + if (data == null || data.length == 0) return; + synchronized (cachedConfigs) { + for (byte[] c : cachedConfigs) { + if (java.util.Arrays.equals(c, data)) return; + } + cachedConfigs.add(data); + } + } + + /** 在每个关键帧前重发缓存的参数集。 */ + private void resendCachedConfigs() { + byte[][] snapshot; + synchronized (cachedConfigs) { + if (cachedConfigs.isEmpty()) return; + snapshot = cachedConfigs.toArray(new byte[0][]); + } + for (byte[] c : snapshot) { + sendUnit(UNIT_TYPE_CONFIG, 0, false, c); + } } private static byte[] toBytes(ByteBuffer b) { diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java index 05b89ec..d4c7b04 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java @@ -72,6 +72,12 @@ public class WebRtcClient { private RemoteDisconnectCallback remoteDisconnectCallback; private boolean remoteDisconnectNotified = false; + public interface IceEventListener { + void onIceConnected(); + } + + private IceEventListener iceEventListener; + // 关键修复:控制端(网页)会在被控端弹出"接受"对话框期间就把 ICE 候选发过来, // 此时 peerConnection 尚未创建 / 远端描述(offer)尚未设置,直接 addIceCandidate 会被丢弃。 // 因此先把候选缓存起来,等 setRemoteDescription(offer) 完成后再统一 flush。 @@ -101,6 +107,10 @@ public class WebRtcClient { this.remoteDisconnectCallback = callback; } + public void setIceEventListener(IceEventListener listener) { + this.iceEventListener = listener; + } + private void notifyRemoteDisconnected() { if (remoteDisconnectNotified) return; remoteDisconnectNotified = true; @@ -266,8 +276,10 @@ public class WebRtcClient { public void onIceConnectionChange(PeerConnection.IceConnectionState newState) { Log.d(TAG, "ICE connection state: " + newState); if (newState == PeerConnection.IceConnectionState.CONNECTED) { - // 连接成功时,诱发编码器产生一个关键帧,而不是重启采集器 + // 连接成功时,诱发编码器产生一个关键帧。 + // 外部 ScreenCaptureService 可通过 registerObserver 等方式监听,或者我们直接在此处回调。 triggerKeyFrame(); + if (iceEventListener != null) iceEventListener.onIceConnected(); } else if (newState == PeerConnection.IceConnectionState.DISCONNECTED || newState == PeerConnection.IceConnectionState.FAILED || newState == PeerConnection.IceConnectionState.CLOSED) { @@ -631,4 +643,12 @@ public class WebRtcClient { } } } + + /** 即时请求自编码器产生一个关键帧。 */ + public void requestSelfCodecKeyFrame(SelfCodecEncoder encoder) { + if (encoder != null) { + Log.d(TAG, "Requesting key frame for self codec encoder"); + encoder.requestKeyFrame(); + } + } } diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/SelfCodecDecoder.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/SelfCodecDecoder.java index 1c269bc..6a8cea1 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/SelfCodecDecoder.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/SelfCodecDecoder.java @@ -2,6 +2,7 @@ package com.ttstd.controller.webrtc; import android.media.MediaCodec; import android.media.MediaFormat; +import android.os.Build; import android.util.Log; import android.view.Surface; @@ -33,6 +34,10 @@ public class SelfCodecDecoder { private boolean configured = false; private boolean enabled = false; + // 实际宽高(由被控端 REPORT_RESOLUTION 上报),若未上报则使用 PLACEHOLDER + private int videoWidth = PLACEHOLDER_W; + private int videoHeight = PLACEHOLDER_H; + // 待喂入的参数集(SPS/PPS 等),以 CODEC_CONFIG 形式输入解码器 private final ArrayDeque pendingConfigs = new ArrayDeque<>(); // 待解码帧 @@ -67,6 +72,23 @@ public class SelfCodecDecoder { } } + /** + * 更新当前视频流的实际宽高(由外部 WebRtcClient 收到被控端上报后调用)。 + * 如果宽高发生变化且解码器已配置,则重置解码器以应用新分辨率,避免黑屏或花屏。 + */ + public void updateResolution(int w, int h) { + if (w <= 0 || h <= 0) return; + if (this.videoWidth != w || this.videoHeight != h) { + Log.i(TAG, "Resolution update: " + videoWidth + "x" + videoHeight + " -> " + w + "x" + h); + this.videoWidth = w; + this.videoHeight = h; + if (configured) { + // 分辨率变化时需重置解码器以重新 configure + releaseDecoder(); + } + } + } + /** 收到 video DataChannel 的二进制消息(一条分片)。 */ public void onBinaryMessage(ByteBuffer raw) { if (!enabled) return; @@ -76,6 +98,11 @@ public class SelfCodecDecoder { byte magic = b.get(); if (magic != MAGIC) return; int seq = b.getInt(); + // 关键修复:增加包序号过期检查(处理包乱序或重复)。 + // 实时视频流中,如果收到了序号更旧的包,直接丢弃以保证延迟和状态一致性。 + if (lastSeq != -1 && seq <= lastSeq && (lastSeq - seq) < 1000) { + return; + } short total = b.getShort(); short idx = b.getShort(); int len = b.getInt(); @@ -83,30 +110,32 @@ public class SelfCodecDecoder { byte[] chunk = new byte[len]; b.get(chunk); - ChunkBuffer cb = assembling.get(seq); - if (cb == null) { - cb = new ChunkBuffer(); - cb.total = total; - cb.chunks = new byte[total][]; - cb.received = 0; - assembling.put(seq, cb); - if (assembling.size() > 24) { - int min = Integer.MAX_VALUE; - for (int k : assembling.keySet()) { - if (k < min) min = k; + synchronized (assembling) { + ChunkBuffer cb = assembling.get(seq); + if (cb == null) { + cb = new ChunkBuffer(); + cb.total = total; + cb.chunks = new byte[total][]; + cb.received = 0; + assembling.put(seq, cb); + if (assembling.size() > 24) { + int min = Integer.MAX_VALUE; + for (int k : assembling.keySet()) { + if (k < min) min = k; + } + assembling.remove(min); } - assembling.remove(min); } - } - if (idx >= 0 && idx < total && cb.chunks[idx] == null) { - cb.chunks[idx] = chunk; - cb.received++; - } - if (cb.received == total) { - assembling.remove(seq); - if (seq > lastSeq) lastSeq = seq; - byte[] unit = join(cb); - if (unit != null) handleUnit(unit); + if (idx >= 0 && idx < total && cb.chunks[idx] == null) { + cb.chunks[idx] = chunk; + cb.received++; + } + if (cb.received == total) { + assembling.remove(seq); + if (seq > lastSeq) lastSeq = seq; + byte[] unit = join(cb); + if (unit != null) handleUnit(unit); + } } } @@ -132,14 +161,25 @@ public class SelfCodecDecoder { b.get(data); if (type == UNIT_TYPE_CONFIG) { - if (configured) { - // 已配置:忽略重复/初始参数集,避免重复以 CODEC_CONFIG 喂入报错 - return; - } synchronized (pendingConfigs) { - pendingConfigs.add(data); + // 仅在尚未包含该配置时添加,避免关键帧前的重复重发堆积。 + // 关键:不因为 configured 而 return,确保 SPS 和 PPS 都能被加入队列。 + boolean exists = false; + for (byte[] c : pendingConfigs) { + if (java.util.Arrays.equals(c, data)) { + exists = true; + break; + } + } + if (!exists) { + pendingConfigs.add(data); + } + } + if (!configured) { + tryConfigure(); + } else { + feed(); // 已配置时也尝试喂入,以应对中途可能的参数集更新 } - tryConfigure(); } else if (type == UNIT_TYPE_FRAME) { Frame f = new Frame(); f.data = data; @@ -160,29 +200,50 @@ public class SelfCodecDecoder { } try { MediaFormat format = MediaFormat.createVideoFormat( - MediaFormat.MIMETYPE_VIDEO_AVC, PLACEHOLDER_W, PLACEHOLDER_H); + MediaFormat.MIMETYPE_VIDEO_AVC, videoWidth, videoHeight); + // 关键:对部分芯片组(如 MTK/Exynos),低延迟标志有助于减少首帧黑屏时间(API 30+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); + } decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); - decoder.configure(format, surface, null, 0); + // 异步回调必须在 configure() 之前设置,否则抛 IllegalStateException, + // 解码器将永远无法进入工作状态(黑屏)。 decoder.setCallback(decoderCallback); + decoder.configure(format, surface, null, 0); decoder.start(); configured = true; Log.i(TAG, "self codec decoder configured"); feed(); } catch (Exception e) { Log.e(TAG, "decoder configure failed", e); + // 清理半初始化的解码器,等待下一个 CONFIG 到达后重试 + if (decoder != null) { + try { + decoder.release(); + } catch (Exception ignored) { + } + decoder = null; + } + configured = false; } } private final MediaCodec.Callback decoderCallback = new MediaCodec.Callback() { @Override public void onInputBufferAvailable(MediaCodec mc, int index) { - inputQueue.add(index); + synchronized (inputQueue) { + inputQueue.add(index); + } feed(); } @Override public void onOutputBufferAvailable(MediaCodec mc, int index, MediaCodec.BufferInfo info) { - mc.releaseOutputBuffer(index, true); + try { + mc.releaseOutputBuffer(index, true); + } catch (IllegalStateException e) { + Log.w(TAG, "onOutputBufferAvailable: codec might be stopped/released", e); + } } @Override @@ -199,42 +260,51 @@ public class SelfCodecDecoder { private void feed() { if (decoder == null || !configured) return; synchronized (pendingConfigs) { - while (!pendingConfigs.isEmpty() && !inputQueue.isEmpty()) { - byte[] cfg = pendingConfigs.poll(); - int idx = inputQueue.poll(); - try { - ByteBuffer ib = decoder.getInputBuffer(idx); - ib.clear(); - ib.put(cfg); - decoder.queueInputBuffer(idx, 0, cfg.length, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG); - } catch (Exception e) { - Log.e(TAG, "queue config failed", e); - pendingConfigs.addFirst(cfg); - inputQueue.addFirst(idx); - break; + synchronized (inputQueue) { + while (!pendingConfigs.isEmpty() && !inputQueue.isEmpty()) { + byte[] cfg = pendingConfigs.poll(); + int idx = inputQueue.poll(); + try { + ByteBuffer ib = decoder.getInputBuffer(idx); + ib.clear(); + ib.put(cfg); + decoder.queueInputBuffer(idx, 0, cfg.length, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG); + } catch (Exception e) { + Log.e(TAG, "queue config failed", e); + pendingConfigs.addFirst(cfg); + inputQueue.addFirst(idx); + break; + } } } } synchronized (frameQueue) { - while (!frameQueue.isEmpty() && !inputQueue.isEmpty()) { - Frame f = frameQueue.poll(); - int idx = inputQueue.poll(); - try { - ByteBuffer ib = decoder.getInputBuffer(idx); - ib.clear(); - ib.put(f.data); - decoder.queueInputBuffer(idx, 0, f.data.length, f.pts, f.flags); - } catch (Exception e) { - Log.e(TAG, "queue frame failed", e); - frameQueue.addFirst(f); - inputQueue.addFirst(idx); - break; + synchronized (inputQueue) { + while (!frameQueue.isEmpty() && !inputQueue.isEmpty()) { + Frame f = frameQueue.poll(); + int idx = inputQueue.poll(); + try { + ByteBuffer ib = decoder.getInputBuffer(idx); + ib.clear(); + ib.put(f.data); + decoder.queueInputBuffer(idx, 0, f.data.length, f.pts, f.flags); + } catch (Exception e) { + Log.e(TAG, "queue frame failed", e); + frameQueue.addFirst(f); + inputQueue.addFirst(idx); + break; + } } } } } private void releaseDecoder() { + configured = false; + synchronized (assembling) { + lastSeq = -1; // 释放时重置序号,避免重连后首帧被误判为过期 + assembling.clear(); + } if (decoder != null) { try { decoder.stop(); @@ -246,11 +316,15 @@ public class SelfCodecDecoder { } decoder = null; } - configured = false; - pendingConfigs.clear(); - frameQueue.clear(); - inputQueue.clear(); - assembling.clear(); + synchronized (pendingConfigs) { + pendingConfigs.clear(); + } + synchronized (frameQueue) { + frameQueue.clear(); + } + synchronized (inputQueue) { + inputQueue.clear(); + } } public void release() { diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java index bf8c7cd..7f1f72c 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java @@ -318,6 +318,15 @@ public class WebRtcClient { } private void handleDataChannel(DataChannel dc) { + if (dc == null) return; + // 关键修复:区分控制端创建的两条通道。 + // 虽然本端是创建方,但 PeerObserver.onDataChannel 仍可能在某些 WebRTC 协商逻辑中被触发。 + if (VIDEO_CHANNEL_LABEL.equals(dc.label())) { + this.videoDataChannel = dc; + setupVideoDataChannel(dc); + return; + } + this.dataChannel = dc; dc.registerObserver(new DataChannel.Observer() { @Override public void onBufferedAmountChange(long previousAmount) {} @@ -337,14 +346,18 @@ public class WebRtcClient { if (buffer == null || buffer.data == null) return; byte[] data = new byte[buffer.data.remaining()]; buffer.data.get(data); - // 若收到被控端回报的串流模式,单独处理。 + // 收到被控端回报的消息,按 Action 分发处理。 try { ControlMessage msg = ControlMessage.parseFrom(data); if (msg.getAction() == Action.REPORT_STREAM_MODE) { if (streamModeReportListener != null) { streamModeReportListener.onStreamModeReported(msg.getStreamMode()); } - return; + } else if (msg.getAction() == Action.REPORT_RESOLUTION) { + Log.i(TAG, "Received resolution report: " + msg.getWidth() + "x" + msg.getHeight()); + if (selfCodecDecoder != null) { + selfCodecDecoder.updateResolution(msg.getWidth(), msg.getHeight()); + } } } catch (Exception ignored) { } diff --git a/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt b/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt index f2934a7..5c5a44b 100644 --- a/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt +++ b/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt @@ -141,8 +141,9 @@ class SelfCodecDecoderPlugin : val st = entry.surfaceTexture() surfaceTexture = st st.setDefaultBufferSize(PLACEHOLDER_W, PLACEHOLDER_H) + // MediaCodec 把解码帧渲染到该 SurfaceTexture。必须消费(updateTexImage) + // 新帧,否则 SurfaceTexture 一直停留在初始(黑)帧,导致切换自编码后无画面。 st.setOnFrameAvailableListener { texture: SurfaceTexture -> - // 解码器将帧渲染到 Surface 后触发,这里更新纹理内容供 Flutter 显示。 try { texture.updateTexImage() } catch (e: Exception) { @@ -251,8 +252,11 @@ class SelfCodecDecoderPlugin : } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - val w = format.getInteger(MediaFormat.KEY_WIDTH, 0) - val h = format.getInteger(MediaFormat.KEY_HEIGHT, 0) + // getInteger(key, default) 需要 API 29+,用 containsKey 兼容低版本 + val w = if (format.containsKey(MediaFormat.KEY_WIDTH)) + format.getInteger(MediaFormat.KEY_WIDTH) else 0 + val h = if (format.containsKey(MediaFormat.KEY_HEIGHT)) + format.getInteger(MediaFormat.KEY_HEIGHT) else 0 if (w > 0 && h > 0) { surfaceTexture?.setDefaultBufferSize(w, h) mainHandler.post { diff --git a/webrtc_controller_flutter/lib/controller/remote_controller.dart b/webrtc_controller_flutter/lib/controller/remote_controller.dart index 1352d08..734715f 100644 --- a/webrtc_controller_flutter/lib/controller/remote_controller.dart +++ b/webrtc_controller_flutter/lib/controller/remote_controller.dart @@ -27,6 +27,9 @@ class RemoteController { /// WebRTC 连接建立(可开始远程控制)。 void Function()? onConnectionEstablished; + /// 连接失败。 + void Function(String error)? onConnectionFailed; + /// 连接断开。 void Function()? onDisconnected; @@ -48,6 +51,9 @@ class RemoteController { /// 自编码解码纹理已就绪(textureId >= 0)。 void Function(int textureId)? onSelfCodecReady; + /// 自编码解码器已释放(连接断开时)。 + void Function()? onSelfCodecLost; + /// 被控端上报当前生效的串流模式。 void Function(int mode)? onStreamModeReport; @@ -99,17 +105,20 @@ class RemoteController { onConnectionEstablished?.call(); _startStats(); }; + _webRtc!.onConnectionFailed = (error) => onConnectionFailed?.call(error); _webRtc!.onDisconnected = () { onDisconnected?.call(); }; _webRtc!.onIceDisconnected = (message) => onIceDisconnected?.call(message); _webRtc!.onRemoteStream = (renderer) => onRemoteStream?.call(renderer); _webRtc!.onSelfCodecReady = (id) => onSelfCodecReady?.call(id); + _webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call(); _webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode); _webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h); _webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call(); _webRtc!.initialize().catchError((e) { onStatusChanged?.call('状态: 连接失败 - $e'); + onConnectionFailed?.call(e.toString()); }); } diff --git a/webrtc_controller_flutter/lib/main.dart b/webrtc_controller_flutter/lib/main.dart index 1fd0d0e..f615098 100644 --- a/webrtc_controller_flutter/lib/main.dart +++ b/webrtc_controller_flutter/lib/main.dart @@ -126,6 +126,69 @@ class _ControllerHomeState extends State { String selectedType = 'NONE'; final valueController = TextEditingController(); + // 纵向选项列表,避免分段控件在窄弹窗中把中文选项挤压成两行。 + Widget buildOption( + String value, + String title, + String desc, + void Function(void Function()) setDialogState, + ) { + final selected = selectedType == value; + return GestureDetector( + onTap: () => setDialogState(() => selectedType = value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + border: Border.all( + color: selected + ? CupertinoColors.activeBlue + : CupertinoColors.systemGrey4, + ), + borderRadius: BorderRadius.circular(10), + color: selected + ? CupertinoColors.activeBlue.withValues(alpha: 0.06) + : null, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: const TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + selected + ? CupertinoIcons.check_mark_circled_solid + : CupertinoIcons.circle, + color: selected + ? CupertinoColors.activeBlue + : CupertinoColors.systemGrey, + ), + ], + ), + ), + ); + } + await showCupertinoDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -134,37 +197,37 @@ class _ControllerHomeState extends State { content: Column( children: [ const SizedBox(height: 12), - CupertinoSegmentedControl( - children: const { - 'NONE': Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('免密连接'), - ), - 'CODE': Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('动态验证码'), - ), - 'PASSWORD': Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('固定密码'), - ), - }, - groupValue: selectedType, - onValueChanged: (v) => setDialogState(() => selectedType = v), + buildOption( + 'NONE', + '免密连接', + '被控端弹出手动确认框,无需验证码或密码', + setDialogState, + ), + buildOption( + 'CODE', + '动态验证码', + '使用一次性动态验证码鉴权', + setDialogState, + ), + buildOption( + 'PASSWORD', + '固定密码', + '使用固定连接密码鉴权', + setDialogState, + ), + const SizedBox(height: 4), + CupertinoTextField( + controller: valueController, + enabled: selectedType != 'NONE', + placeholder: selectedType == 'NONE' + ? '免密连接,无需填写' + : selectedType == 'PASSWORD' + ? '请输入固定密码' + : '请输入动态验证码', + obscureText: true, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - const SizedBox(height: 12), - if (selectedType == 'NONE') - const Text( - '免密连接:被控端将弹出手动确认对话框,无需输入验证码或密码。', - style: TextStyle(fontSize: 13, color: CupertinoColors.systemGrey), - ) - else - CupertinoTextField( - controller: valueController, - placeholder: selectedType == 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码', - obscureText: true, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), ], ), actions: [ @@ -245,6 +308,14 @@ class _ControllerHomeState extends State { }); } }; + _controller!.onSelfCodecLost = () { + if (mounted) { + setState(() { + _selfCodecReady = false; + _selfCodecTextureId = null; + }); + } + }; _controller!.onStreamModeReport = (mode) { if (mounted) setState(() => _streamMode = mode); }; diff --git a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart index 562db58..4a7f6da 100644 --- a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart @@ -217,7 +217,13 @@ class WebRtcController { switch (msg.action) { case Action.REPORT_STREAM_MODE: _currentStreamMode = msg.streamMode; - _selfCodecDecoder?.setEnabled(msg.streamMode == streamModeSelfCodec); + if (msg.streamMode == streamModeSelfCodec) { + _ensureSelfCodecDecoder().then((_) { + _selfCodecDecoder?.setEnabled(true); + }); + } else { + _selfCodecDecoder?.setEnabled(false); + } onStreamModeReport?.call(msg.streamMode); break; case Action.REPORT_RESOLUTION: