在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。 另附带更新信号服务器的数据库配置。
1247 lines
52 KiB
Java
1247 lines
52 KiB
Java
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();
|
||
}
|
||
}
|
||
}
|