init
This commit is contained in:
26
WebRTCController/app/src/main/AndroidManifest.xml
Normal file
26
WebRTCController/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.tt.controller;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.tt.controller.signaling.SignalMessage;
|
||||
import com.tt.controller.signaling.WebSocketClient;
|
||||
import com.tt.controller.view.RemoteTouchView;
|
||||
import com.tt.controller.webrtc.WebRtcClient;
|
||||
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
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 LinearLayout controlPanel;
|
||||
private LinearLayout setupPanel;
|
||||
private TextView tvStatusControl;
|
||||
|
||||
private WebSocketClient wsClient;
|
||||
private WebRtcClient webRtcClient;
|
||||
private EglBase eglBase;
|
||||
private final Gson gson = new Gson();
|
||||
private String myDeviceId;
|
||||
private String targetDeviceId;
|
||||
|
||||
@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);
|
||||
|
||||
// 默认服务器地址
|
||||
etServerUrl.setText("ws://192.168.1.100:8080/ws/signal");
|
||||
// 生成设备ID
|
||||
myDeviceId = "controller_" + UUID.randomUUID().toString().substring(0, 8);
|
||||
etDeviceId.setText(myDeviceId);
|
||||
|
||||
btnConnect.setOnClickListener(v -> connectToControlled());
|
||||
btnDisconnect.setOnClickListener(v -> disconnect());
|
||||
|
||||
// 初始化 EGL 和远端视频渲染
|
||||
eglBase = EglBase.create();
|
||||
remoteVideoView.init(eglBase.getEglBaseContext(), null);
|
||||
remoteVideoView.setZOrderMediaOverlay(true);
|
||||
|
||||
// 设置触摸事件
|
||||
touchOverlay.setTouchEventListener(new RemoteTouchView.TouchEventListener() {
|
||||
@Override
|
||||
public void onTouch(float relX, float relY) {
|
||||
sendTouchCommand(relX, relY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||
sendSwipeCommand(relX1, relY1, relX2, relY2, duration);
|
||||
}
|
||||
});
|
||||
|
||||
updateUI(false);
|
||||
}
|
||||
|
||||
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() {
|
||||
runOnUiThread(() -> {
|
||||
tvStatus.setText("状态: 已连接 - 远程控制中");
|
||||
updateUI(true);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionFailed(String error) {
|
||||
runOnUiThread(() -> {
|
||||
tvStatus.setText("状态: 连接失败 - " + error);
|
||||
updateUI(false);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
runOnUiThread(() -> {
|
||||
tvStatus.setText("状态: 远端已断开");
|
||||
updateUI(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
webRtcClient.createOffer(targetDeviceId, remoteVideoView);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleAnswer(SignalMessage message) {
|
||||
try {
|
||||
JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class);
|
||||
String sdp = payload.get("sdp").getAsString();
|
||||
if (webRtcClient != null) {
|
||||
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) {
|
||||
JsonObject cmd = new JsonObject();
|
||||
cmd.addProperty("action", "TOUCH");
|
||||
cmd.addProperty("x", relX);
|
||||
cmd.addProperty("y", relY);
|
||||
webRtcClient.sendControlCommand(cmd.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||
if (webRtcClient != null) {
|
||||
JsonObject cmd = new JsonObject();
|
||||
cmd.addProperty("action", "SWIPE");
|
||||
cmd.addProperty("x1", relX1);
|
||||
cmd.addProperty("y1", relY1);
|
||||
cmd.addProperty("x2", relX2);
|
||||
cmd.addProperty("y2", relY2);
|
||||
cmd.addProperty("duration", duration);
|
||||
webRtcClient.sendControlCommand(cmd.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnect() {
|
||||
if (webRtcClient != null) {
|
||||
webRtcClient.close();
|
||||
webRtcClient = null;
|
||||
}
|
||||
if (wsClient != null) {
|
||||
wsClient.disconnect();
|
||||
wsClient = null;
|
||||
}
|
||||
remoteVideoView.clearImage();
|
||||
updateUI(false);
|
||||
tvStatus.setText("状态: 已停止");
|
||||
}
|
||||
|
||||
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 (tvStatusControl != null) {
|
||||
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
disconnect();
|
||||
if (eglBase != null) {
|
||||
eglBase.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.tt.controller.signaling;
|
||||
|
||||
public class SignalMessage {
|
||||
|
||||
private String type;
|
||||
private String fromDeviceId;
|
||||
private String toDeviceId;
|
||||
private String deviceType;
|
||||
private String payload;
|
||||
|
||||
public SignalMessage() {}
|
||||
|
||||
public SignalMessage(String type, String fromDeviceId, String toDeviceId, String payload) {
|
||||
this.type = type;
|
||||
this.fromDeviceId = fromDeviceId;
|
||||
this.toDeviceId = toDeviceId;
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
|
||||
public String getFromDeviceId() { return fromDeviceId; }
|
||||
public void setFromDeviceId(String fromDeviceId) { this.fromDeviceId = fromDeviceId; }
|
||||
|
||||
public String getToDeviceId() { return toDeviceId; }
|
||||
public void setToDeviceId(String toDeviceId) { this.toDeviceId = toDeviceId; }
|
||||
|
||||
public String getDeviceType() { return deviceType; }
|
||||
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
|
||||
|
||||
public String getPayload() { return payload; }
|
||||
public void setPayload(String payload) { this.payload = payload; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.tt.controller.signaling;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
|
||||
public class WebSocketClient {
|
||||
|
||||
private static final String TAG = "WebSocketClient";
|
||||
|
||||
private final String serverUrl;
|
||||
private final String deviceId;
|
||||
private final Gson gson = new Gson();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private OkHttpClient client;
|
||||
private WebSocket webSocket;
|
||||
private SignalListener listener;
|
||||
|
||||
public interface SignalListener {
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
void onError(String error);
|
||||
void onMessage(SignalMessage message);
|
||||
}
|
||||
|
||||
public WebSocketClient(String serverUrl, String deviceId) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public void setListener(SignalListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
client = new OkHttpClient.Builder().build();
|
||||
Request request = new Request.Builder().url(serverUrl).build();
|
||||
|
||||
webSocket = client.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket ws, Response response) {
|
||||
Log.i(TAG, "WebSocket connected");
|
||||
registerDevice();
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onConnected();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket ws, String text) {
|
||||
Log.d(TAG, "Received: " + text);
|
||||
try {
|
||||
SignalMessage message = gson.fromJson(text, SignalMessage.class);
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onMessage(message);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error parsing message", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket ws, int code, String reason) {
|
||||
Log.i(TAG, "WebSocket closed: " + reason);
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onDisconnected();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket ws, Throwable t, Response response) {
|
||||
Log.e(TAG, "WebSocket error: " + t.getMessage(), t);
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) listener.onError(t.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void sendMessage(SignalMessage message) {
|
||||
if (webSocket != null) {
|
||||
String json = gson.toJson(message);
|
||||
Log.d(TAG, "Sending: " + json);
|
||||
webSocket.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "Disconnecting");
|
||||
}
|
||||
if (client != null) {
|
||||
client.dispatcher().executorService().shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void registerDevice() {
|
||||
SignalMessage registerMsg = new SignalMessage();
|
||||
registerMsg.setType("REGISTER");
|
||||
registerMsg.setFromDeviceId(deviceId);
|
||||
registerMsg.setDeviceType("CONTROLLER");
|
||||
sendMessage(registerMsg);
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return webSocket != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.tt.controller.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceView;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* 自定义SurfaceView,用于捕获触摸事件并转换为远端输入指令。
|
||||
* 触摸坐标会被转换为相对于视图的百分比坐标(0.0-1.0),
|
||||
* 以便在不同分辨率的设备间进行坐标换算。
|
||||
*/
|
||||
public class RemoteTouchView extends SurfaceView {
|
||||
|
||||
private static final String TAG = "RemoteTouchView";
|
||||
private final Gson gson = new Gson();
|
||||
private TouchEventListener listener;
|
||||
|
||||
private float lastTouchX;
|
||||
private float lastTouchY;
|
||||
private long swipeStartTime;
|
||||
|
||||
public interface TouchEventListener {
|
||||
void onTouch(float relX, float relY);
|
||||
void onSwipe(float relX1, float relY1, float relX2, float relY2, long duration);
|
||||
}
|
||||
|
||||
public RemoteTouchView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public RemoteTouchView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public RemoteTouchView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setTouchEventListener(TouchEventListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
if (listener == null) return super.onTouchEvent(event);
|
||||
|
||||
float relX = event.getX() / getWidth();
|
||||
float relY = event.getY() / getHeight();
|
||||
|
||||
// 确保坐标在 [0, 1] 范围内
|
||||
relX = Math.max(0, Math.min(1, relX));
|
||||
relY = Math.max(0, Math.min(1, relY));
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
lastTouchX = relX;
|
||||
lastTouchY = relY;
|
||||
swipeStartTime = System.currentTimeMillis();
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
// 实时更新坐标(可选:用于拖拽效果)
|
||||
lastTouchX = relX;
|
||||
lastTouchY = relY;
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_UP:
|
||||
long duration = System.currentTimeMillis() - swipeStartTime;
|
||||
float dx = Math.abs(relX - lastTouchX);
|
||||
float dy = Math.abs(relY - lastTouchY);
|
||||
|
||||
if (duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||
// 点击事件
|
||||
listener.onTouch(lastTouchX, lastTouchY);
|
||||
Log.d(TAG, "Touch at: " + lastTouchX + ", " + lastTouchY);
|
||||
} else {
|
||||
// 滑动事件
|
||||
listener.onSwipe(lastTouchX, lastTouchY, relX, relY, duration);
|
||||
Log.d(TAG, "Swipe from (" + lastTouchX + "," + lastTouchY + ") to (" + relX + "," + relY + ")");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
public String createTouchCommand(float relX, float relY) {
|
||||
JsonObject cmd = new JsonObject();
|
||||
cmd.addProperty("action", "TOUCH");
|
||||
cmd.addProperty("x", relX);
|
||||
cmd.addProperty("y", relY);
|
||||
return cmd.toString();
|
||||
}
|
||||
|
||||
public String createSwipeCommand(float relX1, float relY1, float relX2, float relY2, long duration) {
|
||||
JsonObject cmd = new JsonObject();
|
||||
cmd.addProperty("action", "SWIPE");
|
||||
cmd.addProperty("x1", relX1);
|
||||
cmd.addProperty("y1", relY1);
|
||||
cmd.addProperty("x2", relX2);
|
||||
cmd.addProperty("y2", relY2);
|
||||
cmd.addProperty("duration", duration);
|
||||
return cmd.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.tt.controller.webrtc;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.webrtc.SdpObserver;
|
||||
import org.webrtc.SessionDescription;
|
||||
|
||||
public class AppSdpObserver implements SdpObserver {
|
||||
|
||||
private static final String TAG = "AppSdpObserver";
|
||||
|
||||
private final SdpEventListener listener;
|
||||
|
||||
public interface SdpEventListener {
|
||||
void onCreateSuccess(SessionDescription sdp);
|
||||
void onSetSuccess();
|
||||
void onCreateFailure(String error);
|
||||
void onSetFailure(String error);
|
||||
}
|
||||
|
||||
public AppSdpObserver(SdpEventListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateSuccess(SessionDescription sdp) {
|
||||
Log.d(TAG, "SDP created: " + sdp.type);
|
||||
if (listener != null) listener.onCreateSuccess(sdp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetSuccess() {
|
||||
Log.d(TAG, "SDP set success");
|
||||
if (listener != null) listener.onSetSuccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateFailure(String error) {
|
||||
Log.e(TAG, "SDP create failure: " + error);
|
||||
if (listener != null) listener.onCreateFailure(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetFailure(String error) {
|
||||
Log.e(TAG, "SDP set failure: " + error);
|
||||
if (listener != null) listener.onSetFailure(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.tt.controller.webrtc;
|
||||
|
||||
import org.webrtc.DataChannel;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
import org.webrtc.RtpReceiver;
|
||||
|
||||
public class PeerObserver implements PeerConnection.Observer {
|
||||
|
||||
private static final String TAG = "PeerObserver";
|
||||
|
||||
private final PeerEventListener listener;
|
||||
|
||||
public interface PeerEventListener {
|
||||
void onIceCandidate(IceCandidate candidate);
|
||||
void onIceConnectionChange(PeerConnection.IceConnectionState newState);
|
||||
void onDataChannel(DataChannel dataChannel);
|
||||
void onAddStream(MediaStream stream);
|
||||
void onRemoveStream(MediaStream stream);
|
||||
}
|
||||
|
||||
public PeerObserver(PeerEventListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSignalingChange(PeerConnection.SignalingState newState) {
|
||||
android.util.Log.d(TAG, "Signaling state: " + newState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
||||
android.util.Log.d(TAG, "ICE connection state: " + newState);
|
||||
if (listener != null) listener.onIceConnectionChange(newState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceConnectionReceivingChange(boolean receiving) {}
|
||||
|
||||
@Override
|
||||
public void onIceGatheringChange(PeerConnection.IceGatheringState newState) {
|
||||
android.util.Log.d(TAG, "ICE gathering state: " + newState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceCandidate(IceCandidate candidate) {
|
||||
if (listener != null) listener.onIceCandidate(candidate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceCandidatesRemoved(IceCandidate[] candidates) {}
|
||||
|
||||
@Override
|
||||
public void onAddStream(MediaStream stream) {
|
||||
if (listener != null) listener.onAddStream(stream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveStream(MediaStream stream) {
|
||||
if (listener != null) listener.onRemoveStream(stream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataChannel(DataChannel dataChannel) {
|
||||
android.util.Log.d(TAG, "Data channel received: " + dataChannel.label());
|
||||
if (listener != null) listener.onDataChannel(dataChannel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenegotiationNeeded() {
|
||||
android.util.Log.d(TAG, "Renegotiation needed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.tt.controller.webrtc;
|
||||
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.RendererCommon;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
import org.webrtc.VideoFrame;
|
||||
import org.webrtc.VideoSink;
|
||||
|
||||
/**
|
||||
* 远端视频流渲染器,将WebRTC视频帧渲染到SurfaceViewRenderer。
|
||||
*/
|
||||
public class VideoRenderer implements VideoSink {
|
||||
|
||||
private static final String TAG = "VideoRenderer";
|
||||
private final SurfaceViewRenderer surfaceViewRenderer;
|
||||
|
||||
public VideoRenderer(SurfaceViewRenderer surfaceViewRenderer) {
|
||||
this.surfaceViewRenderer = surfaceViewRenderer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFrame(VideoFrame frame) {
|
||||
surfaceViewRenderer.onFrame(frame);
|
||||
}
|
||||
|
||||
public void init(EglBase eglBase) {
|
||||
surfaceViewRenderer.init(eglBase.getEglBaseContext(), null);
|
||||
surfaceViewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
|
||||
surfaceViewRenderer.setEnableHardwareScaler(true);
|
||||
}
|
||||
|
||||
public void release() {
|
||||
surfaceViewRenderer.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.tt.controller.webrtc;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.tt.controller.signaling.SignalMessage;
|
||||
import com.tt.controller.signaling.WebSocketClient;
|
||||
|
||||
import org.webrtc.DataChannel;
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.MediaConstraints;
|
||||
import org.webrtc.MediaStream;
|
||||
import org.webrtc.PeerConnection;
|
||||
import org.webrtc.PeerConnectionFactory;
|
||||
import org.webrtc.SessionDescription;
|
||||
import org.webrtc.SurfaceViewRenderer;
|
||||
import org.webrtc.VideoTrack;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class WebRtcClient {
|
||||
|
||||
private static final String TAG = "WebRtcClient";
|
||||
private static final String DATA_CHANNEL_LABEL = "control_channel";
|
||||
|
||||
private final Context context;
|
||||
private final WebSocketClient wsClient;
|
||||
private final String deviceId;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private PeerConnectionFactory peerConnectionFactory;
|
||||
private PeerConnection peerConnection;
|
||||
private DataChannel dataChannel;
|
||||
private EglBase eglBase;
|
||||
private String currentControlledId;
|
||||
private VideoRenderer videoRenderer;
|
||||
|
||||
public interface ConnectionListener {
|
||||
void onConnectionEstablished();
|
||||
void onConnectionFailed(String error);
|
||||
void onDisconnected();
|
||||
}
|
||||
|
||||
private ConnectionListener connectionListener;
|
||||
|
||||
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
|
||||
this.context = context;
|
||||
this.wsClient = wsClient;
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public void setConnectionListener(ConnectionListener listener) {
|
||||
this.connectionListener = listener;
|
||||
}
|
||||
|
||||
public void initialize(EglBase eglBase) {
|
||||
this.eglBase = eglBase;
|
||||
PeerConnectionFactory.InitializationOptions initOptions =
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.createInitializationOptions();
|
||||
PeerConnectionFactory.initialize(initOptions);
|
||||
|
||||
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
|
||||
peerConnectionFactory = PeerConnectionFactory.builder()
|
||||
.setOptions(options)
|
||||
.createPeerConnectionFactory();
|
||||
}
|
||||
|
||||
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) {
|
||||
this.currentControlledId = controlledId;
|
||||
|
||||
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(new ArrayList<>());
|
||||
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
|
||||
|
||||
PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() {
|
||||
@Override
|
||||
public void onIceCandidate(IceCandidate candidate) {
|
||||
sendIceCandidate(candidate, controlledId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
|
||||
Log.d(TAG, "ICE connection state: " + newState);
|
||||
if (newState == PeerConnection.IceConnectionState.CONNECTED) {
|
||||
if (connectionListener != null) connectionListener.onConnectionEstablished();
|
||||
} else if (newState == PeerConnection.IceConnectionState.FAILED ||
|
||||
newState == PeerConnection.IceConnectionState.DISCONNECTED) {
|
||||
if (connectionListener != null) connectionListener.onDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataChannel(DataChannel dc) {
|
||||
Log.d(TAG, "Data channel received: " + dc.label());
|
||||
handleDataChannel(dc);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAddStream(MediaStream stream) {
|
||||
if (!stream.videoTracks.isEmpty()) {
|
||||
VideoTrack videoTrack = stream.videoTracks.get(0);
|
||||
if (videoRenderer != null) {
|
||||
videoTrack.addSink(videoRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveStream(MediaStream stream) {}
|
||||
});
|
||||
|
||||
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
|
||||
|
||||
// 创建 DataChannel
|
||||
DataChannel.Init dcInit = new DataChannel.Init();
|
||||
dcInit.ordered = true;
|
||||
dataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL, dcInit);
|
||||
handleDataChannel(dataChannel);
|
||||
|
||||
// 初始化远端视频渲染器
|
||||
if (remoteRenderer != null) {
|
||||
videoRenderer = new VideoRenderer(remoteRenderer);
|
||||
}
|
||||
|
||||
// 创建 Offer
|
||||
MediaConstraints constraints = new MediaConstraints();
|
||||
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"));
|
||||
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"));
|
||||
|
||||
peerConnection.createOffer(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
|
||||
@Override
|
||||
public void onCreateSuccess(SessionDescription sdp) {
|
||||
peerConnection.setLocalDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
|
||||
@Override
|
||||
public void onCreateSuccess(SessionDescription s) {}
|
||||
@Override
|
||||
public void onSetSuccess() {
|
||||
sendOffer(sdp.description, controlledId);
|
||||
}
|
||||
@Override
|
||||
public void onCreateFailure(String error) {}
|
||||
@Override
|
||||
public void onSetFailure(String error) {}
|
||||
}), sdp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetSuccess() {}
|
||||
@Override
|
||||
public void onCreateFailure(String error) {
|
||||
if (connectionListener != null) connectionListener.onConnectionFailed(error);
|
||||
}
|
||||
@Override
|
||||
public void onSetFailure(String error) {}
|
||||
}), constraints);
|
||||
}
|
||||
|
||||
public void handleAnswer(String answerSdp) {
|
||||
if (peerConnection != null) {
|
||||
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.ANSWER, answerSdp);
|
||||
peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
|
||||
@Override
|
||||
public void onCreateSuccess(SessionDescription sdp) {}
|
||||
@Override
|
||||
public void onSetSuccess() {
|
||||
Log.d(TAG, "Remote answer set successfully");
|
||||
}
|
||||
@Override
|
||||
public void onCreateFailure(String error) {}
|
||||
@Override
|
||||
public void onSetFailure(String error) {}
|
||||
}), remoteSdp);
|
||||
}
|
||||
}
|
||||
|
||||
public void addIceCandidate(IceCandidate candidate) {
|
||||
if (peerConnection != null) {
|
||||
peerConnection.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendControlCommand(String commandJson) {
|
||||
if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) {
|
||||
DataChannel.Buffer buffer = new DataChannel.Buffer(
|
||||
ByteBuffer.wrap(commandJson.getBytes(StandardCharsets.UTF_8)), false);
|
||||
dataChannel.send(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (dataChannel != null) {
|
||||
dataChannel.close();
|
||||
dataChannel.dispose();
|
||||
}
|
||||
if (peerConnection != null) {
|
||||
peerConnection.close();
|
||||
peerConnection.dispose();
|
||||
}
|
||||
if (peerConnectionFactory != null) {
|
||||
peerConnectionFactory.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDataChannel(DataChannel dc) {
|
||||
dc.registerObserver(new DataChannel.Observer() {
|
||||
@Override
|
||||
public void onBufferedAmountChange(long previousAmount) {}
|
||||
|
||||
@Override
|
||||
public void onStateChange() {
|
||||
Log.d(TAG, "DataChannel state: " + dc.state());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(DataChannel.Buffer buffer) {
|
||||
byte[] data = new byte[buffer.data.remaining()];
|
||||
buffer.data.get(data);
|
||||
String message = new String(data, StandardCharsets.UTF_8);
|
||||
Log.d(TAG, "DataChannel message: " + message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendOffer(String sdp, String controlledId) {
|
||||
SignalMessage msg = new SignalMessage();
|
||||
msg.setType("OFFER");
|
||||
msg.setFromDeviceId(deviceId);
|
||||
msg.setToDeviceId(controlledId);
|
||||
msg.setDeviceType("CONTROLLER");
|
||||
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("sdp", sdp);
|
||||
msg.setPayload(payload.toString());
|
||||
|
||||
wsClient.sendMessage(msg);
|
||||
}
|
||||
|
||||
private void sendIceCandidate(IceCandidate candidate, String controlledId) {
|
||||
SignalMessage msg = new SignalMessage();
|
||||
msg.setType("ICE_CANDIDATE");
|
||||
msg.setFromDeviceId(deviceId);
|
||||
msg.setToDeviceId(controlledId);
|
||||
msg.setDeviceType("CONTROLLER");
|
||||
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("sdpMid", candidate.sdpMid);
|
||||
payload.addProperty("sdpMLineIndex", candidate.sdpMLineIndex);
|
||||
payload.addProperty("candidate", candidate.sdp);
|
||||
msg.setPayload(payload.toString());
|
||||
|
||||
wsClient.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
124
WebRTCController/app/src/main/res/layout/activity_main.xml
Normal file
124
WebRTCController/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- 设置面板 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/setup_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="WebRTC 控制端"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="24dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="信令服务器地址:"
|
||||
android:textSize="14sp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_server_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:hint="ws://192.168.1.100:8080/ws/signal"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="本机设备ID:"
|
||||
android:textSize="14sp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_device_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:hint="设备ID"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="目标被控设备ID:"
|
||||
android:textSize="14sp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_target_device_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:hint="被控端设备ID"
|
||||
android:layout_marginBottom="24dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="状态: 已停止"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="24dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_connect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="连接被控设备"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="断开连接"
|
||||
android:enabled="false"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 控制面板(连接后显示) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/control_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- 远端视频渲染 -->
|
||||
<org.webrtc.SurfaceViewRenderer
|
||||
android:id="@+id/remote_video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
|
||||
<!-- 触摸控制层(覆盖在视频上方) -->
|
||||
<com.tt.controller.view.RemoteTouchView
|
||||
android:id="@+id/touch_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"/>
|
||||
|
||||
<!-- 顶部状态栏 -->
|
||||
<TextView
|
||||
android:id="@+id/tv_status_control"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:padding="8dp"
|
||||
android:background="#80000000"
|
||||
android:textColor="@android:color/white"
|
||||
android:text="远程控制中"
|
||||
android:textSize="14sp"/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
4
WebRTCController/app/src/main/res/values/strings.xml
Normal file
4
WebRTCController/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">WebRTC控制端</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user