Files
VibeCoding/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java
TongTongStudio 362b9f238a feat: 支持手动切换采集帧率
在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。

另附带更新信号服务器的数据库配置。
2026-07-31 03:26:40 +08:00

1247 lines
52 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.RadioGroup;
import android.widget.Switch;
import android.view.SurfaceView;
import android.view.SurfaceHolder;
import android.view.Gravity;
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;
import androidx.appcompat.app.AppCompatActivity;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.ttstd.control.Action;
import com.ttstd.control.ControlMessage;
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;
import org.webrtc.IceCandidate;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import java.util.Locale;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "ControllerMain";
private EditText etServerUrl;
private EditText etDeviceId;
private EditText etTargetDeviceId;
private Button btnConnect;
private Button btnDisconnect;
private TextView tvStatus;
private SurfaceViewRenderer remoteVideoView;
private RemoteTouchView touchOverlay;
private FrameLayout controlPanel;
private LinearLayout setupPanel;
private TextView tvStatusControl;
private TextView tvStats;
private Spinner spinnerResolution;
private Spinner spinnerFps;
/** 帧率下拉框当前档位(以被控端上报的 supported_fps 为准)。 */
private final List<Integer> fpsOptions = new ArrayList<>();
/** 被控端最近上报的实际采集分辨率/帧率(仅切帧率时保持分辨率不变)。 */
private int lastReportedWidth = 0;
private int lastReportedHeight = 0;
private int lastKnownFps = 0;
private Button btnDisconnectControl;
// 屏幕串流"自编码"模式相关 UI 与解码器
private Switch switchStreamMode;
private TextView tvStreamMode;
private SurfaceView selfCodecSurface;
private SelfCodecDecoder selfCodecDecoder;
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;
private final Gson gson = new Gson();
private String myDeviceId;
private String targetDeviceId;
private String pendingAuthType;
private String pendingAuthValue;
private final Handler statsHandler = new Handler(Looper.getMainLooper());
private final Runnable statsRunnable = new Runnable() {
@Override
public void run() {
updateStats();
statsHandler.postDelayed(this, 1000);
}
};
// 网速统计基线(累计字节数与上次采样时间戳)
private long prevBytesReceived = 0;
private long prevBytesSent = 0;
private long prevStatsTime = 0;
// 连接建立时的时间戳(毫秒),用于计算连接时长。
private long connectionStartTime = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
etServerUrl = findViewById(R.id.et_server_url);
etDeviceId = findViewById(R.id.et_device_id);
etTargetDeviceId = findViewById(R.id.et_target_device_id);
btnConnect = findViewById(R.id.btn_connect);
btnDisconnect = findViewById(R.id.btn_disconnect);
tvStatus = findViewById(R.id.tv_status);
remoteVideoView = findViewById(R.id.remote_video_view);
touchOverlay = findViewById(R.id.touch_overlay);
controlPanel = findViewById(R.id.control_panel);
setupPanel = findViewById(R.id.setup_panel);
tvStatusControl = findViewById(R.id.tv_status_control);
tvStats = findViewById(R.id.tv_stats);
spinnerResolution = findViewById(R.id.spinner_resolution);
spinnerFps = findViewById(R.id.spinner_fps);
btnDisconnectControl = findViewById(R.id.btn_disconnect_control);
// 默认服务器地址
etServerUrl.setText("wss://www.ttstd.com/signal");
// 生成设备ID非系统签名使用兜底方案获取设备标识
myDeviceId = DeviceUtils.getSerialNumber(this);
etDeviceId.setText(myDeviceId);
btnConnect.setOnClickListener(v -> showAuthDialog());
btnDisconnect.setOnClickListener(v -> disconnect());
btnDisconnectControl.setOnClickListener(v -> disconnect());
// 初始化 EGL 和远端视频渲染
eglBase = EglBase.create();
remoteVideoView.init(eglBase.getEglBaseContext(), new RendererCommon.RendererEvents() {
@Override
public void onFirstFrameRendered() {
Log.d(TAG, "First frame rendered");
}
@Override
public void onFrameResolutionChanged(int videoWidth, int videoHeight, int rotation) {
Log.d(TAG, "Video resolution changed: " + videoWidth + "x" + videoHeight + " rotation: " + rotation);
adjustVideoSize(videoWidth, videoHeight, rotation);
}
});
remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
remoteVideoView.setEnableHardwareScaler(true);
remoteVideoView.setZOrderMediaOverlay(true);
// 设置触摸事件
touchOverlay.setTouchEventListener(new RemoteTouchView.TouchEventListener() {
@Override
public void onTouch(float relX, float relY) {
// 已通过 onMotionEvent 处理
}
@Override
public void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration) {
// 已通过 onMotionEvent 处理
sendSwipeCommand(relX1, relY1, relX2, relY2, duration);
}
@Override
public void onKeyEvent(int keyCode, int action) {
sendKeyCommand(keyCode, action);
}
@Override
public void onLongPress(float relX, float relY) {
// 如果需要高层级的长按,可以保留,但 raw 模式下远程系统会自动识别
// sendLongPressCommand(relX, relY);
}
@Override
public void onMotionEvent(int action, float relX, float relY) {
sendMotionEventCommand(action, relX, relY);
}
});
setupResolutionSpinner();
setupFpsSpinner();
updateUI(false);
setupSelfCodecUi();
setupRecordUi();
}
private void adjustVideoSize(int videoWidth, int videoHeight, int rotation) {
runOnUiThread(() -> {
if (videoWidth == 0 || videoHeight == 0) return;
int containerWidth = controlPanel.getWidth();
int containerHeight = controlPanel.getHeight();
if (containerWidth == 0 || containerHeight == 0) {
// 如果容器还没测量好,稍后再试
controlPanel.postDelayed(() -> adjustVideoSize(videoWidth, videoHeight, rotation), 100);
return;
}
float videoAspectRatio;
if (rotation == 90 || rotation == 270) {
videoAspectRatio = (float) videoHeight / videoWidth;
} else {
videoAspectRatio = (float) videoWidth / videoHeight;
}
float containerAspectRatio = (float) containerWidth / containerHeight;
int newWidth, newHeight;
if (videoAspectRatio > containerAspectRatio) {
// 视频更宽,以容器宽度为准
newWidth = containerWidth;
newHeight = (int) (containerWidth / videoAspectRatio);
} else {
// 视频更高,以容器高度为准
newWidth = (int) (containerHeight * videoAspectRatio);
newHeight = containerHeight;
}
// 更新视频渲染视图大小
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) remoteVideoView.getLayoutParams();
lp.width = newWidth;
lp.height = newHeight;
remoteVideoView.setLayoutParams(lp);
// 更新触摸层大小,使其与视频区域完全重合
FrameLayout.LayoutParams overlayLp = (FrameLayout.LayoutParams) touchOverlay.getLayoutParams();
overlayLp.width = newWidth;
overlayLp.height = newHeight;
touchOverlay.setLayoutParams(overlayLp);
Log.d(TAG, String.format("Adjusted video view size: %dx%d (video: %dx%d, rotation: %d, container: %dx%d)",
newWidth, newHeight, videoWidth, videoHeight, rotation, containerWidth, containerHeight));
});
}
/**
* 自编码模式:按比例调整 selfCodecSurface 与触摸层尺寸。
* MediaCodec 渲染到 SurfaceView 时会把帧拉伸填满整个 Surface 显示区域,
* 因此必须让 Surface 的宽高比与视频一致,否则画面会被拉伸变形。
*/
private void adjustSelfCodecSize(int videoWidth, int videoHeight) {
runOnUiThread(() -> {
if (videoWidth == 0 || videoHeight == 0) return;
int containerWidth = controlPanel.getWidth();
int containerHeight = controlPanel.getHeight();
if (containerWidth == 0 || containerHeight == 0) {
// 容器尚未测量完成,稍后重试
controlPanel.postDelayed(() -> adjustSelfCodecSize(videoWidth, videoHeight), 100);
return;
}
float videoAspectRatio = (float) videoWidth / videoHeight;
float containerAspectRatio = (float) containerWidth / containerHeight;
int newWidth, newHeight;
if (videoAspectRatio > containerAspectRatio) {
// 视频更宽,以容器宽度为准
newWidth = containerWidth;
newHeight = (int) (containerWidth / videoAspectRatio);
} else {
// 视频更高,以容器高度为准
newWidth = (int) (containerHeight * videoAspectRatio);
newHeight = containerHeight;
}
// 调整自编码渲染 Surface 的尺寸(等比居中),避免画面被拉伸变形
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) selfCodecSurface.getLayoutParams();
lp.width = newWidth;
lp.height = newHeight;
lp.gravity = Gravity.CENTER;
selfCodecSurface.setLayoutParams(lp);
// 同步触摸层,使其与视频区域完全重合,保证触控坐标一致
FrameLayout.LayoutParams overlayLp = (FrameLayout.LayoutParams) touchOverlay.getLayoutParams();
overlayLp.width = newWidth;
overlayLp.height = newHeight;
overlayLp.gravity = Gravity.CENTER;
touchOverlay.setLayoutParams(overlayLp);
Log.d(TAG, String.format("Adjusted self-codec view size: %dx%d (video: %dx%d, container: %dx%d)",
newWidth, newHeight, videoWidth, videoHeight, containerWidth, containerHeight));
});
}
/** 弹出鉴权输入对话框:选择免密连接、动态验证码或固定密码,输入后发起连接。 */
private void showAuthDialog() {
String serverUrl = etServerUrl.getText().toString().trim();
String target = etTargetDeviceId.getText().toString().trim();
String myId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) {
Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show();
return;
}
View view = getLayoutInflater().inflate(R.layout.dialog_auth_input, null);
RadioGroup rg = view.findViewById(R.id.rg_auth_type);
EditText et = view.findViewById(R.id.et_auth_value);
TextView tip = view.findViewById(R.id.tv_auth_tip);
// 根据选中的鉴权方式切换输入框与提示文案。默认选中“免密连接”,输入框隐藏。
rg.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_none) {
et.setVisibility(View.GONE);
tip.setVisibility(View.VISIBLE);
tip.setText(R.string.auth_none_tip);
} else if (checkedId == R.id.rb_password) {
et.setVisibility(View.VISIBLE);
et.setHint(R.string.auth_password_hint);
tip.setVisibility(View.VISIBLE);
tip.setText(R.string.auth_password_tip);
} else {
et.setVisibility(View.VISIBLE);
et.setHint(R.string.auth_code_hint);
tip.setVisibility(View.VISIBLE);
tip.setText(R.string.auth_code_tip);
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("连接鉴权")
.setView(view)
.setNegativeButton("取消", null)
.setPositiveButton("连接", null)
.create();
// 校验失败时不关闭对话框,引导用户重新输入
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener(v -> {
int checkedId = rg.getCheckedRadioButtonId();
// 免密连接NONE无需输入验证码/密码
if (checkedId == R.id.rb_none) {
pendingAuthType = "NONE";
pendingAuthValue = "";
} else {
boolean isPassword = (checkedId == R.id.rb_password);
String authType = isPassword ? "PASSWORD" : "CODE";
String authValue = et.getText().toString().trim();
if (authValue.isEmpty()) {
Toast.makeText(MainActivity.this, "请输入验证码或密码", Toast.LENGTH_SHORT).show();
return;
}
pendingAuthType = authType;
pendingAuthValue = authValue;
}
dialog.dismiss();
connectToControlled();
}));
dialog.show();
}
private void connectToControlled() {
String serverUrl = etServerUrl.getText().toString().trim();
targetDeviceId = etTargetDeviceId.getText().toString().trim();
myDeviceId = etDeviceId.getText().toString().trim();
if (serverUrl.isEmpty() || targetDeviceId.isEmpty() || myDeviceId.isEmpty()) {
Toast.makeText(this, "请填写所有字段", Toast.LENGTH_SHORT).show();
return;
}
tvStatus.setText("状态: 正在连接信令服务器...");
// 初始化 WebSocket
wsClient = new WebSocketClient(serverUrl, myDeviceId);
wsClient.setListener(new WebSocketClient.SignalListener() {
@Override
public void onConnected() {
tvStatus.setText("状态: 已连接信令服务器,正在发起连接...");
// 连接成功后初始化 WebRTC 并创建 Offer
initWebRtcAndConnect();
}
@Override
public void onDisconnected() {
tvStatus.setText("状态: 已断开连接");
updateUI(false);
}
@Override
public void onError(String error) {
Log.e(TAG, "onError: " + error);
tvStatus.setText("状态: 连接错误 - " + error);
updateUI(false);
}
@Override
public void onMessage(SignalMessage message) {
handleSignalMessage(message);
}
});
wsClient.connect();
}
private void initWebRtcAndConnect() {
webRtcClient = new WebRtcClient(this, wsClient, myDeviceId);
webRtcClient.initialize(eglBase);
webRtcClient.setSelfCodecDecoder(selfCodecDecoder);
webRtcClient.setRecorder(videoRecorder);
webRtcClient.setStreamModeReportListener(mode -> runOnUiThread(() -> syncStreamModeUi(mode)));
// 被控端上报当前分辨率/帧率与支持的帧率档位 -> 同步帧率下拉框
webRtcClient.setResolutionReportListener((w, h, fps, supportedFps) -> runOnUiThread(() -> {
if (w > 0) lastReportedWidth = w;
if (h > 0) lastReportedHeight = h;
if (fps > 0) lastKnownFps = fps;
applyFpsOptions(supportedFps, fps);
}));
webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
@Override
public void onConnectionEstablished() {
connectionStartTime = System.currentTimeMillis();
runOnUiThread(() -> {
tvStatus.setText("状态: 已连接 - 远程控制中");
updateUI(true);
touchOverlay.requestFocus();
});
}
@Override
public void onConnectionFailed(String error) {
runOnUiThread(() -> {
tvStatus.setText("状态: 连接失败 - " + error);
updateUI(false);
});
}
@Override
public void onDisconnected() {
runOnUiThread(() -> {
Toast.makeText(MainActivity.this, "网络连接已断开", Toast.LENGTH_SHORT).show();
disconnect();
tvStatus.setText("状态: 远端已断开");
});
}
});
webRtcClient.createOffer(targetDeviceId, remoteVideoView, pendingAuthType, pendingAuthValue);
}
private void handleSignalMessage(SignalMessage message) {
String type = message.getType();
if (type == null) return;
switch (type.toUpperCase()) {
case "ANSWER":
handleAnswer(message);
break;
case "ICE_CANDIDATE":
handleIceCandidate(message);
break;
case "TARGET_OFFLINE":
handleTargetOffline(message);
break;
case "CONNECTION_REJECTED":
handleConnectionRejected(message);
break;
case "REQUEST_ERROR":
case "REQUEST_TIMEOUT":
handleRequestAborted(message);
break;
}
}
private void handleTargetOffline(SignalMessage message) {
String offlineId = message.getToDeviceId();
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
: "目标被控端不在线,请确认设备已开启并连接服务器";
String finalText = text;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Target device " + offlineId + " is offline");
}
/**
* 被控端拒绝了连接请求:提示用户并复位到未连接状态。
*/
private void handleConnectionRejected(SignalMessage message) {
String targetId = message.getToDeviceId();
String reason = parseReason(message.getPayload());
String finalText = reason;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Connection request to " + targetId + " was rejected: " + reason);
}
/** 解析拒绝原因:优先读取 JSON 中的 reason 字段,否则直接返回原文。 */
private String parseReason(String payload) {
if (payload == null || payload.isEmpty()) {
return "目标被控端拒绝了连接请求";
}
try {
JsonObject obj = gson.fromJson(payload, JsonObject.class);
if (obj != null && obj.has("reason") && !obj.get("reason").isJsonNull()) {
return obj.get("reason").getAsString();
}
} catch (Exception ignored) {
}
return payload;
}
/**
* 连接请求被服务器拒绝REQUEST_ERROR或应答超时REQUEST_TIMEOUT提示并复位。
*/
private void handleRequestAborted(SignalMessage message) {
String payload = message.getPayload();
String text = (payload != null && !payload.isEmpty())
? payload
: "连接请求失败";
String finalText = text;
runOnUiThread(() -> {
tvStatus.setText("状态: " + finalText);
Toast.makeText(MainActivity.this, finalText, Toast.LENGTH_LONG).show();
updateUI(false);
});
Log.w(TAG, "Connection request aborted: " + finalText);
}
private void handleAnswer(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdp = payload.get("sdp").getAsString();
if (webRtcClient != null) {
// 被控端已接受连接请求,进入 WebRTC 协商阶段。
runOnUiThread(() -> tvStatus.setText("状态: 被控端已接受连接,正在建立连接..."));
webRtcClient.handleAnswer(sdp);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing answer", e);
}
}
private void handleIceCandidate(SignalMessage message) {
try {
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
String sdpMid = payload.get("sdpMid").getAsString();
int sdpMLineIndex = payload.get("sdpMLineIndex").getAsInt();
String candidate = payload.get("candidate").getAsString();
IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, candidate);
if (webRtcClient != null) {
webRtcClient.addIceCandidate(iceCandidate);
}
} catch (Exception e) {
Log.e(TAG, "Error parsing ICE candidate", e);
}
}
private void sendTouchCommand(float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.TOUCH)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.SWIPE)
.setX1(relX1)
.setY1(relY1)
.setX2(relX2)
.setY2(relY2)
.setDuration(duration)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendKeyCommand(int keyCode, int action) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.KEY)
.setKeyCode(keyCode)
.setKeyAction(action)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendLongPressCommand(float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.LONG_PRESS)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void sendMotionEventCommand(int action, float relX, float relY) {
if (webRtcClient != null) {
ControlMessage msg = ControlMessage.newBuilder()
.setAction(Action.MOTION_EVENT)
.setMotionAction(action)
.setX(relX)
.setY(relY)
.build();
webRtcClient.sendControlCommand(msg);
}
}
private void disconnect() {
if (webRtcClient != null) {
webRtcClient.close();
webRtcClient = null;
}
if (wsClient != null) {
wsClient.disconnect();
wsClient = null;
}
// 重置网速统计基线,避免下次连接首帧显示错误速率。
prevBytesReceived = 0;
prevBytesSent = 0;
prevStatsTime = 0;
connectionStartTime = 0;
remoteVideoView.clearImage();
updateUI(false);
tvStatus.setText("状态: 已停止");
}
/** 分辨率预设(发送给被控端的 SET_RESOLUTION 参数)。 */
private static class ResolutionPreset {
final String label;
final int width;
final int height;
final int fps;
ResolutionPreset(String label, int width, int height, int fps) {
this.label = label;
this.width = width;
this.height = height;
this.fps = fps;
}
@Override
public String toString() {
return label;
}
}
/** 构建分辨率下拉框,选择后请求被控端切换采集分辨率。 */
private void setupResolutionSpinner() {
List<ResolutionPreset> presets = new ArrayList<>();
presets.add(new ResolutionPreset("原始画质", 0, 0, 0)); // 被控端原生分辨率
presets.add(new ResolutionPreset("1080P", 1920, 0, 0));
presets.add(new ResolutionPreset("720P", 1280, 0, 0));
presets.add(new ResolutionPreset("480P", 854, 0, 0));
ArrayAdapter<ResolutionPreset> adapter = new ArrayAdapter<>(
this, android.R.layout.simple_spinner_item, presets);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerResolution.setAdapter(adapter);
spinnerResolution.setSelection(0);
// 先选中默认项再设置监听,避免初始化即触发一次切换。
spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
ResolutionPreset preset = (ResolutionPreset) parent.getItemAtPosition(position);
if (webRtcClient != null) {
webRtcClient.requestResolutionChange(preset.width, preset.height, preset.fps);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
/** 构建帧率下拉框档位以被控端上报REPORT_RESOLUTION.supported_fps为准未上报前使用常用档位。 */
private void setupFpsSpinner() {
List<Integer> defaults = new ArrayList<>();
defaults.add(15);
defaults.add(24);
defaults.add(30);
defaults.add(60);
applyFpsOptions(defaults, 0);
spinnerFps.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
if (position < 0 || position >= fpsOptions.size()) return;
int fps = fpsOptions.get(position);
// 与被控端当前帧率一致时不重复发送(含上报同步 setSelection 触发的回调)
if (fps <= 0 || fps == lastKnownFps || webRtcClient == null) return;
// 仅切换帧率:分辨率沿用被控端最近上报的实际采集尺寸
webRtcClient.requestResolutionChange(lastReportedWidth, lastReportedHeight, fps);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
/** 更新帧率档位并把选中项同步为最接近 currentFps 的档位。 */
private void applyFpsOptions(List<Integer> options, int currentFps) {
if (spinnerFps == null) return;
if (options != null && !options.isEmpty() && !options.equals(fpsOptions)) {
fpsOptions.clear();
fpsOptions.addAll(options);
List<String> labels = new ArrayList<>();
for (int f : fpsOptions) labels.add(f + "fps");
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this, android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerFps.setAdapter(adapter);
}
if (currentFps > 0 && !fpsOptions.isEmpty()) {
int idx = 0;
int best = Integer.MAX_VALUE;
for (int i = 0; i < fpsOptions.size(); i++) {
int diff = Math.abs(fpsOptions.get(i) - currentFps);
if (diff < best) {
best = diff;
idx = i;
}
}
if (spinnerFps.getSelectedItemPosition() != idx) {
spinnerFps.setSelection(idx);
}
}
}
private void updateUI(boolean connected) {
setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
btnConnect.setEnabled(!connected);
btnDisconnect.setEnabled(connected);
if (spinnerResolution != null) {
spinnerResolution.setEnabled(connected);
}
if (spinnerFps != null) {
spinnerFps.setEnabled(connected);
}
if (tvStatusControl != null) {
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
}
if (connected) {
statsHandler.post(statsRunnable);
if (switchStreamMode != null) switchStreamMode.setEnabled(true);
if (switchRecord != null) switchRecord.setEnabled(true);
} else {
statsHandler.removeCallbacks(statsRunnable);
if (switchStreamMode != null) {
switchStreamMode.setEnabled(false);
programmaticSwitch = true;
switchStreamMode.setChecked(false);
programmaticSwitch = false;
}
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("未录制");
}
}
/** 初始化"自编码"串流模式的 UI 与解码器。 */
private void setupSelfCodecUi() {
switchStreamMode = findViewById(R.id.switch_stream_mode);
tvStreamMode = findViewById(R.id.tv_stream_mode);
selfCodecSurface = findViewById(R.id.self_codec_surface);
selfCodecDecoder = new SelfCodecDecoder();
selfCodecDecoder.setOnResolutionUpdateListener((w, h) -> adjustSelfCodecSize(w, h));
surfaceCallback = new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
if (selfCodecDecoder != null) {
selfCodecDecoder.setSurface(holder.getSurface());
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
};
selfCodecSurface.getHolder().addCallback(surfaceCallback);
switchStreamMode.setEnabled(false);
switchStreamMode.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (programmaticSwitch) return;
applyStreamMode(isChecked);
});
}
/** 初始化"屏幕录制"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()) {
webRtcClient.sendStreamMode(self ? WebRtcClient.STREAM_MODE_SELF_CODEC
: WebRtcClient.STREAM_MODE_WEBRTC);
}
tvStreamMode.setText(self ? "串流: 自编码" : "串流: WebRTC");
if (self) {
selfCodecSurface.setVisibility(View.VISIBLE);
remoteVideoView.setVisibility(View.GONE);
selfCodecDecoder.setEnabled(true);
if (selfCodecDecoder.getVideoWidth() > 0) {
adjustSelfCodecSize(selfCodecDecoder.getVideoWidth(), selfCodecDecoder.getVideoHeight());
}
} else {
selfCodecSurface.setVisibility(View.GONE);
remoteVideoView.setVisibility(View.VISIBLE);
selfCodecDecoder.setEnabled(false);
}
}
/** 收到被控端回报的当前模式:同步 UI 与视图(防止切换失败不一致)。 */
private void syncStreamModeUi(int mode) {
boolean self = (mode == WebRtcClient.STREAM_MODE_SELF_CODEC);
this.isSelfCodecMode = self;
programmaticSwitch = true;
switchStreamMode.setChecked(self);
programmaticSwitch = false;
tvStreamMode.setText(self ? "串流: 自编码" : "串流: WebRTC");
if (self) {
selfCodecSurface.setVisibility(View.VISIBLE);
remoteVideoView.setVisibility(View.GONE);
selfCodecDecoder.setEnabled(true);
if (selfCodecDecoder.getVideoWidth() > 0) {
adjustSelfCodecSize(selfCodecDecoder.getVideoWidth(), selfCodecDecoder.getVideoHeight());
}
} else {
selfCodecSurface.setVisibility(View.GONE);
remoteVideoView.setVisibility(View.VISIBLE);
selfCodecDecoder.setEnabled(false);
}
}
private void updateStats() {
if (webRtcClient != null) {
webRtcClient.getStats(report -> {
long width = 0, height = 0, fps = 0, delay = 0;
String codecName = "-";
double avgDecodeTimeMs = 0;
// 网速 / 连接类型相关
long bytesReceived = 0;
long bytesSent = 0;
String localCandidateId = null;
String remoteCandidateId = null;
boolean hasNominatedPair = false;
// 抖动 / 丢包 / 丢帧
double jitterMs = 0;
String lossRate = "-";
long framesDropped = 0;
// 控制 DataChannel 收发消息数
long dcMessagesSent = 0;
long dcMessagesReceived = 0;
for (org.webrtc.RTCStats stats : report.getStatsMap().values()) {
String type = stats.getType();
if ("inbound-rtp".equals(type) && "video".equals(stats.getMembers().get("kind"))) {
// 分辨率
Object w = stats.getMembers().get("frameWidth");
if (w instanceof Number) width = ((Number) w).longValue();
Object h = stats.getMembers().get("frameHeight");
if (h instanceof Number) height = ((Number) h).longValue();
// 帧率
Object f = stats.getMembers().get("framesPerSecond");
if (f instanceof Number) fps = ((Number) f).longValue();
// 解码耗时 (平均)
Object totalTime = stats.getMembers().get("totalDecodeTime");
Object frames = stats.getMembers().get("framesDecoded");
if (totalTime instanceof Number && frames instanceof Number) {
double t = ((Number) totalTime).doubleValue();
long c = ((Number) frames).longValue();
if (c > 0) {
avgDecodeTimeMs = (t * 1000.0) / c;
}
}
// 解码格式
Object codecId = stats.getMembers().get("codecId");
if (codecId instanceof String) {
org.webrtc.RTCStats codecStats = report.getStatsMap().get(codecId);
if (codecStats != null) {
Object mime = codecStats.getMembers().get("mimeType");
if (mime instanceof String) {
codecName = (String) mime;
if (codecName.startsWith("video/")) {
codecName = codecName.substring(6);
}
}
}
}
// 解码器实现
Object decoder = stats.getMembers().get("decoderImplementation");
if (decoder instanceof String) {
String d = (String) decoder;
codecName = codecName.equals("-") ? d : codecName + " (" + d + ")";
}
// 抖动(秒 -> 毫秒)
Object jitter = stats.getMembers().get("jitter");
if (jitter instanceof Number) {
jitterMs = ((Number) jitter).doubleValue() * 1000;
}
// 丢包率
Object pr = stats.getMembers().get("packetsReceived");
Object pl = stats.getMembers().get("packetsLost");
if (pr instanceof Number && pl instanceof Number) {
long total = ((Number) pr).longValue() + ((Number) pl).longValue();
lossRate = total > 0
? String.format(Locale.getDefault(), "%.1f", ((Number) pl).doubleValue() / total * 100)
: "0.0";
}
// 丢帧
Object fd = stats.getMembers().get("framesDropped");
if (fd instanceof Number) framesDropped = ((Number) fd).longValue();
}
if ("candidate-pair".equals(type)) {
Object nominated = stats.getMembers().get("nominated");
if (Boolean.TRUE.equals(nominated)) {
hasNominatedPair = true;
Object rtt = stats.getMembers().get("currentRoundTripTime");
if (rtt instanceof Number) {
delay = (long) (((Number) rtt).doubleValue() * 1000);
}
// candidate-pair 的 bytesReceived/bytesSent 为整条连接
// (视频 + DataChannel + ICE的累计值用于计算每秒网速。
Object br = stats.getMembers().get("bytesReceived");
if (br instanceof Number) bytesReceived = ((Number) br).longValue();
Object bs = stats.getMembers().get("bytesSent");
if (bs instanceof Number) bytesSent = ((Number) bs).longValue();
Object lc = stats.getMembers().get("localCandidateId");
if (lc instanceof String) localCandidateId = (String) lc;
Object rc = stats.getMembers().get("remoteCandidateId");
if (rc instanceof String) remoteCandidateId = (String) rc;
}
}
if ("data-channel".equals(type)) {
// 仅统计与对端的控制通道
Object label = stats.getMembers().get("label");
if ("control_channel".equals(label)) {
Object ms = stats.getMembers().get("messagesSent");
if (ms instanceof Number) dcMessagesSent = ((Number) ms).longValue();
Object mr = stats.getMembers().get("messagesReceived");
if (mr instanceof Number)
dcMessagesReceived = ((Number) mr).longValue();
}
}
}
// 计算每秒上下行网速(与上次采样差值)。
long now = System.currentTimeMillis();
String downSpeed = "-";
String upSpeed = "-";
if (prevStatsTime != 0) {
double dt = (now - prevStatsTime) / 1000.0;
if (dt > 0) {
double downBps = (bytesReceived - prevBytesReceived) / dt;
double upBps = (bytesSent - prevBytesSent) / dt;
downSpeed = formatSpeed(downBps);
upSpeed = formatSpeed(upBps);
}
}
prevBytesReceived = bytesReceived;
prevBytesSent = bytesSent;
prevStatsTime = now;
// 判定连接类型与传输协议:两端候选任一为 relay 即走 TURN 中继,否则为 P2P 直连。
String connType = "-";
String protocol = "-";
if (hasNominatedPair) {
connType = resolveConnectionType(report, localCandidateId, remoteCandidateId);
protocol = resolveProtocol(report, localCandidateId, remoteCandidateId);
}
// 连接时长mm:ss
String duration = "-";
if (connectionStartTime != 0) {
long secs = (now - connectionStartTime) / 1000;
duration = String.format(Locale.getDefault(), "%02d:%02d", secs / 60, secs % 60);
}
// 控制 DataChannel 状态
String dcStatus = (webRtcClient != null && webRtcClient.isDataChannelOpen())
? "已连接" : "未连接";
final String statsText = String.format(Locale.getDefault(),
"分辨率: %dx%d 帧率: %d 延迟: %dms\n"
+ "解码格式: %s 抖动: %.0fms 丢包: %s%% 解码耗时: %.1fms\n"
+ "丢帧: %d ↓下载: %s ↑上传: %s\n"
+ "连接类型: %s (%s) 时长: %s\n"
+ "控制通道: %s 收发: %d/%d",
width, height, fps, delay, codecName, jitterMs, lossRate, avgDecodeTimeMs,
framesDropped, downSpeed, upSpeed, connType, protocol,
duration, dcStatus, dcMessagesSent, dcMessagesReceived);
runOnUiThread(() -> {
if (tvStats != null) {
tvStats.setText(statsText);
}
});
});
}
}
// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
private String resolveConnectionType(org.webrtc.RTCStatsReport report,
String localCandidateId,
String remoteCandidateId) {
String local = candidateTypeOf(report, localCandidateId);
String remote = candidateTypeOf(report, remoteCandidateId);
boolean isRelay = "relay".equals(local) || "relay".equals(remote);
if (isRelay) {
if ("relay".equals(local)) return "TURN 中继 (本端经服务器)";
return "TURN 中继";
}
// host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。
return "P2P 直连";
}
// 返回选定候选对的传输协议udp/tcp
private String resolveProtocol(org.webrtc.RTCStatsReport report,
String localCandidateId,
String remoteCandidateId) {
String protoOf = candidateMemberOf(report, localCandidateId, "protocol");
String protoOfRemote = candidateMemberOf(report, remoteCandidateId, "protocol");
if (!"-".equals(protoOf) && !"-".equals(protoOfRemote) && !protoOf.equals(protoOfRemote)) {
return protoOf + "/" + protoOfRemote;
}
return !"-".equals(protoOf) ? protoOf : protoOfRemote;
}
private String candidateTypeOf(org.webrtc.RTCStatsReport report, String candidateId) {
return candidateMemberOf(report, candidateId, "candidateType");
}
private String candidateMemberOf(org.webrtc.RTCStatsReport report, String candidateId, String key) {
if (candidateId == null) return "-";
org.webrtc.RTCStats candidate = report.getStatsMap().get(candidateId);
if (candidate == null) return "-";
Object value = candidate.getMembers().get(key);
return value instanceof String ? (String) value : "-";
}
// 将字节/秒格式化为人类可读字符串B/s、KB/s、MB/s
private String formatSpeed(double bytesPerSecond) {
if (bytesPerSecond >= 1024 * 1024) {
return String.format(Locale.getDefault(), "%.1f MB/s", bytesPerSecond / (1024 * 1024));
} else if (bytesPerSecond >= 1024) {
return String.format(Locale.getDefault(), "%.1f KB/s", bytesPerSecond / 1024);
}
return String.format(Locale.getDefault(), "%.0f B/s", bytesPerSecond);
}
@Override
protected void onDestroy() {
super.onDestroy();
disconnect();
if (eglBase != null) {
eglBase.release();
}
}
}