feat: 添加远端视频录制功能

This commit is contained in:
TongTongStudio
2026-07-29 16:20:35 +08:00
parent 1e37aa64cc
commit 7f545359d9
5 changed files with 691 additions and 3 deletions

View File

@@ -12,6 +12,17 @@
<!-- 如果你只跑在定制系统/有特权,用这个 -->
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
<!-- 保存录制视频Android 10+ 作用域存储通过 MediaStore 写入无需权限;
低版本(<29需要存储权限限制在 API 32 及以下申请。 -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Android 13+ 读取媒体所需(写入应用自身媒体无需此权限,声明以适配) -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application
android:allowBackup="true"

View File

@@ -1,5 +1,8 @@
package com.ttstd.controller;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -20,8 +23,10 @@ import android.widget.Switch;
import android.view.SurfaceView;
import android.view.SurfaceHolder;
import android.view.Gravity;
import com.ttstd.controller.webrtc.SelfCodecDecoder;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.List;
@@ -36,6 +41,8 @@ import com.ttstd.controller.signaling.SignalMessage;
import com.ttstd.controller.signaling.WebSocketClient;
import com.ttstd.controller.view.RemoteTouchView;
import com.ttstd.controller.utils.DeviceUtils;
import com.ttstd.controller.webrtc.SelfCodecDecoder;
import com.ttstd.controller.webrtc.VideoRecorder;
import com.ttstd.controller.webrtc.WebRtcClient;
import org.webrtc.EglBase;
@@ -72,6 +79,14 @@ public class MainActivity extends AppCompatActivity {
private SurfaceHolder.Callback surfaceCallback;
private boolean programmaticSwitch = false;
// 屏幕录制相关
private static final int REQ_RECORD_PERMISSION = 1001;
private Switch switchRecord;
private TextView tvRecordStatus;
private VideoRecorder videoRecorder;
private boolean isSelfCodecMode = false; // 当前是否为自编码串流模式
private boolean pendingRecordStart = false;
private WebSocketClient wsClient;
private WebRtcClient webRtcClient;
private EglBase eglBase;
@@ -180,6 +195,7 @@ public class MainActivity extends AppCompatActivity {
updateUI(false);
setupSelfCodecUi();
setupRecordUi();
}
private void adjustVideoSize(int videoWidth, int videoHeight, int rotation) {
@@ -396,6 +412,7 @@ public class MainActivity extends AppCompatActivity {
webRtcClient = new WebRtcClient(this, wsClient, myDeviceId);
webRtcClient.initialize(eglBase);
webRtcClient.setSelfCodecDecoder(selfCodecDecoder);
webRtcClient.setRecorder(videoRecorder);
webRtcClient.setStreamModeReportListener(mode -> 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;

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -179,6 +179,25 @@
android:textColor="@android:color/white"
android:textSize="12sp" />
<Switch
android:id="@+id/switch_record"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:enabled="false"
android:text="录制屏幕"
android:textColor="@android:color/white"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_record_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="未录制"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>