840 lines
35 KiB
Java
840 lines
35 KiB
Java
package com.ttstd.controller;
|
||
|
||
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 androidx.appcompat.app.AlertDialog;
|
||
|
||
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.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 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);
|
||
|
||
// 默认服务器地址
|
||
etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
|
||
// 生成设备ID(非系统签名,使用兜底方案获取设备标识)
|
||
myDeviceId = DeviceUtils.getSerialNumber(this);
|
||
etDeviceId.setText(myDeviceId);
|
||
|
||
btnConnect.setOnClickListener(v -> showAuthDialog());
|
||
btnDisconnect.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();
|
||
updateUI(false);
|
||
}
|
||
|
||
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));
|
||
});
|
||
}
|
||
|
||
/** 弹出鉴权输入对话框:选择动态验证码或固定密码,输入后发起连接。 */
|
||
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_password) {
|
||
et.setHint("请输入固定密码");
|
||
tip.setText("固定密码为被控端设置的固定密码");
|
||
} else {
|
||
et.setHint("请输入动态验证码");
|
||
tip.setText("动态验证码为被控端界面显示的 6 位大小写字母与数字组合");
|
||
}
|
||
});
|
||
|
||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||
.setTitle("连接鉴权")
|
||
.setView(view)
|
||
.setNegativeButton("取消", null)
|
||
.setPositiveButton("连接", null)
|
||
.create();
|
||
|
||
// 校验失败时不关闭对话框,引导用户重新输入
|
||
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||
.setOnClickListener(v -> {
|
||
boolean isPassword = (rg.getCheckedRadioButtonId() == 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) {
|
||
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.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) {
|
||
}
|
||
});
|
||
}
|
||
|
||
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 (tvStatusControl != null) {
|
||
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
|
||
}
|
||
|
||
if (connected) {
|
||
statsHandler.post(statsRunnable);
|
||
} else {
|
||
statsHandler.removeCallbacks(statsRunnable);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
}
|