fix: 修复自编码模式黑屏及并发问题
- 修复 MediaCodec 回调需在 configure 前设置导致的异常 - 修复解码器线程安全问题及包乱序/重复处理 - 增加关键帧前重发 SPS/PPS 以防首帧丢失导致黑屏 - 支持分辨率切换时重置解码器 - 修复 Flutter 控制端连接类型选项 UI 显示拥挤问题
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -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<byte[]> 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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<byte[]> 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,6 +110,7 @@ public class SelfCodecDecoder {
|
||||
byte[] chunk = new byte[len];
|
||||
b.get(chunk);
|
||||
|
||||
synchronized (assembling) {
|
||||
ChunkBuffer cb = assembling.get(seq);
|
||||
if (cb == null) {
|
||||
cb = new ChunkBuffer();
|
||||
@@ -109,6 +137,7 @@ public class SelfCodecDecoder {
|
||||
if (unit != null) handleUnit(unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] join(ChunkBuffer cb) {
|
||||
int totalLen = 0;
|
||||
@@ -132,14 +161,25 @@ public class SelfCodecDecoder {
|
||||
b.get(data);
|
||||
|
||||
if (type == UNIT_TYPE_CONFIG) {
|
||||
if (configured) {
|
||||
// 已配置:忽略重复/初始参数集,避免重复以 CODEC_CONFIG 喂入报错
|
||||
return;
|
||||
}
|
||||
synchronized (pendingConfigs) {
|
||||
// 仅在尚未包含该配置时添加,避免关键帧前的重复重发堆积。
|
||||
// 关键:不因为 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(); // 已配置时也尝试喂入,以应对中途可能的参数集更新
|
||||
}
|
||||
} 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) {
|
||||
synchronized (inputQueue) {
|
||||
inputQueue.add(index);
|
||||
}
|
||||
feed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOutputBufferAvailable(MediaCodec mc, int index, MediaCodec.BufferInfo info) {
|
||||
try {
|
||||
mc.releaseOutputBuffer(index, true);
|
||||
} catch (IllegalStateException e) {
|
||||
Log.w(TAG, "onOutputBufferAvailable: codec might be stopped/released", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -199,6 +260,7 @@ public class SelfCodecDecoder {
|
||||
private void feed() {
|
||||
if (decoder == null || !configured) return;
|
||||
synchronized (pendingConfigs) {
|
||||
synchronized (inputQueue) {
|
||||
while (!pendingConfigs.isEmpty() && !inputQueue.isEmpty()) {
|
||||
byte[] cfg = pendingConfigs.poll();
|
||||
int idx = inputQueue.poll();
|
||||
@@ -215,7 +277,9 @@ public class SelfCodecDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized (frameQueue) {
|
||||
synchronized (inputQueue) {
|
||||
while (!frameQueue.isEmpty() && !inputQueue.isEmpty()) {
|
||||
Frame f = frameQueue.poll();
|
||||
int idx = inputQueue.poll();
|
||||
@@ -233,8 +297,14 @@ public class SelfCodecDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
synchronized (pendingConfigs) {
|
||||
pendingConfigs.clear();
|
||||
}
|
||||
synchronized (frameQueue) {
|
||||
frameQueue.clear();
|
||||
}
|
||||
synchronized (inputQueue) {
|
||||
inputQueue.clear();
|
||||
assembling.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,69 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
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<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
@@ -134,36 +197,36 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
content: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
CupertinoSegmentedControl<String>(
|
||||
children: const {
|
||||
'NONE': Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('免密连接'),
|
||||
buildOption(
|
||||
'NONE',
|
||||
'免密连接',
|
||||
'被控端弹出手动确认框,无需验证码或密码',
|
||||
setDialogState,
|
||||
),
|
||||
'CODE': Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('动态验证码'),
|
||||
buildOption(
|
||||
'CODE',
|
||||
'动态验证码',
|
||||
'使用一次性动态验证码鉴权',
|
||||
setDialogState,
|
||||
),
|
||||
'PASSWORD': Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('固定密码'),
|
||||
buildOption(
|
||||
'PASSWORD',
|
||||
'固定密码',
|
||||
'使用固定连接密码鉴权',
|
||||
setDialogState,
|
||||
),
|
||||
},
|
||||
groupValue: selectedType,
|
||||
onValueChanged: (v) => setDialogState(() => selectedType = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (selectedType == 'NONE')
|
||||
const Text(
|
||||
'免密连接:被控端将弹出手动确认对话框,无需输入验证码或密码。',
|
||||
style: TextStyle(fontSize: 13, color: CupertinoColors.systemGrey),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 4),
|
||||
CupertinoTextField(
|
||||
controller: valueController,
|
||||
placeholder: selectedType == 'PASSWORD' ? '请输入固定密码' : '请输入动态验证码',
|
||||
enabled: selectedType != 'NONE',
|
||||
placeholder: selectedType == 'NONE'
|
||||
? '免密连接,无需填写'
|
||||
: selectedType == 'PASSWORD'
|
||||
? '请输入固定密码'
|
||||
: '请输入动态验证码',
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -245,6 +308,14 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
});
|
||||
}
|
||||
};
|
||||
_controller!.onSelfCodecLost = () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selfCodecReady = false;
|
||||
_selfCodecTextureId = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
_controller!.onStreamModeReport = (mode) {
|
||||
if (mounted) setState(() => _streamMode = mode);
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user