feat: 添加远端视频录制功能
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user