From 7f545359d94a79e2eb56296361a629285175dcc4 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Wed, 29 Jul 2026 16:20:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=BF=9C=E7=AB=AF?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/main/AndroidManifest.xml | 11 + .../com/ttstd/controller/MainActivity.java | 165 ++++++- .../controller/webrtc/VideoRecorder.java | 459 ++++++++++++++++++ .../ttstd/controller/webrtc/WebRtcClient.java | 40 +- .../app/src/main/res/layout/activity_main.xml | 19 + 5 files changed, 691 insertions(+), 3 deletions(-) create mode 100644 WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/VideoRecorder.java 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" /> + + + +