diff --git a/WebRTCController/app/src/main/AndroidManifest.xml b/WebRTCController/app/src/main/AndroidManifest.xml
index 6c3261c..e09d63a 100644
--- a/WebRTCController/app/src/main/AndroidManifest.xml
+++ b/WebRTCController/app/src/main/AndroidManifest.xml
@@ -12,6 +12,17 @@
+
+
+
+
+
+
runOnUiThread(() -> syncStreamModeUi(mode)));
webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
@Override
@@ -687,6 +704,7 @@ public class MainActivity extends AppCompatActivity {
if (connected) {
statsHandler.post(statsRunnable);
if (switchStreamMode != null) switchStreamMode.setEnabled(true);
+ if (switchRecord != null) switchRecord.setEnabled(true);
} else {
statsHandler.removeCallbacks(statsRunnable);
if (switchStreamMode != null) {
@@ -697,6 +715,17 @@ public class MainActivity extends AppCompatActivity {
}
if (tvStreamMode != null) tvStreamMode.setText("串流: WebRTC");
if (selfCodecDecoder != null) selfCodecDecoder.release();
+ // 断开连接时停止录制
+ if (videoRecorder != null && videoRecorder.isRecording()) {
+ stopRecording();
+ }
+ if (switchRecord != null) {
+ switchRecord.setEnabled(false);
+ programmaticSwitch = true;
+ switchRecord.setChecked(false);
+ programmaticSwitch = false;
+ }
+ if (tvRecordStatus != null) tvRecordStatus.setText("未录制");
}
}
@@ -731,6 +760,139 @@ public class MainActivity extends AppCompatActivity {
});
}
+ /** 初始化"屏幕录制"UI,绑定录制开关并处理保存逻辑。 */
+ private void setupRecordUi() {
+ switchRecord = findViewById(R.id.switch_record);
+ tvRecordStatus = findViewById(R.id.tv_record_status);
+ switchRecord.setEnabled(false);
+
+ videoRecorder = new VideoRecorder();
+ videoRecorder.setOnRecordingListener(new VideoRecorder.OnRecordingListener() {
+ @Override
+ public void onStarted() {
+ runOnUiThread(() -> tvRecordStatus.setText("录制中"));
+ }
+
+ @Override
+ public void onStopped(String savedLocation) {
+ runOnUiThread(() -> {
+ tvRecordStatus.setText("未录制");
+ Toast.makeText(MainActivity.this,
+ "视频已保存: " + savedLocation, Toast.LENGTH_LONG).show();
+ });
+ }
+
+ @Override
+ public void onStoppedEmpty() {
+ runOnUiThread(() -> {
+ tvRecordStatus.setText("未录制");
+ Toast.makeText(MainActivity.this, "本次未录制到画面", Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ @Override
+ public void onError(String message) {
+ runOnUiThread(() -> {
+ tvRecordStatus.setText("录制失败");
+ programmaticSwitch = true;
+ switchRecord.setChecked(false);
+ programmaticSwitch = false;
+ Toast.makeText(MainActivity.this, message, Toast.LENGTH_LONG).show();
+ });
+ }
+ });
+
+ switchRecord.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (programmaticSwitch) return;
+ if (isChecked) {
+ startRecording();
+ } else {
+ stopRecording();
+ }
+ });
+ }
+
+ /** 开始录制:先校验存储权限,必要时切回 WebRTC 模式再开始。 */
+ private void startRecording() {
+ if (!hasStoragePermission()) {
+ pendingRecordStart = true;
+ requestStoragePermission();
+ return;
+ }
+ doStartRecording();
+ }
+
+ private void doStartRecording() {
+ if (videoRecorder == null) return;
+ videoRecorder.start(this);
+ if (webRtcClient != null) {
+ webRtcClient.setRecorder(videoRecorder);
+ webRtcClient.setRecording(true);
+ }
+ // 自编码模式下没有 WebRTC 媒体轨道,自动切回 WebRTC 才能录到画面
+ if (isSelfCodecMode) {
+ applyStreamMode(false);
+ }
+ tvRecordStatus.setText("录制中");
+ }
+
+ private void stopRecording() {
+ if (webRtcClient != null) {
+ webRtcClient.setRecording(false);
+ }
+ if (videoRecorder != null) {
+ videoRecorder.stop();
+ }
+ tvRecordStatus.setText("保存中...");
+ }
+
+ /** Android 10+ 使用作用域存储,写入应用自身的媒体集合无需存储权限。 */
+ private boolean hasStoragePermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return true;
+ }
+ return ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private void requestStoragePermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return;
+ String[] perms;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ perms = new String[]{
+ Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ };
+ } else {
+ perms = new String[]{
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ };
+ }
+ ActivityCompat.requestPermissions(this, perms, REQ_RECORD_PERMISSION);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == REQ_RECORD_PERMISSION) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (pendingRecordStart) {
+ pendingRecordStart = false;
+ doStartRecording();
+ }
+ } else {
+ pendingRecordStart = false;
+ programmaticSwitch = true;
+ switchRecord.setChecked(false);
+ programmaticSwitch = false;
+ Toast.makeText(this, "需要存储权限才能保存录制视频", Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+
/** 用户切换串流模式:发送信令并切换本地视图。 */
private void applyStreamMode(boolean self) {
if (webRtcClient != null && webRtcClient.isDataChannelOpen()) {
@@ -755,6 +917,7 @@ public class MainActivity extends AppCompatActivity {
/** 收到被控端回报的当前模式:同步 UI 与视图(防止切换失败不一致)。 */
private void syncStreamModeUi(int mode) {
boolean self = (mode == WebRtcClient.STREAM_MODE_SELF_CODEC);
+ this.isSelfCodecMode = self;
programmaticSwitch = true;
switchStreamMode.setChecked(self);
programmaticSwitch = false;
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/VideoRecorder.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/VideoRecorder.java
new file mode 100644
index 0000000..df53619
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/VideoRecorder.java
@@ -0,0 +1,459 @@
+package com.ttstd.controller.webrtc;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore;
+import android.util.Log;
+
+import org.webrtc.VideoFrame;
+import org.webrtc.VideoSink;
+
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * 将远控端收到的视频帧编码为 MP4 文件保存。
+ *
+ * 通过实现 {@link VideoSink} 接收 WebRTC 解码后的视频帧(I420),
+ * 也可通过 {@link #submitDecodedFrame(byte[], int, int, long)} 接收自编码模式解码出的帧,
+ * 统一转换为 NV12 后交由 MediaCodec 编码器与 MediaMuxer 写盘。
+ *
+ * 编码与写盘均在单一后台线程执行,避免阻塞 WebRTC 网络/解码线程。
+ */
+public class VideoRecorder implements VideoSink {
+
+ private static final String TAG = "VideoRecorder";
+ private static final String FOLDER_NAME = "WebRTCRecordings";
+ private static final long TIMEOUT_US = 10000;
+
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ private volatile Context appContext;
+ private volatile boolean recording = false;
+
+ // 仅由后台线程访问
+ private MediaCodec encoder;
+ private MediaMuxer muxer;
+ private int trackIndex = -1;
+ private boolean muxerStarted = false;
+ private int framesWritten = 0;
+ private Uri mediaStoreUri = null;
+ private String lastFilePath = null;
+ private long startPtsUs = -1;
+ private boolean initErrorShown = false;
+
+ // 编码器输入颜色格式(在 start 时选定,整段录制共用)
+ private int inputColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
+ private boolean planarInput = false;
+
+ private OnRecordingListener listener;
+
+ public interface OnRecordingListener {
+ void onStarted();
+
+ /** 录制成功,savedLocation 为保存路径或 Uri。 */
+ void onStopped(String savedLocation);
+
+ /** 录制已停止,但没有写入任何画面(例如未收到帧即停止)。 */
+ void onStoppedEmpty();
+
+ /** 真正的错误(如编码器/封装初始化失败)。 */
+ void onError(String message);
+ }
+
+ public void setOnRecordingListener(OnRecordingListener l) {
+ this.listener = l;
+ }
+
+ public boolean isRecording() {
+ return recording;
+ }
+
+ /** 开始录制。真正的编码器与文件会在收到第一帧(确定分辨率)时初始化。 */
+ public void start(Context context) {
+ if (recording) return;
+ this.appContext = context.getApplicationContext();
+ this.recording = true;
+ this.startPtsUs = -1;
+ this.framesWritten = 0;
+ this.initErrorShown = false;
+ // 提前选定编码器输入颜色格式,避免首帧时尚未确定导致帧格式错乱
+ this.inputColorFormat = selectColorFormat(MediaFormat.MIMETYPE_VIDEO_AVC);
+ this.planarInput = (inputColorFormat == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar);
+ if (listener != null) listener.onStarted();
+ }
+
+ /** 停止录制并保存文件。 */
+ public void stop() {
+ if (!recording) return;
+ recording = false;
+ executor.execute(this::finalizeRecording);
+ }
+
+ /** WebRTC 模式:接收解码后的视频帧。 */
+ @Override
+ public void onFrame(VideoFrame frame) {
+ if (!recording) return;
+ VideoFrame.I420Buffer i420 = frame.getBuffer().toI420();
+ int w = i420.getWidth();
+ int h = i420.getHeight();
+ if (w <= 0 || h <= 0) {
+ i420.release();
+ return;
+ }
+ byte[] data = planarInput ? i420ToI420(i420) : i420ToNv12(i420);
+ i420.release();
+ long ptsUs = frame.getTimestampNs() / 1000;
+ executor.execute(() -> encodeFrameInternal(data, w, h, ptsUs));
+ }
+
+ /** 自编码模式:接收解码器输出并已转换为 NV12 的帧。 */
+ public void submitDecodedFrame(byte[] nv12, int w, int h, long ptsUs) {
+ if (!recording || nv12 == null) return;
+ byte[] data = planarInput ? nv12ToI420(nv12, w, h) : nv12;
+ executor.execute(() -> encodeFrameInternal(data, w, h, ptsUs));
+ }
+
+ private void encodeFrameInternal(byte[] nv12, int w, int h, long ptsUs) {
+ if (encoder == null) {
+ if (!initEncoder(w, h)) {
+ Log.e(TAG, "编码器初始化失败,停止录制");
+ return;
+ }
+ }
+ if (startPtsUs < 0) startPtsUs = ptsUs;
+ long sampleTime = ptsUs - startPtsUs;
+ if (sampleTime < 0) sampleTime = 0;
+
+ try {
+ int inIdx = encoder.dequeueInputBuffer(TIMEOUT_US);
+ if (inIdx >= 0) {
+ ByteBuffer inBuf = encoder.getInputBuffer(inIdx);
+ if (inBuf != null) {
+ inBuf.clear();
+ inBuf.put(nv12);
+ encoder.queueInputBuffer(inIdx, 0, nv12.length, sampleTime, 0);
+ }
+ }
+ drainEncoder(false);
+ } catch (Exception e) {
+ Log.e(TAG, "编码异常", e);
+ }
+ }
+
+ private boolean initEncoder(int w, int h) {
+ try {
+ int bitrate = (int) (w * h * 30 * 0.15);
+ bitrate = Math.max(1_000_000, Math.min(bitrate, 8_000_000));
+
+ MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h);
+ format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate);
+ format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, inputColorFormat);
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2);
+
+ encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
+ encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ encoder.start();
+
+ if (!initMuxer()) {
+ encoder.release();
+ encoder = null;
+ return false;
+ }
+ return true;
+ } catch (Exception e) {
+ Log.e(TAG, "initEncoder 错误", e);
+ if (listener != null && !initErrorShown) {
+ initErrorShown = true;
+ listener.onError("录制初始化失败: " + e.getMessage());
+ }
+ return false;
+ }
+ }
+
+ private boolean initMuxer() {
+ try {
+ ContentValues values = new ContentValues();
+ String fileName = "webrtc_record_" + System.currentTimeMillis() + ".mp4";
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ values.put(MediaStore.Video.Media.RELATIVE_PATH,
+ Environment.DIRECTORY_MOVIES + "/" + FOLDER_NAME);
+ values.put(MediaStore.Video.Media.DISPLAY_NAME, fileName);
+ values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4");
+ values.put(MediaStore.Video.Media.IS_PENDING, 1);
+ mediaStoreUri = appContext.getContentResolver()
+ .insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
+ if (mediaStoreUri == null) return false;
+ ParcelFileDescriptor pfd = appContext.getContentResolver()
+ .openFileDescriptor(mediaStoreUri, "rw");
+ if (pfd == null) return false;
+ muxer = new MediaMuxer(pfd.getFileDescriptor(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+ } else {
+ File dir = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_MOVIES), FOLDER_NAME);
+ if (!dir.exists() && !dir.mkdirs()) return false;
+ File file = new File(dir, fileName);
+ muxer = new MediaMuxer(file.getAbsolutePath(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+ lastFilePath = file.getAbsolutePath();
+ }
+ return true;
+ } catch (Exception e) {
+ Log.e(TAG, "initMuxer 错误", e);
+ return false;
+ }
+ }
+
+ private void drainEncoder(boolean endOfStream) {
+ if (endOfStream) {
+ try {
+ int inIdx = encoder.dequeueInputBuffer(TIMEOUT_US);
+ if (inIdx >= 0) {
+ encoder.queueInputBuffer(inIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ while (true) {
+ int outIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_US);
+ if (outIdx == MediaCodec.INFO_TRY_AGAIN_LATER) break;
+ if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ if (muxer != null && !muxerStarted) {
+ trackIndex = muxer.addTrack(encoder.getOutputFormat());
+ muxer.start();
+ muxerStarted = true;
+ Log.d(TAG, "Muxer 已开始,视频轨道: " + trackIndex);
+ }
+ continue;
+ }
+ if (outIdx < 0) continue;
+ try {
+ ByteBuffer outBuf = encoder.getOutputBuffer(outIdx);
+ if (outBuf != null && info.size > 0 && muxerStarted
+ && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
+ outBuf.position(info.offset);
+ outBuf.limit(info.offset + info.size);
+ muxer.writeSampleData(trackIndex, outBuf, info);
+ framesWritten++;
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "writeSampleData 错误", e);
+ } finally {
+ encoder.releaseOutputBuffer(outIdx, false);
+ }
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) break;
+ }
+ }
+
+ private void finalizeRecording() {
+ // 先保存状态,避免下方置空后误判
+ boolean wasStarted = muxerStarted;
+ int written = framesWritten;
+ Uri msUri = mediaStoreUri;
+ String fp = lastFilePath;
+
+ try {
+ if (encoder != null) drainEncoder(true);
+ } catch (Exception e) {
+ Log.e(TAG, "收尾 drain 错误", e);
+ }
+ try {
+ if (muxer != null && muxerStarted) muxer.stop();
+ } catch (Exception e) {
+ Log.e(TAG, "muxer stop 错误", e);
+ }
+ try {
+ if (muxer != null) muxer.release();
+ } catch (Exception ignored) {
+ }
+ try {
+ if (encoder != null) encoder.release();
+ } catch (Exception ignored) {
+ }
+ encoder = null;
+ muxer = null;
+ muxerStarted = false;
+ mediaStoreUri = null;
+ lastFilePath = null;
+ startPtsUs = -1;
+
+ if (listener == null) return;
+
+ boolean hasContent = wasStarted && written > 0;
+ if (hasContent) {
+ if (msUri != null) {
+ ContentValues v = new ContentValues();
+ v.put(MediaStore.Video.Media.IS_PENDING, 0);
+ appContext.getContentResolver().update(msUri, v, null, null);
+ listener.onStopped("已保存至 相册/Movies/" + FOLDER_NAME);
+ } else if (fp != null) {
+ listener.onStopped(fp);
+ } else {
+ listener.onStopped("已保存");
+ }
+ } else {
+ // 没有写入任何画面:清理占位的媒体库记录或空文件
+ if (msUri != null) {
+ try {
+ appContext.getContentResolver().delete(msUri, null, null);
+ } catch (Exception ignored) {
+ }
+ }
+ if (fp != null) {
+ try {
+ new File(fp).delete();
+ } catch (Exception ignored) {
+ }
+ }
+ listener.onStoppedEmpty();
+ }
+ }
+
+ private static int selectColorFormat(String mimeType) {
+ MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ // 优选顺序:NV12(SemiPlanar) -> I420(Planar) -> Flexible
+ // 避免直接选 Flexible:很多设备会把 NV12 输入的色度平面读错位置,
+ // 导致只剩亮度平面,画面变成黑白、颜色对不上。
+ int[] preferred = new int[]{
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar,
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
+ };
+ int fallback = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
+ int chosen = -1;
+ for (MediaCodecInfo info : list.getCodecInfos()) {
+ if (!info.isEncoder()) continue;
+ try {
+ for (String type : info.getSupportedTypes()) {
+ if (type.equalsIgnoreCase(mimeType)) {
+ MediaCodecInfo.CodecCapabilities caps = info.getCapabilitiesForType(type);
+ // 按优选顺序找到第一个受支持的格式
+ for (int want : preferred) {
+ for (int cf : caps.colorFormats) {
+ if (cf == want) {
+ if (chosen < 0 || preferRank(want) < preferRank(chosen)) {
+ chosen = want;
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ return chosen > 0 ? chosen : fallback;
+ }
+
+ private static int preferRank(int cf) {
+ if (cf == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) return 0;
+ if (cf == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar) return 1;
+ return 2;
+ }
+
+ private static byte[] i420ToNv12(VideoFrame.I420Buffer i420) {
+ int w = i420.getWidth();
+ int h = i420.getHeight();
+ int ySize = w * h;
+ int uvSize = w * h / 2;
+ byte[] nv12 = new byte[ySize + uvSize];
+
+ ByteBuffer dataY = i420.getDataY();
+ ByteBuffer dataU = i420.getDataU();
+ ByteBuffer dataV = i420.getDataV();
+ int strideY = i420.getStrideY();
+ int strideU = i420.getStrideU();
+ int strideV = i420.getStrideV();
+
+ // Y 平面
+ for (int y = 0; y < h; y++) {
+ dataY.position(y * strideY);
+ dataY.get(nv12, y * w, w);
+ }
+
+ // U/V 交织成 NV12
+ int halfH = h / 2;
+ int halfW = w / 2;
+ byte[] uRow = new byte[halfW];
+ byte[] vRow = new byte[halfW];
+ int pos = ySize;
+ for (int y = 0; y < halfH; y++) {
+ dataU.position(y * strideU);
+ dataU.get(uRow, 0, halfW);
+ dataV.position(y * strideV);
+ dataV.get(vRow, 0, halfW);
+ for (int x = 0; x < halfW; x++) {
+ nv12[pos++] = uRow[x];
+ nv12[pos++] = vRow[x];
+ }
+ }
+ return nv12;
+ }
+
+ /** I420 转 I420(抽掉各平面 stride 填充,输出紧凑 packed 布局)。 */
+ private static byte[] i420ToI420(VideoFrame.I420Buffer i420) {
+ int w = i420.getWidth();
+ int h = i420.getHeight();
+ int ySize = w * h;
+ int uvSize = w * h / 4;
+ byte[] out = new byte[ySize + uvSize * 2];
+
+ ByteBuffer dataY = i420.getDataY();
+ ByteBuffer dataU = i420.getDataU();
+ ByteBuffer dataV = i420.getDataV();
+ int strideY = i420.getStrideY();
+ int strideU = i420.getStrideU();
+ int strideV = i420.getStrideV();
+ int halfW = w / 2;
+ int halfH = h / 2;
+
+ int pos = 0;
+ for (int y = 0; y < h; y++) {
+ dataY.position(y * strideY);
+ dataY.get(out, pos, w);
+ pos += w;
+ }
+ for (int y = 0; y < halfH; y++) {
+ dataU.position(y * strideU);
+ dataU.get(out, pos, halfW);
+ pos += halfW;
+ }
+ for (int y = 0; y < halfH; y++) {
+ dataV.position(y * strideV);
+ dataV.get(out, pos, halfW);
+ pos += halfW;
+ }
+ return out;
+ }
+
+ /** NV12 转 I420(抽掉 UV 交织,输出紧凑 I420 布局)。 */
+ private static byte[] nv12ToI420(byte[] nv12, int w, int h) {
+ int ySize = w * h;
+ int halfW = w / 2;
+ int halfH = h / 2;
+ int uvPixels = halfW * halfH;
+ byte[] out = new byte[ySize + uvPixels * 2];
+ System.arraycopy(nv12, 0, out, 0, ySize);
+ int uvOff = ySize;
+ int uPos = ySize;
+ int vPos = ySize + uvPixels;
+ for (int i = 0; i < uvPixels; i++) {
+ out[uPos + i] = nv12[uvOff + i * 2];
+ out[vPos + i] = nv12[uvOff + i * 2 + 1];
+ }
+ return out;
+ }
+}
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 7f1f72c..0ad50d2 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
@@ -24,6 +24,7 @@ import org.webrtc.RtpReceiver;
import org.webrtc.RtpTransceiver;
import org.webrtc.SessionDescription;
import org.webrtc.SurfaceViewRenderer;
+import org.webrtc.VideoSink;
import org.webrtc.VideoDecoderFactory;
import org.webrtc.VideoEncoderFactory;
import org.webrtc.VideoTrack;
@@ -57,6 +58,8 @@ public class WebRtcClient {
private String currentControlledId;
private SurfaceViewRenderer remoteSurfaceView;
private VideoTrack remoteVideoTrack;
+ private VideoSink recorderSink; // 视频录制器(可作为额外的 sink 接入远端视频轨道)
+ private boolean recording = false;
public interface ConnectionListener {
void onConnectionEstablished();
@@ -281,8 +284,19 @@ public class WebRtcClient {
}
public void close() {
- if (remoteVideoTrack != null && remoteSurfaceView != null) {
- remoteVideoTrack.removeSink(remoteSurfaceView);
+ if (remoteVideoTrack != null) {
+ if (remoteSurfaceView != null) {
+ try {
+ remoteVideoTrack.removeSink(remoteSurfaceView);
+ } catch (Exception ignored) {
+ }
+ }
+ if (recorderSink != null) {
+ try {
+ remoteVideoTrack.removeSink(recorderSink);
+ } catch (Exception ignored) {
+ }
+ }
remoteVideoTrack = null;
}
if (dataChannel != null) {
@@ -313,6 +327,9 @@ public class WebRtcClient {
this.remoteVideoTrack = videoTrack;
remoteVideoTrack.setEnabled(true);
remoteVideoTrack.addSink(remoteSurfaceView);
+ if (recorderSink != null && recording) {
+ remoteVideoTrack.addSink(recorderSink);
+ }
Log.d(TAG, "Remote video track attached to renderer");
}
}
@@ -394,6 +411,25 @@ public class WebRtcClient {
this.selfCodecDecoder = decoder;
}
+ /** 设置视频录制器(实现 VideoSink 即可接入远端视频轨道)。 */
+ public void setRecorder(VideoSink recorder) {
+ this.recorderSink = recorder;
+ }
+
+ /** 开启/关闭录制。开启时会将录制器作为额外 sink 接入当前远端视频轨道;
+ * 若轨道尚未建立,则在本端收到轨道时(setupRemoteVideoTrack)自动接入。 */
+ public void setRecording(boolean on) {
+ this.recording = on;
+ if (recorderSink == null || remoteVideoTrack == null) return;
+ try {
+ remoteVideoTrack.removeSink(recorderSink);
+ } catch (Exception ignored) {
+ }
+ if (on) {
+ remoteVideoTrack.addSink(recorderSink);
+ }
+ }
+
public void setStreamModeReportListener(StreamModeReportListener l) {
this.streamModeReportListener = l;
}
diff --git a/WebRTCController/app/src/main/res/layout/activity_main.xml b/WebRTCController/app/src/main/res/layout/activity_main.xml
index 4a3915f..2cb53a6 100644
--- a/WebRTCController/app/src/main/res/layout/activity_main.xml
+++ b/WebRTCController/app/src/main/res/layout/activity_main.xml
@@ -179,6 +179,25 @@
android:textColor="@android:color/white"
android:textSize="12sp" />
+
+
+
+