fix: 修复自编码模式黑屏及并发问题
- 修复 MediaCodec 回调需在 configure 前设置导致的异常 - 修复解码器线程安全问题及包乱序/重复处理 - 增加关键帧前重发 SPS/PPS 以防首帧丢失导致黑屏 - 支持分辨率切换时重置解码器 - 修复 Flutter 控制端连接类型选项 UI 显示拥挤问题
This commit is contained in:
@@ -301,6 +301,11 @@ public class ScreenCaptureService extends Service {
|
|||||||
currentCaptureHeight = targetH;
|
currentCaptureHeight = targetH;
|
||||||
currentCaptureFps = targetFps;
|
currentCaptureFps = targetFps;
|
||||||
|
|
||||||
|
// 关键:分辨率切换后,如果是自编码模式,同样需要触发扰动确保产生首帧,避免静态画面黑屏。
|
||||||
|
if (currentStreamMode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC) {
|
||||||
|
updateNotification("正在切换分辨率...");
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Capture resolution changed -> " + targetW + "x" + targetH + " @ " + targetFps + "fps (remote=" + fromRemote + ")");
|
Log.i(TAG, "Capture resolution changed -> " + targetW + "x" + targetH + " @ " + targetFps + "fps (remote=" + fromRemote + ")");
|
||||||
showToast(getString(R.string.resolution_changed_message, targetW, targetH));
|
showToast(getString(R.string.resolution_changed_message, targetW, targetH));
|
||||||
|
|
||||||
@@ -354,7 +359,19 @@ public class ScreenCaptureService extends Service {
|
|||||||
if (webRtcClient != null) {
|
if (webRtcClient != null) {
|
||||||
webRtcClient.stopCapture();
|
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 {
|
} else {
|
||||||
// 切回 WebRTC:停止自编码,恢复默认屏幕采集。
|
// 切回 WebRTC:停止自编码,恢复默认屏幕采集。
|
||||||
stopSelfCodecEncoder();
|
stopSelfCodecEncoder();
|
||||||
@@ -371,10 +388,11 @@ public class ScreenCaptureService extends Service {
|
|||||||
return currentStreamMode;
|
return currentStreamMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startSelfCodecEncoder() {
|
/** 启动自编码通道。返回 false 表示启动失败(调用方应回退 WebRTC 模式)。 */
|
||||||
|
private boolean startSelfCodecEncoder() {
|
||||||
if (selfEncoder != null) {
|
if (selfEncoder != null) {
|
||||||
Log.w(TAG, "self codec encoder already running");
|
Log.w(TAG, "self codec encoder already running");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (webRtcClient == null || !webRtcClient.isVideoDataChannelOpen()) {
|
if (webRtcClient == null || !webRtcClient.isVideoDataChannelOpen()) {
|
||||||
Log.w(TAG, "startSelfCodecEncoder: video channel not ready yet");
|
Log.w(TAG, "startSelfCodecEncoder: video channel not ready yet");
|
||||||
@@ -385,7 +403,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
selfMediaProjection = mpm.getMediaProjection(resultCode, resultDataIntent);
|
selfMediaProjection = mpm.getMediaProjection(resultCode, resultDataIntent);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "getMediaProjection for self codec failed", e);
|
Log.e(TAG, "getMediaProjection for self codec failed", e);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
int dpi = getResources().getDisplayMetrics().densityDpi;
|
int dpi = getResources().getDisplayMetrics().densityDpi;
|
||||||
int[] res = getCurrentCaptureResolution();
|
int[] res = getCurrentCaptureResolution();
|
||||||
@@ -406,6 +424,7 @@ public class ScreenCaptureService extends Service {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
selfEncoder.start();
|
selfEncoder.start();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopSelfCodecEncoder() {
|
private void stopSelfCodecEncoder() {
|
||||||
@@ -537,6 +556,16 @@ public class ScreenCaptureService extends Service {
|
|||||||
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
|
webRtcClient = new WebRtcClient(this, wsClient, deviceId);
|
||||||
webRtcClient.initialize(eglBase);
|
webRtcClient.initialize(eglBase);
|
||||||
webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes));
|
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 ->
|
webRtcClient.setRemoteDisconnectCallback(controllerId ->
|
||||||
mainHandler.post(() -> onControllerDisconnected(controllerId)));
|
mainHandler.post(() -> onControllerDisconnected(controllerId)));
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ public class SelfCodecEncoder {
|
|||||||
private boolean running = false;
|
private boolean running = false;
|
||||||
private int frameSeq = 0;
|
private int frameSeq = 0;
|
||||||
private final Object sendLock = new Object();
|
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 {
|
public interface Callback {
|
||||||
void onEncoderStarted(int w, int h, int fps);
|
void onEncoderStarted(int w, int h, int fps);
|
||||||
@@ -79,9 +82,10 @@ public class SelfCodecEncoder {
|
|||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
|
||||||
|
|
||||||
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
|
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||||
|
// 异步回调需在 configure() 之前设置(部分系统版本在 configure 后设置会抛异常)
|
||||||
|
encoder.setCallback(encoderCallback);
|
||||||
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||||
inputSurface = encoder.createInputSurface();
|
inputSurface = encoder.createInputSurface();
|
||||||
encoder.setCallback(encoderCallback);
|
|
||||||
encoder.start();
|
encoder.start();
|
||||||
|
|
||||||
virtualDisplay = mediaProjection.createVirtualDisplay(
|
virtualDisplay = mediaProjection.createVirtualDisplay(
|
||||||
@@ -132,8 +136,14 @@ public class SelfCodecEncoder {
|
|||||||
mc.releaseOutputBuffer(index, false);
|
mc.releaseOutputBuffer(index, false);
|
||||||
|
|
||||||
if (isConfig) {
|
if (isConfig) {
|
||||||
|
cacheConfig(data);
|
||||||
sendUnit(UNIT_TYPE_CONFIG, 0, false, data);
|
sendUnit(UNIT_TYPE_CONFIG, 0, false, data);
|
||||||
} else {
|
} else {
|
||||||
|
if (isKey) {
|
||||||
|
// 关键帧前重发参数集:解码端已配置时会忽略重复 CONFIG,
|
||||||
|
// 未配置(首次 CONFIG 丢失/通道未就绪)时借此完成配置。
|
||||||
|
resendCachedConfigs();
|
||||||
|
}
|
||||||
sendUnit(UNIT_TYPE_FRAME, pts, isKey, data);
|
sendUnit(UNIT_TYPE_FRAME, pts, isKey, data);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -152,8 +162,39 @@ public class SelfCodecEncoder {
|
|||||||
if (format == null) return;
|
if (format == null) return;
|
||||||
ByteBuffer sps = format.getByteBuffer("csd-0");
|
ByteBuffer sps = format.getByteBuffer("csd-0");
|
||||||
ByteBuffer pps = format.getByteBuffer("csd-1");
|
ByteBuffer pps = format.getByteBuffer("csd-1");
|
||||||
if (sps != null) sendUnit(UNIT_TYPE_CONFIG, 0, false, toBytes(sps));
|
if (sps != null) {
|
||||||
if (pps != null) sendUnit(UNIT_TYPE_CONFIG, 0, false, toBytes(pps));
|
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) {
|
private static byte[] toBytes(ByteBuffer b) {
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ public class WebRtcClient {
|
|||||||
private RemoteDisconnectCallback remoteDisconnectCallback;
|
private RemoteDisconnectCallback remoteDisconnectCallback;
|
||||||
private boolean remoteDisconnectNotified = false;
|
private boolean remoteDisconnectNotified = false;
|
||||||
|
|
||||||
|
public interface IceEventListener {
|
||||||
|
void onIceConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IceEventListener iceEventListener;
|
||||||
|
|
||||||
// 关键修复:控制端(网页)会在被控端弹出"接受"对话框期间就把 ICE 候选发过来,
|
// 关键修复:控制端(网页)会在被控端弹出"接受"对话框期间就把 ICE 候选发过来,
|
||||||
// 此时 peerConnection 尚未创建 / 远端描述(offer)尚未设置,直接 addIceCandidate 会被丢弃。
|
// 此时 peerConnection 尚未创建 / 远端描述(offer)尚未设置,直接 addIceCandidate 会被丢弃。
|
||||||
// 因此先把候选缓存起来,等 setRemoteDescription(offer) 完成后再统一 flush。
|
// 因此先把候选缓存起来,等 setRemoteDescription(offer) 完成后再统一 flush。
|
||||||
@@ -101,6 +107,10 @@ public class WebRtcClient {
|
|||||||
this.remoteDisconnectCallback = callback;
|
this.remoteDisconnectCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setIceEventListener(IceEventListener listener) {
|
||||||
|
this.iceEventListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
private void notifyRemoteDisconnected() {
|
private void notifyRemoteDisconnected() {
|
||||||
if (remoteDisconnectNotified) return;
|
if (remoteDisconnectNotified) return;
|
||||||
remoteDisconnectNotified = true;
|
remoteDisconnectNotified = true;
|
||||||
@@ -266,8 +276,10 @@ public class WebRtcClient {
|
|||||||
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
||||||
Log.d(TAG, "ICE connection state: " + newState);
|
Log.d(TAG, "ICE connection state: " + newState);
|
||||||
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||||
// 连接成功时,诱发编码器产生一个关键帧,而不是重启采集器
|
// 连接成功时,诱发编码器产生一个关键帧。
|
||||||
|
// 外部 ScreenCaptureService 可通过 registerObserver 等方式监听,或者我们直接在此处回调。
|
||||||
triggerKeyFrame();
|
triggerKeyFrame();
|
||||||
|
if (iceEventListener != null) iceEventListener.onIceConnected();
|
||||||
} else if (newState == PeerConnection.IceConnectionState.DISCONNECTED
|
} else if (newState == PeerConnection.IceConnectionState.DISCONNECTED
|
||||||
|| newState == PeerConnection.IceConnectionState.FAILED
|
|| newState == PeerConnection.IceConnectionState.FAILED
|
||||||
|| newState == PeerConnection.IceConnectionState.CLOSED) {
|
|| 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.MediaCodec;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
|
import android.os.Build;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
@@ -33,6 +34,10 @@ public class SelfCodecDecoder {
|
|||||||
private boolean configured = false;
|
private boolean configured = false;
|
||||||
private boolean enabled = false;
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
// 实际宽高(由被控端 REPORT_RESOLUTION 上报),若未上报则使用 PLACEHOLDER
|
||||||
|
private int videoWidth = PLACEHOLDER_W;
|
||||||
|
private int videoHeight = PLACEHOLDER_H;
|
||||||
|
|
||||||
// 待喂入的参数集(SPS/PPS 等),以 CODEC_CONFIG 形式输入解码器
|
// 待喂入的参数集(SPS/PPS 等),以 CODEC_CONFIG 形式输入解码器
|
||||||
private final ArrayDeque<byte[]> pendingConfigs = new ArrayDeque<>();
|
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 的二进制消息(一条分片)。 */
|
/** 收到 video DataChannel 的二进制消息(一条分片)。 */
|
||||||
public void onBinaryMessage(ByteBuffer raw) {
|
public void onBinaryMessage(ByteBuffer raw) {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -76,6 +98,11 @@ public class SelfCodecDecoder {
|
|||||||
byte magic = b.get();
|
byte magic = b.get();
|
||||||
if (magic != MAGIC) return;
|
if (magic != MAGIC) return;
|
||||||
int seq = b.getInt();
|
int seq = b.getInt();
|
||||||
|
// 关键修复:增加包序号过期检查(处理包乱序或重复)。
|
||||||
|
// 实时视频流中,如果收到了序号更旧的包,直接丢弃以保证延迟和状态一致性。
|
||||||
|
if (lastSeq != -1 && seq <= lastSeq && (lastSeq - seq) < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
short total = b.getShort();
|
short total = b.getShort();
|
||||||
short idx = b.getShort();
|
short idx = b.getShort();
|
||||||
int len = b.getInt();
|
int len = b.getInt();
|
||||||
@@ -83,30 +110,32 @@ public class SelfCodecDecoder {
|
|||||||
byte[] chunk = new byte[len];
|
byte[] chunk = new byte[len];
|
||||||
b.get(chunk);
|
b.get(chunk);
|
||||||
|
|
||||||
ChunkBuffer cb = assembling.get(seq);
|
synchronized (assembling) {
|
||||||
if (cb == null) {
|
ChunkBuffer cb = assembling.get(seq);
|
||||||
cb = new ChunkBuffer();
|
if (cb == null) {
|
||||||
cb.total = total;
|
cb = new ChunkBuffer();
|
||||||
cb.chunks = new byte[total][];
|
cb.total = total;
|
||||||
cb.received = 0;
|
cb.chunks = new byte[total][];
|
||||||
assembling.put(seq, cb);
|
cb.received = 0;
|
||||||
if (assembling.size() > 24) {
|
assembling.put(seq, cb);
|
||||||
int min = Integer.MAX_VALUE;
|
if (assembling.size() > 24) {
|
||||||
for (int k : assembling.keySet()) {
|
int min = Integer.MAX_VALUE;
|
||||||
if (k < min) min = k;
|
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) {
|
||||||
if (idx >= 0 && idx < total && cb.chunks[idx] == null) {
|
cb.chunks[idx] = chunk;
|
||||||
cb.chunks[idx] = chunk;
|
cb.received++;
|
||||||
cb.received++;
|
}
|
||||||
}
|
if (cb.received == total) {
|
||||||
if (cb.received == total) {
|
assembling.remove(seq);
|
||||||
assembling.remove(seq);
|
if (seq > lastSeq) lastSeq = seq;
|
||||||
if (seq > lastSeq) lastSeq = seq;
|
byte[] unit = join(cb);
|
||||||
byte[] unit = join(cb);
|
if (unit != null) handleUnit(unit);
|
||||||
if (unit != null) handleUnit(unit);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +161,25 @@ public class SelfCodecDecoder {
|
|||||||
b.get(data);
|
b.get(data);
|
||||||
|
|
||||||
if (type == UNIT_TYPE_CONFIG) {
|
if (type == UNIT_TYPE_CONFIG) {
|
||||||
if (configured) {
|
|
||||||
// 已配置:忽略重复/初始参数集,避免重复以 CODEC_CONFIG 喂入报错
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
synchronized (pendingConfigs) {
|
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) {
|
} else if (type == UNIT_TYPE_FRAME) {
|
||||||
Frame f = new Frame();
|
Frame f = new Frame();
|
||||||
f.data = data;
|
f.data = data;
|
||||||
@@ -160,29 +200,50 @@ public class SelfCodecDecoder {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(
|
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 = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||||
decoder.configure(format, surface, null, 0);
|
// 异步回调必须在 configure() 之前设置,否则抛 IllegalStateException,
|
||||||
|
// 解码器将永远无法进入工作状态(黑屏)。
|
||||||
decoder.setCallback(decoderCallback);
|
decoder.setCallback(decoderCallback);
|
||||||
|
decoder.configure(format, surface, null, 0);
|
||||||
decoder.start();
|
decoder.start();
|
||||||
configured = true;
|
configured = true;
|
||||||
Log.i(TAG, "self codec decoder configured");
|
Log.i(TAG, "self codec decoder configured");
|
||||||
feed();
|
feed();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "decoder configure failed", 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() {
|
private final MediaCodec.Callback decoderCallback = new MediaCodec.Callback() {
|
||||||
@Override
|
@Override
|
||||||
public void onInputBufferAvailable(MediaCodec mc, int index) {
|
public void onInputBufferAvailable(MediaCodec mc, int index) {
|
||||||
inputQueue.add(index);
|
synchronized (inputQueue) {
|
||||||
|
inputQueue.add(index);
|
||||||
|
}
|
||||||
feed();
|
feed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onOutputBufferAvailable(MediaCodec mc, int index, MediaCodec.BufferInfo info) {
|
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
|
@Override
|
||||||
@@ -199,42 +260,51 @@ public class SelfCodecDecoder {
|
|||||||
private void feed() {
|
private void feed() {
|
||||||
if (decoder == null || !configured) return;
|
if (decoder == null || !configured) return;
|
||||||
synchronized (pendingConfigs) {
|
synchronized (pendingConfigs) {
|
||||||
while (!pendingConfigs.isEmpty() && !inputQueue.isEmpty()) {
|
synchronized (inputQueue) {
|
||||||
byte[] cfg = pendingConfigs.poll();
|
while (!pendingConfigs.isEmpty() && !inputQueue.isEmpty()) {
|
||||||
int idx = inputQueue.poll();
|
byte[] cfg = pendingConfigs.poll();
|
||||||
try {
|
int idx = inputQueue.poll();
|
||||||
ByteBuffer ib = decoder.getInputBuffer(idx);
|
try {
|
||||||
ib.clear();
|
ByteBuffer ib = decoder.getInputBuffer(idx);
|
||||||
ib.put(cfg);
|
ib.clear();
|
||||||
decoder.queueInputBuffer(idx, 0, cfg.length, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG);
|
ib.put(cfg);
|
||||||
} catch (Exception e) {
|
decoder.queueInputBuffer(idx, 0, cfg.length, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG);
|
||||||
Log.e(TAG, "queue config failed", e);
|
} catch (Exception e) {
|
||||||
pendingConfigs.addFirst(cfg);
|
Log.e(TAG, "queue config failed", e);
|
||||||
inputQueue.addFirst(idx);
|
pendingConfigs.addFirst(cfg);
|
||||||
break;
|
inputQueue.addFirst(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
synchronized (frameQueue) {
|
synchronized (frameQueue) {
|
||||||
while (!frameQueue.isEmpty() && !inputQueue.isEmpty()) {
|
synchronized (inputQueue) {
|
||||||
Frame f = frameQueue.poll();
|
while (!frameQueue.isEmpty() && !inputQueue.isEmpty()) {
|
||||||
int idx = inputQueue.poll();
|
Frame f = frameQueue.poll();
|
||||||
try {
|
int idx = inputQueue.poll();
|
||||||
ByteBuffer ib = decoder.getInputBuffer(idx);
|
try {
|
||||||
ib.clear();
|
ByteBuffer ib = decoder.getInputBuffer(idx);
|
||||||
ib.put(f.data);
|
ib.clear();
|
||||||
decoder.queueInputBuffer(idx, 0, f.data.length, f.pts, f.flags);
|
ib.put(f.data);
|
||||||
} catch (Exception e) {
|
decoder.queueInputBuffer(idx, 0, f.data.length, f.pts, f.flags);
|
||||||
Log.e(TAG, "queue frame failed", e);
|
} catch (Exception e) {
|
||||||
frameQueue.addFirst(f);
|
Log.e(TAG, "queue frame failed", e);
|
||||||
inputQueue.addFirst(idx);
|
frameQueue.addFirst(f);
|
||||||
break;
|
inputQueue.addFirst(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void releaseDecoder() {
|
private void releaseDecoder() {
|
||||||
|
configured = false;
|
||||||
|
synchronized (assembling) {
|
||||||
|
lastSeq = -1; // 释放时重置序号,避免重连后首帧被误判为过期
|
||||||
|
assembling.clear();
|
||||||
|
}
|
||||||
if (decoder != null) {
|
if (decoder != null) {
|
||||||
try {
|
try {
|
||||||
decoder.stop();
|
decoder.stop();
|
||||||
@@ -246,11 +316,15 @@ public class SelfCodecDecoder {
|
|||||||
}
|
}
|
||||||
decoder = null;
|
decoder = null;
|
||||||
}
|
}
|
||||||
configured = false;
|
synchronized (pendingConfigs) {
|
||||||
pendingConfigs.clear();
|
pendingConfigs.clear();
|
||||||
frameQueue.clear();
|
}
|
||||||
inputQueue.clear();
|
synchronized (frameQueue) {
|
||||||
assembling.clear();
|
frameQueue.clear();
|
||||||
|
}
|
||||||
|
synchronized (inputQueue) {
|
||||||
|
inputQueue.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release() {
|
public void release() {
|
||||||
|
|||||||
@@ -318,6 +318,15 @@ public class WebRtcClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleDataChannel(DataChannel dc) {
|
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() {
|
dc.registerObserver(new DataChannel.Observer() {
|
||||||
@Override
|
@Override
|
||||||
public void onBufferedAmountChange(long previousAmount) {}
|
public void onBufferedAmountChange(long previousAmount) {}
|
||||||
@@ -337,14 +346,18 @@ public class WebRtcClient {
|
|||||||
if (buffer == null || buffer.data == null) return;
|
if (buffer == null || buffer.data == null) return;
|
||||||
byte[] data = new byte[buffer.data.remaining()];
|
byte[] data = new byte[buffer.data.remaining()];
|
||||||
buffer.data.get(data);
|
buffer.data.get(data);
|
||||||
// 若收到被控端回报的串流模式,单独处理。
|
// 收到被控端回报的消息,按 Action 分发处理。
|
||||||
try {
|
try {
|
||||||
ControlMessage msg = ControlMessage.parseFrom(data);
|
ControlMessage msg = ControlMessage.parseFrom(data);
|
||||||
if (msg.getAction() == Action.REPORT_STREAM_MODE) {
|
if (msg.getAction() == Action.REPORT_STREAM_MODE) {
|
||||||
if (streamModeReportListener != null) {
|
if (streamModeReportListener != null) {
|
||||||
streamModeReportListener.onStreamModeReported(msg.getStreamMode());
|
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) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,9 @@ class SelfCodecDecoderPlugin :
|
|||||||
val st = entry.surfaceTexture()
|
val st = entry.surfaceTexture()
|
||||||
surfaceTexture = st
|
surfaceTexture = st
|
||||||
st.setDefaultBufferSize(PLACEHOLDER_W, PLACEHOLDER_H)
|
st.setDefaultBufferSize(PLACEHOLDER_W, PLACEHOLDER_H)
|
||||||
|
// MediaCodec 把解码帧渲染到该 SurfaceTexture。必须消费(updateTexImage)
|
||||||
|
// 新帧,否则 SurfaceTexture 一直停留在初始(黑)帧,导致切换自编码后无画面。
|
||||||
st.setOnFrameAvailableListener { texture: SurfaceTexture ->
|
st.setOnFrameAvailableListener { texture: SurfaceTexture ->
|
||||||
// 解码器将帧渲染到 Surface 后触发,这里更新纹理内容供 Flutter 显示。
|
|
||||||
try {
|
try {
|
||||||
texture.updateTexImage()
|
texture.updateTexImage()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -251,8 +252,11 @@ class SelfCodecDecoderPlugin :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
||||||
val w = format.getInteger(MediaFormat.KEY_WIDTH, 0)
|
// getInteger(key, default) 需要 API 29+,用 containsKey 兼容低版本
|
||||||
val h = format.getInteger(MediaFormat.KEY_HEIGHT, 0)
|
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) {
|
if (w > 0 && h > 0) {
|
||||||
surfaceTexture?.setDefaultBufferSize(w, h)
|
surfaceTexture?.setDefaultBufferSize(w, h)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class RemoteController {
|
|||||||
/// WebRTC 连接建立(可开始远程控制)。
|
/// WebRTC 连接建立(可开始远程控制)。
|
||||||
void Function()? onConnectionEstablished;
|
void Function()? onConnectionEstablished;
|
||||||
|
|
||||||
|
/// 连接失败。
|
||||||
|
void Function(String error)? onConnectionFailed;
|
||||||
|
|
||||||
/// 连接断开。
|
/// 连接断开。
|
||||||
void Function()? onDisconnected;
|
void Function()? onDisconnected;
|
||||||
|
|
||||||
@@ -48,6 +51,9 @@ class RemoteController {
|
|||||||
/// 自编码解码纹理已就绪(textureId >= 0)。
|
/// 自编码解码纹理已就绪(textureId >= 0)。
|
||||||
void Function(int textureId)? onSelfCodecReady;
|
void Function(int textureId)? onSelfCodecReady;
|
||||||
|
|
||||||
|
/// 自编码解码器已释放(连接断开时)。
|
||||||
|
void Function()? onSelfCodecLost;
|
||||||
|
|
||||||
/// 被控端上报当前生效的串流模式。
|
/// 被控端上报当前生效的串流模式。
|
||||||
void Function(int mode)? onStreamModeReport;
|
void Function(int mode)? onStreamModeReport;
|
||||||
|
|
||||||
@@ -99,17 +105,20 @@ class RemoteController {
|
|||||||
onConnectionEstablished?.call();
|
onConnectionEstablished?.call();
|
||||||
_startStats();
|
_startStats();
|
||||||
};
|
};
|
||||||
|
_webRtc!.onConnectionFailed = (error) => onConnectionFailed?.call(error);
|
||||||
_webRtc!.onDisconnected = () {
|
_webRtc!.onDisconnected = () {
|
||||||
onDisconnected?.call();
|
onDisconnected?.call();
|
||||||
};
|
};
|
||||||
_webRtc!.onIceDisconnected = (message) => onIceDisconnected?.call(message);
|
_webRtc!.onIceDisconnected = (message) => onIceDisconnected?.call(message);
|
||||||
_webRtc!.onRemoteStream = (renderer) => onRemoteStream?.call(renderer);
|
_webRtc!.onRemoteStream = (renderer) => onRemoteStream?.call(renderer);
|
||||||
_webRtc!.onSelfCodecReady = (id) => onSelfCodecReady?.call(id);
|
_webRtc!.onSelfCodecReady = (id) => onSelfCodecReady?.call(id);
|
||||||
|
_webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call();
|
||||||
_webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode);
|
_webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode);
|
||||||
_webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h);
|
_webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h);
|
||||||
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
|
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
|
||||||
_webRtc!.initialize().catchError((e) {
|
_webRtc!.initialize().catchError((e) {
|
||||||
onStatusChanged?.call('状态: 连接失败 - $e');
|
onStatusChanged?.call('状态: 连接失败 - $e');
|
||||||
|
onConnectionFailed?.call(e.toString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,69 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
String selectedType = 'NONE';
|
String selectedType = 'NONE';
|
||||||
final valueController = TextEditingController();
|
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>(
|
await showCupertinoDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
@@ -134,37 +197,37 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
content: Column(
|
content: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
CupertinoSegmentedControl<String>(
|
buildOption(
|
||||||
children: const {
|
'NONE',
|
||||||
'NONE': Padding(
|
'免密连接',
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
'被控端弹出手动确认框,无需验证码或密码',
|
||||||
child: Text('免密连接'),
|
setDialogState,
|
||||||
),
|
),
|
||||||
'CODE': Padding(
|
buildOption(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
'CODE',
|
||||||
child: Text('动态验证码'),
|
'动态验证码',
|
||||||
),
|
'使用一次性动态验证码鉴权',
|
||||||
'PASSWORD': Padding(
|
setDialogState,
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
),
|
||||||
child: Text('固定密码'),
|
buildOption(
|
||||||
),
|
'PASSWORD',
|
||||||
},
|
'固定密码',
|
||||||
groupValue: selectedType,
|
'使用固定连接密码鉴权',
|
||||||
onValueChanged: (v) => setDialogState(() => selectedType = v),
|
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: [
|
actions: [
|
||||||
@@ -245,6 +308,14 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
_controller!.onSelfCodecLost = () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_selfCodecReady = false;
|
||||||
|
_selfCodecTextureId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
_controller!.onStreamModeReport = (mode) {
|
_controller!.onStreamModeReport = (mode) {
|
||||||
if (mounted) setState(() => _streamMode = mode);
|
if (mounted) setState(() => _streamMode = mode);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -217,7 +217,13 @@ class WebRtcController {
|
|||||||
switch (msg.action) {
|
switch (msg.action) {
|
||||||
case Action.REPORT_STREAM_MODE:
|
case Action.REPORT_STREAM_MODE:
|
||||||
_currentStreamMode = msg.streamMode;
|
_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);
|
onStreamModeReport?.call(msg.streamMode);
|
||||||
break;
|
break;
|
||||||
case Action.REPORT_RESOLUTION:
|
case Action.REPORT_RESOLUTION:
|
||||||
|
|||||||
Reference in New Issue
Block a user