feat: 增加预览并远程显示画面

This commit is contained in:
2026-07-14 00:23:36 +08:00
parent c3b816415a
commit b2af050e5d
21 changed files with 709 additions and 107 deletions

View File

@@ -1,4 +1,5 @@
/build/ /build/
/app/build/ /app/build/
/.idea/ /.idea/
/.gradle/ /.gradle/
config.gradle

View File

@@ -2,6 +2,14 @@ plugins {
id 'com.android.application' id 'com.android.application'
} }
def releaseTime() {
return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
}
def appName() {
return "WebRTCControlled"
}
android { android {
namespace 'com.tt.controlled' namespace 'com.tt.controlled'
compileSdk 34 compileSdk 34
@@ -25,10 +33,85 @@ android {
aidl true aidl true
} }
signingConfigs {
keypub {
storeFile file(rootProject.ext.signingConfigs.keypub.storeFile)
storePassword rootProject.ext.signingConfigs.keypub.storePassword
keyAlias rootProject.ext.signingConfigs.keypub.keyAlias
keyPassword rootProject.ext.signingConfigs.keypub.keyPassword
}
crosshatch {
storeFile file(rootProject.ext.signingConfigs.crosshatch.storeFile)
storePassword rootProject.ext.signingConfigs.crosshatch.storePassword
keyAlias rootProject.ext.signingConfigs.crosshatch.keyAlias
keyPassword rootProject.ext.signingConfigs.crosshatch.keyPassword
}
zhanxun {
storeFile file(rootProject.ext.signingConfigs.zhanxun.storeFile)
storePassword rootProject.ext.signingConfigs.zhanxun.storePassword
keyAlias rootProject.ext.signingConfigs.zhanxun.keyAlias
keyPassword rootProject.ext.signingConfigs.zhanxun.keyPassword
}
}
buildTypes { buildTypes {
zhanRuiDebug.initWith(debug)
zhanRuiDebug {
debuggable true
signingConfig signingConfigs.zhanxun
}
zhanRuiRelease.initWith(release)
zhanRuiRelease {
signingConfig signingConfigs.zhanxun
}
CrosshatchDebug.initWith(debug)
CrosshatchDebug {
debuggable true
signingConfig signingConfigs.crosshatch
}
CrosshatchRelease.initWith(release)
CrosshatchRelease {
signingConfig signingConfigs.crosshatch
}
debug {
versionNameSuffix "_debug"
debuggable true
minifyEnabled false
//Zipalign优化
zipAlignEnabled true
signingConfig signingConfigs.keypub
}
release { release {
minifyEnabled false minifyEnabled false
//Zipalign优化
zipAlignEnabled true
//前一部分代表系统默认的android程序的混淆文件该文件已经包含了基本的混淆声明后一个文件是自己的定义混淆文件
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//签名
signingConfig signingConfigs.keypub
}
}
// Add application variant configuration here instead
applicationVariants.all { variant ->
variant.outputs.each { output ->
def buildType = variant.buildType.name
def fileName = ""
if (buildType.contains("debug")) {
fileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk"
} else {
fileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk"
}
output.outputFileName = fileName
} }
} }

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:sharedUserId="android.uid.system">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -12,6 +13,7 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight"> android:theme="@style/Theme.MaterialComponents.DayNight">

View File

@@ -1,12 +1,15 @@
package com.tt.controlled; package com.tt.controlled;
import android.Manifest; import android.Manifest;
import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.media.projection.MediaProjectionManager; import android.media.projection.MediaProjectionManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder;
import android.provider.Settings; import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
@@ -22,6 +25,10 @@ import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.tt.controlled.service.ScreenCaptureService; import com.tt.controlled.service.ScreenCaptureService;
import com.tt.controlled.webrtc.WebRtcClient;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import java.util.UUID; import java.util.UUID;
@@ -36,8 +43,28 @@ public class MainActivity extends AppCompatActivity {
private Button btnStart; private Button btnStart;
private Button btnStop; private Button btnStop;
private TextView tvStatus; private TextView tvStatus;
private SurfaceViewRenderer localView;
private boolean isServiceRunning = false; private boolean isServiceRunning = false;
private ScreenCaptureService screenCaptureService;
private boolean isBound = false;
private boolean isLocalViewInitialized = false;
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
ScreenCaptureService.LocalBinder binder = (ScreenCaptureService.LocalBinder) service;
screenCaptureService = binder.getService();
isBound = true;
setupLocalPreview();
}
@Override
public void onServiceDisconnected(ComponentName name) {
isBound = false;
screenCaptureService = null;
}
};
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -49,11 +76,12 @@ public class MainActivity extends AppCompatActivity {
btnStart = findViewById(R.id.btn_start); btnStart = findViewById(R.id.btn_start);
btnStop = findViewById(R.id.btn_stop); btnStop = findViewById(R.id.btn_stop);
tvStatus = findViewById(R.id.tv_status); tvStatus = findViewById(R.id.tv_status);
localView = findViewById(R.id.local_view);
// 默认服务器地址 // 默认服务器地址
etServerUrl.setText("ws://192.168.1.100:8080/ws/signal"); etServerUrl.setText("ws://192.168.100.222:8080/ws/signal");
// 生成设备ID // 生成设备ID
etDeviceId.setText("controlled_" + UUID.randomUUID().toString().substring(0, 8)); // etDeviceId.setText("controlled_" + UUID.randomUUID().toString().substring(0, 8));
btnStart.setOnClickListener(v -> startScreenSharing()); btnStart.setOnClickListener(v -> startScreenSharing());
btnStop.setOnClickListener(v -> stopScreenSharing()); btnStop.setOnClickListener(v -> stopScreenSharing());
@@ -97,6 +125,8 @@ public class MainActivity extends AppCompatActivity {
startService(serviceIntent); startService(serviceIntent);
} }
bindService(serviceIntent, serviceConnection, BIND_AUTO_CREATE);
updateUI(true); updateUI(true);
Toast.makeText(this, "屏幕共享已启动", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "屏幕共享已启动", Toast.LENGTH_SHORT).show();
} else { } else {
@@ -118,12 +148,53 @@ public class MainActivity extends AppCompatActivity {
} }
private void stopScreenSharing() { private void stopScreenSharing() {
if (isBound) {
unbindService(serviceConnection);
isBound = false;
}
Intent serviceIntent = new Intent(this, ScreenCaptureService.class); Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
stopService(serviceIntent); stopService(serviceIntent);
if (localView != null) {
localView.release();
isLocalViewInitialized = false;
}
updateUI(false); updateUI(false);
Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show();
} }
private void setupLocalPreview() {
if (isBound && screenCaptureService != null && !isLocalViewInitialized) {
WebRtcClient webRtcClient = screenCaptureService.getWebRtcClient();
if (webRtcClient != null && webRtcClient.getEglContext() != null) {
try {
localView.init(webRtcClient.getEglContext(), null);
localView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
localView.setMirror(false);
webRtcClient.setLocalSink(localView);
isLocalViewInitialized = true;
} catch (Exception e) {
Log.e(TAG, "Error initializing local view", e);
}
} else {
// 如果 webRtcClient 还没准备好,稍后重试
localView.postDelayed(this::setupLocalPreview, 500);
}
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (isBound) {
unbindService(serviceConnection);
isBound = false;
}
if (localView != null) {
localView.release();
isLocalViewInitialized = false;
}
}
private void updateUI(boolean running) { private void updateUI(boolean running) {
isServiceRunning = running; isServiceRunning = running;
btnStart.setEnabled(!running); btnStart.setEnabled(!running);

View File

@@ -11,6 +11,7 @@ import android.content.Intent;
import android.content.pm.ServiceInfo; import android.content.pm.ServiceInfo;
import android.media.projection.MediaProjection; import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager; import android.media.projection.MediaProjectionManager;
import android.os.Binder;
import android.os.Build; import android.os.Build;
import android.os.IBinder; import android.os.IBinder;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
@@ -85,20 +86,50 @@ public class ScreenCaptureService extends Service {
return START_NOT_STICKY; return START_NOT_STICKY;
} }
// 获取屏幕尺寸 // 获取屏幕真实尺寸和刷新率
WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics(); DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
inputHandler = new InputCommandHandler(dm.widthPixels, dm.heightPixels); wm.getDefaultDisplay().getRealMetrics(dm);
} else {
wm.getDefaultDisplay().getMetrics(dm);
}
int width = dm.widthPixels;
int height = dm.heightPixels;
float refreshRate = 30;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
refreshRate = wm.getDefaultDisplay().getRefreshRate();
} catch (Exception ignored) {}
}
int fps = Math.round(refreshRate);
if (fps > 60) fps = 60; // 限制最大 60fps
if (fps <= 0) fps = 30;
startScreenCapture(resultCode, resultData, serverUrl, deviceId); Log.i(TAG, "Screen resolution: " + width + "x" + height + " @ " + fps + "fps");
inputHandler = new InputCommandHandler(width, height);
startScreenCapture(resultCode, resultData, serverUrl, deviceId, width, height, fps);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
private final IBinder binder = new LocalBinder();
public class LocalBinder extends Binder {
public ScreenCaptureService getService() {
return ScreenCaptureService.this;
}
}
@Nullable @Nullable
@Override @Override
public IBinder onBind(Intent intent) { public IBinder onBind(Intent intent) {
return null; return binder;
}
public WebRtcClient getWebRtcClient() {
return webRtcClient;
} }
@Override @Override
@@ -122,7 +153,7 @@ public class ScreenCaptureService extends Service {
} }
} }
private void startScreenCapture(int resultCode, Intent resultData, String serverUrl, String deviceId) { private void startScreenCapture(int resultCode, Intent resultData, String serverUrl, String deviceId, int width, int height, int fps) {
// 初始化 MediaProjection // 初始化 MediaProjection
MediaProjectionManager projectionManager = MediaProjectionManager projectionManager =
(MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
@@ -166,7 +197,7 @@ public class ScreenCaptureService extends Service {
}); });
webRtcClient.setVideoCapturer(screenCapturer); webRtcClient.setVideoCapturer(screenCapturer, width, height, fps);
} }
private void handleSignalMessage(SignalMessage message) { private void handleSignalMessage(SignalMessage message) {

View File

@@ -5,6 +5,7 @@ import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
@@ -18,7 +19,7 @@ public class WebSocketClient {
private final String serverUrl; private final String serverUrl;
private final String deviceId; private final String deviceId;
private final Gson gson = new Gson(); private final Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private OkHttpClient client; private OkHttpClient client;
private WebSocket webSocket; private WebSocket webSocket;

View File

@@ -4,11 +4,13 @@ import android.content.Context;
import android.util.Log; import android.util.Log;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.tt.controlled.signaling.SignalMessage; import com.tt.controlled.signaling.SignalMessage;
import com.tt.controlled.signaling.WebSocketClient; import com.tt.controlled.signaling.WebSocketClient;
import org.webrtc.DataChannel; import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoDecoderFactory;
import org.webrtc.DefaultVideoEncoderFactory; import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.EglBase; import org.webrtc.EglBase;
import org.webrtc.IceCandidate; import org.webrtc.IceCandidate;
@@ -17,13 +19,17 @@ import org.webrtc.MediaStream;
import org.webrtc.PeerConnection; import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory; import org.webrtc.PeerConnectionFactory;
import org.webrtc.SessionDescription; import org.webrtc.SessionDescription;
import org.webrtc.SurfaceTextureHelper;
import org.webrtc.VideoCapturer; import org.webrtc.VideoCapturer;
import org.webrtc.VideoCodecInfo;
import org.webrtc.VideoSink;
import org.webrtc.VideoSource; import org.webrtc.VideoSource;
import org.webrtc.VideoTrack; import org.webrtc.VideoTrack;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
public class WebRtcClient { public class WebRtcClient {
@@ -36,7 +42,7 @@ public class WebRtcClient {
private final Context context; private final Context context;
private final WebSocketClient wsClient; private final WebSocketClient wsClient;
private final String deviceId; private final String deviceId;
private final Gson gson = new Gson(); private final Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private PeerConnectionFactory peerConnectionFactory; private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection; private PeerConnection peerConnection;
@@ -65,30 +71,77 @@ public class WebRtcClient {
this.eglBase = eglBase; this.eglBase = eglBase;
PeerConnectionFactory.InitializationOptions initOptions = PeerConnectionFactory.InitializationOptions initOptions =
PeerConnectionFactory.InitializationOptions.builder(context) PeerConnectionFactory.InitializationOptions.builder(context)
.setFieldTrials("WebRTC-H264HighProfile/Enabled/") .setFieldTrials("WebRTC-H264HighProfile/Enabled/WebRTC-Video-HwAcceleration/Enabled/")
.createInitializationOptions(); .createInitializationOptions();
PeerConnectionFactory.initialize(initOptions); PeerConnectionFactory.initialize(initOptions);
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true);
DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
// 打印所有支持的编码格式
Log.i(TAG, "Supported Encoders:");
for (VideoCodecInfo info : encoderFactory.getSupportedCodecs()) {
Log.i(TAG, " - Codec: " + info.name + ", params: " + info.params);
}
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
peerConnectionFactory = PeerConnectionFactory.builder() peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options) .setOptions(options)
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true)) .setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.createPeerConnectionFactory(); .createPeerConnectionFactory();
} }
public void setVideoCapturer(VideoCapturer capturer) { public void setVideoCapturer(VideoCapturer capturer, int width, int height, int fps) {
VideoSource videoSource = peerConnectionFactory.createVideoSource(false); SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext());
capturer.initialize(null, context, videoSource.getCapturerObserver()); // 对于屏幕共享isScreencast 设为 true这会优化细节保留
capturer.startCapture(1280, 720, 30); VideoSource videoSource = peerConnectionFactory.createVideoSource(true);
capturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
capturer.startCapture(width, height, fps);
videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource); videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);
if (localSink != null) {
videoTrack.addSink(localSink);
}
}
private VideoSink localSink;
public void setLocalSink(VideoSink sink) {
this.localSink = sink;
if (videoTrack != null && sink != null) {
videoTrack.addSink(sink);
}
}
public EglBase.Context getEglContext() {
return eglBase != null ? eglBase.getEglBaseContext() : null;
} }
public void createPeerConnectionAndAnswer(String offerSdp, String controllerId) { public void createPeerConnectionAndAnswer(String offerSdp, String controllerId) {
if (peerConnection != null) {
Log.d(TAG, "Closing previous peer connection");
closeCurrentConnection();
}
this.currentControllerId = controllerId; this.currentControllerId = controllerId;
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(new ArrayList<>()); List<PeerConnection.IceServer> iceServers = new ArrayList<>();
// 建议:如果你有 TURN 服务器,请务必在此处添加,例如:
// iceServers.add(PeerConnection.IceServer.builder("turn:your_turn_server").setUsername("user").setPassword("pass").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("turn:175.178.213.60:3478").setUsername("fanhuitong").setPassword("Fan19961207..").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("turn:192.168.5.224:3478").setUsername("tt").setPassword("fht").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:175.178.213.60:3478").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:192.168.5.224:3478").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun2.l.google.com:19302").createIceServer());
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(iceServers);
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
config.iceCandidatePoolSize = 10;
// 建议启用,以便在复杂网络下有更好的表现
config.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED;
PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() { PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() {
@Override @Override
@@ -108,18 +161,18 @@ public class WebRtcClient {
} }
@Override @Override
public void onAddStream(MediaStream stream) {} public void onAddStream(MediaStream stream) {
}
@Override @Override
public void onRemoveStream(MediaStream stream) {} public void onRemoveStream(MediaStream stream) {
}
}); });
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver); peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
if (videoTrack != null) { if (videoTrack != null) {
MediaStream stream = peerConnectionFactory.createLocalMediaStream(STREAM_ID); peerConnection.addTrack(videoTrack, Collections.singletonList(STREAM_ID));
stream.addTrack(videoTrack);
peerConnection.addStream(stream);
} }
// 创建本地 DataChannel // 创建本地 DataChannel
@@ -132,7 +185,8 @@ public class WebRtcClient {
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.OFFER, offerSdp); SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.OFFER, offerSdp);
peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() { peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@Override @Override
public void onCreateSuccess(SessionDescription sdp) {} public void onCreateSuccess(SessionDescription sdp) {
}
@Override @Override
public void onSetSuccess() { public void onSetSuccess() {
@@ -143,31 +197,47 @@ public class WebRtcClient {
public void onCreateSuccess(SessionDescription sdp) { public void onCreateSuccess(SessionDescription sdp) {
peerConnection.setLocalDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() { peerConnection.setLocalDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@Override @Override
public void onCreateSuccess(SessionDescription s) {} public void onCreateSuccess(SessionDescription s) {
}
@Override @Override
public void onSetSuccess() { public void onSetSuccess() {
sendAnswer(sdp.description, controllerId); // 尝试通过 SDP Munging 提高码率和优化编码
String modifiedSdp = optimizeSdp(sdp.description);
sendAnswer(modifiedSdp, controllerId);
} }
@Override @Override
public void onCreateFailure(String error) {} public void onCreateFailure(String error) {
}
@Override @Override
public void onSetFailure(String error) {} public void onSetFailure(String error) {
}
}), sdp); }), sdp);
} }
@Override @Override
public void onSetSuccess() {} public void onSetSuccess() {
}
@Override @Override
public void onCreateFailure(String error) {} public void onCreateFailure(String error) {
}
@Override @Override
public void onSetFailure(String error) {} public void onSetFailure(String error) {
}
}), constraints); }), constraints);
} }
@Override @Override
public void onCreateFailure(String error) {} public void onCreateFailure(String error) {
}
@Override @Override
public void onSetFailure(String error) {} public void onSetFailure(String error) {
}
}), remoteSdp); }), remoteSdp);
} }
@@ -186,23 +256,35 @@ public class WebRtcClient {
} }
public void close() { public void close() {
closeCurrentConnection();
if (peerConnectionFactory != null) {
peerConnectionFactory.dispose();
peerConnectionFactory = null;
}
}
private void closeCurrentConnection() {
if (dataChannel != null) { if (dataChannel != null) {
try {
dataChannel.unregisterObserver();
} catch (Exception e) {
}
dataChannel.close(); dataChannel.close();
dataChannel.dispose(); dataChannel.dispose();
dataChannel = null;
} }
if (peerConnection != null) { if (peerConnection != null) {
peerConnection.close(); peerConnection.close();
peerConnection.dispose(); peerConnection.dispose();
} peerConnection = null;
if (peerConnectionFactory != null) {
peerConnectionFactory.dispose();
} }
} }
private void handleDataChannel(DataChannel dc) { private void handleDataChannel(DataChannel dc) {
dc.registerObserver(new DataChannel.Observer() { dc.registerObserver(new DataChannel.Observer() {
@Override @Override
public void onBufferedAmountChange(long previousAmount) {} public void onBufferedAmountChange(long previousAmount) {
}
@Override @Override
public void onStateChange() { public void onStateChange() {
@@ -222,6 +304,44 @@ public class WebRtcClient {
}); });
} }
private String optimizeSdp(String sdp) {
String[] lines = sdp.split("\r\n");
StringBuilder newSdp = new StringBuilder();
// 优先使用 H264
String h264Payload = null;
// 首先找到 H264 的 payload type
for (String line : lines) {
if (line.startsWith("a=rtpmap:") && line.contains("H264/90000")) {
h264Payload = line.split(":")[1].split(" ")[0];
break;
}
}
for (String line : lines) {
if (line.startsWith("m=video") && h264Payload != null) {
// 将 H264 payload 移到最前面以优先使用硬件加速编码
String[] parts = line.split(" ");
StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]);
mLine.append(" ").append(h264Payload);
for (int i = 3; i < parts.length; i++) {
if (!parts[i].equals(h264Payload)) {
mLine.append(" ").append(parts[i]);
}
}
newSdp.append(mLine).append("\r\n");
} else if (h264Payload != null && line.startsWith("a=fmtp:" + h264Payload)) {
// 为 H264 添加起步码率和最大码率限制 (单位: kbps)
// 提高起步码率能显著改善初始画质
newSdp.append(line).append(";x-google-start-bitrate=5000;x-google-max-bitrate=50000;x-google-min-bitrate=2000\r\n");
} else {
newSdp.append(line).append("\r\n");
}
}
return newSdp.toString();
}
private void sendAnswer(String sdp, String controllerId) { private void sendAnswer(String sdp, String controllerId) {
SignalMessage msg = new SignalMessage(); SignalMessage msg = new SignalMessage();
msg.setType("ANSWER"); msg.setType("ANSWER");

View File

@@ -8,60 +8,68 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="WebRTC 被控端" android:text="WebRTC 被控端"
android:textSize="24sp" android:textSize="24sp"
android:textStyle="bold" android:textStyle="bold" />
android:layout_marginBottom="24dp"/>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="信令服务器地址:" android:text="信令服务器地址:"
android:textSize="14sp"/> android:textSize="14sp" />
<EditText <EditText
android:id="@+id/et_server_url" android:id="@+id/et_server_url"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textUri" android:layout_marginBottom="16dp"
android:hint="ws://192.168.1.100:8080/ws/signal" android:hint="ws://192.168.100.222:8080/ws/signal"
android:layout_marginBottom="16dp"/> android:inputType="textUri" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="设备ID:" android:text="设备ID:"
android:textSize="14sp"/> android:textSize="14sp" />
<EditText <EditText
android:id="@+id/et_device_id" android:id="@+id/et_device_id"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:layout_marginBottom="24dp"
android:hint="设备ID" android:hint="设备ID"
android:layout_marginBottom="24dp"/> android:inputType="text"
android:text="981964879" />
<TextView <TextView
android:id="@+id/tv_status" android:id="@+id/tv_status"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="状态: 已停止" android:text="状态: 已停止"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" android:textStyle="bold" />
android:layout_marginBottom="24dp"/>
<Button <Button
android:id="@+id/btn_start" android:id="@+id/btn_start"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="开始屏幕共享" android:layout_marginBottom="8dp"
android:layout_marginBottom="8dp"/> android:text="开始屏幕共享" />
<Button <Button
android:id="@+id/btn_stop" android:id="@+id/btn_stop"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="停止屏幕共享" android:layout_marginBottom="16dp"
android:enabled="false"/> android:enabled="false"
android:text="停止屏幕共享" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/local_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -2,3 +2,4 @@
plugins { plugins {
id 'com.android.application' version '8.1.4' apply false id 'com.android.application' version '8.1.4' apply false
} }
apply from: "config.gradle"

View File

@@ -4,5 +4,5 @@
# Location of the SDK. This is only used by Gradle. # Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the # For customization when using a Version Control System, please read the
# header note. # header note.
#Sat Jul 11 15:36:39 CST 2026 #Mon Jul 13 11:51:13 CST 2026
sdk.dir=F\:\\AndroidSDK sdk.dir=D\:\\AndroidSdk\\Windows

View File

@@ -1,4 +1,5 @@
/build/ /build/
/app/build/ /app/build/
/.idea/ /.idea/
/.gradle/ /.gradle/
config.gradle

View File

@@ -2,6 +2,14 @@ plugins {
id 'com.android.application' id 'com.android.application'
} }
def releaseTime() {
return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
}
def appName() {
return "WebRTCControlled"
}
android { android {
namespace 'com.tt.controller' namespace 'com.tt.controller'
compileSdk 34 compileSdk 34
@@ -14,17 +22,98 @@ android {
versionName "1.0" versionName "1.0"
} }
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
buildFeatures {
dataBinding true
buildConfig true
aidl true
}
signingConfigs {
keypub {
storeFile file(rootProject.ext.signingConfigs.keypub.storeFile)
storePassword rootProject.ext.signingConfigs.keypub.storePassword
keyAlias rootProject.ext.signingConfigs.keypub.keyAlias
keyPassword rootProject.ext.signingConfigs.keypub.keyPassword
}
crosshatch {
storeFile file(rootProject.ext.signingConfigs.crosshatch.storeFile)
storePassword rootProject.ext.signingConfigs.crosshatch.storePassword
keyAlias rootProject.ext.signingConfigs.crosshatch.keyAlias
keyPassword rootProject.ext.signingConfigs.crosshatch.keyPassword
}
zhanxun {
storeFile file(rootProject.ext.signingConfigs.zhanxun.storeFile)
storePassword rootProject.ext.signingConfigs.zhanxun.storePassword
keyAlias rootProject.ext.signingConfigs.zhanxun.keyAlias
keyPassword rootProject.ext.signingConfigs.zhanxun.keyPassword
}
}
buildTypes {
zhanRuiDebug.initWith(debug)
zhanRuiDebug {
debuggable true
signingConfig signingConfigs.zhanxun
}
zhanRuiRelease.initWith(release)
zhanRuiRelease {
signingConfig signingConfigs.zhanxun
}
CrosshatchDebug.initWith(debug)
CrosshatchDebug {
debuggable true
signingConfig signingConfigs.crosshatch
}
CrosshatchRelease.initWith(release)
CrosshatchRelease {
signingConfig signingConfigs.crosshatch
}
debug {
versionNameSuffix "_debug"
debuggable true
minifyEnabled false
//Zipalign优化
zipAlignEnabled true
signingConfig signingConfigs.keypub
}
release {
minifyEnabled false
//Zipalign优化
zipAlignEnabled true
//前一部分代表系统默认的android程序的混淆文件该文件已经包含了基本的混淆声明后一个文件是自己的定义混淆文件
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//签名
signingConfig signingConfigs.keypub
}
}
// Add application variant configuration here instead
applicationVariants.all { variant ->
variant.outputs.each { output ->
def buildType = variant.buildType.name
def fileName = ""
if (buildType.contains("debug")) {
fileName = "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk"
} else {
fileName = "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk"
}
output.outputFileName = fileName
}
}
} }
dependencies { dependencies {

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:sharedUserId="android.uid.system">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -8,13 +9,14 @@
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.DayNight"> android:theme="@style/Theme.MaterialComponents.DayNight">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="landscape"> android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@@ -1,6 +1,8 @@
package com.tt.controller; package com.tt.controller;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.Button; import android.widget.Button;
@@ -21,8 +23,10 @@ import com.tt.controller.webrtc.WebRtcClient;
import org.webrtc.EglBase; import org.webrtc.EglBase;
import org.webrtc.IceCandidate; import org.webrtc.IceCandidate;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer; import org.webrtc.SurfaceViewRenderer;
import java.util.Locale;
import java.util.UUID; import java.util.UUID;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
@@ -40,6 +44,7 @@ public class MainActivity extends AppCompatActivity {
private FrameLayout controlPanel; private FrameLayout controlPanel;
private LinearLayout setupPanel; private LinearLayout setupPanel;
private TextView tvStatusControl; private TextView tvStatusControl;
private TextView tvStats;
private WebSocketClient wsClient; private WebSocketClient wsClient;
private WebRtcClient webRtcClient; private WebRtcClient webRtcClient;
@@ -48,6 +53,15 @@ public class MainActivity extends AppCompatActivity {
private String myDeviceId; private String myDeviceId;
private String targetDeviceId; private String targetDeviceId;
private final Handler statsHandler = new Handler(Looper.getMainLooper());
private final Runnable statsRunnable = new Runnable() {
@Override
public void run() {
updateStats();
statsHandler.postDelayed(this, 1000);
}
};
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -64,9 +78,10 @@ public class MainActivity extends AppCompatActivity {
controlPanel = findViewById(R.id.control_panel); controlPanel = findViewById(R.id.control_panel);
setupPanel = findViewById(R.id.setup_panel); setupPanel = findViewById(R.id.setup_panel);
tvStatusControl = findViewById(R.id.tv_status_control); tvStatusControl = findViewById(R.id.tv_status_control);
tvStats = findViewById(R.id.tv_stats);
// 默认服务器地址 // 默认服务器地址
etServerUrl.setText("ws://192.168.1.100:8080/ws/signal"); etServerUrl.setText("ws://192.168.100.222:8080/ws/signal");
// 生成设备ID // 生成设备ID
myDeviceId = "controller_" + UUID.randomUUID().toString().substring(0, 8); myDeviceId = "controller_" + UUID.randomUUID().toString().substring(0, 8);
etDeviceId.setText(myDeviceId); etDeviceId.setText(myDeviceId);
@@ -77,6 +92,8 @@ public class MainActivity extends AppCompatActivity {
// 初始化 EGL 和远端视频渲染 // 初始化 EGL 和远端视频渲染
eglBase = EglBase.create(); eglBase = EglBase.create();
remoteVideoView.init(eglBase.getEglBaseContext(), null); remoteVideoView.init(eglBase.getEglBaseContext(), null);
remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
remoteVideoView.setEnableHardwareScaler(true);
remoteVideoView.setZOrderMediaOverlay(true); remoteVideoView.setZOrderMediaOverlay(true);
// 设置触摸事件 // 设置触摸事件
@@ -255,6 +272,86 @@ public class MainActivity extends AppCompatActivity {
if (tvStatusControl != null) { if (tvStatusControl != null) {
tvStatusControl.setText(connected ? "远程控制中" : "未连接"); 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;
for (org.webrtc.RTCStats stats : report.getStatsMap().values()) {
if ("inbound-rtp".equals(stats.getType()) && "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 + ")";
}
}
if ("candidate-pair".equals(stats.getType())) {
Object nominated = stats.getMembers().get("nominated");
if (Boolean.TRUE.equals(nominated)) {
Object rtt = stats.getMembers().get("currentRoundTripTime");
if (rtt instanceof Number) {
delay = (long) (((Number) rtt).doubleValue() * 1000);
}
}
}
}
final String statsText = String.format(Locale.getDefault(),
"分辨率: %dx%d 帧率: %d 延迟: %dms\n解码格式: %s 平均解码耗时: %.1fms",
width, height, fps, delay, codecName, avgDecodeTimeMs);
runOnUiThread(() -> {
if (tvStats != null) {
tvStats.setText(statsText);
}
});
});
}
} }
@Override @Override

View File

@@ -18,6 +18,7 @@ public class PeerObserver implements PeerConnection.Observer {
void onDataChannel(DataChannel dataChannel); void onDataChannel(DataChannel dataChannel);
void onAddStream(MediaStream stream); void onAddStream(MediaStream stream);
void onRemoveStream(MediaStream stream); void onRemoveStream(MediaStream stream);
void onTrack(RtpReceiver receiver, MediaStream[] streams);
} }
public PeerObserver(PeerEventListener listener) { public PeerObserver(PeerEventListener listener) {
@@ -73,5 +74,8 @@ public class PeerObserver implements PeerConnection.Observer {
} }
@Override @Override
public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) {} public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) {
android.util.Log.d(TAG, "Track added: " + receiver.id());
if (listener != null) listener.onTrack(receiver, streams);
}
} }

View File

@@ -9,19 +9,27 @@ import com.tt.controller.signaling.SignalMessage;
import com.tt.controller.signaling.WebSocketClient; import com.tt.controller.signaling.WebSocketClient;
import org.webrtc.DataChannel; import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoDecoderFactory;
import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.EglBase; import org.webrtc.EglBase;
import org.webrtc.IceCandidate; import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints; import org.webrtc.MediaConstraints;
import org.webrtc.MediaStream; import org.webrtc.MediaStream;
import org.webrtc.MediaStreamTrack;
import org.webrtc.PeerConnection; import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory; import org.webrtc.PeerConnectionFactory;
import org.webrtc.RtpReceiver;
import org.webrtc.RtpTransceiver;
import org.webrtc.SessionDescription; import org.webrtc.SessionDescription;
import org.webrtc.SurfaceViewRenderer; import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoDecoderFactory;
import org.webrtc.VideoEncoderFactory;
import org.webrtc.VideoTrack; import org.webrtc.VideoTrack;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List;
public class WebRtcClient { public class WebRtcClient {
@@ -38,7 +46,8 @@ public class WebRtcClient {
private DataChannel dataChannel; private DataChannel dataChannel;
private EglBase eglBase; private EglBase eglBase;
private String currentControlledId; private String currentControlledId;
private VideoRenderer videoRenderer; private SurfaceViewRenderer remoteSurfaceView;
private VideoTrack remoteVideoTrack;
public interface ConnectionListener { public interface ConnectionListener {
void onConnectionEstablished(); void onConnectionEstablished();
@@ -66,16 +75,36 @@ public class WebRtcClient {
PeerConnectionFactory.initialize(initOptions); PeerConnectionFactory.initialize(initOptions);
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
// 创建视频编解码工厂,以支持硬件加速和各种格式
VideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(
eglBase.getEglBaseContext(), true, true);
VideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
peerConnectionFactory = PeerConnectionFactory.builder() peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options) .setOptions(options)
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.createPeerConnectionFactory(); .createPeerConnectionFactory();
} }
public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) { public void createOffer(String controlledId, SurfaceViewRenderer remoteRenderer) {
this.currentControlledId = controlledId; this.currentControlledId = controlledId;
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(new ArrayList<>()); List<PeerConnection.IceServer> iceServers = new ArrayList<>();
// 添加 STUN 和 TURN 服务器
// 注意:如果是 TURN 服务器,必须使用 turn: 前缀
iceServers.add(PeerConnection.IceServer.builder("turn:175.178.213.60:3478").setUsername("fanhuitong").setPassword("Fan19961207..").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("turn:192.168.5.224:3478").setUsername("tt").setPassword("fht").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:175.178.213.60:3478").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:192.168.5.224:3478").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer());
iceServers.add(PeerConnection.IceServer.builder("stun:stun2.l.google.com:19302").createIceServer());
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(iceServers);
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
config.iceCandidatePoolSize = 10;
PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() { PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() {
@Override @Override
@@ -102,30 +131,41 @@ public class WebRtcClient {
@Override @Override
public void onAddStream(MediaStream stream) { public void onAddStream(MediaStream stream) {
Log.d(TAG, "onAddStream: " + stream.getId());
// Unified Plan 下建议使用 onTrack但为了兼容性也可以处理
if (!stream.videoTracks.isEmpty()) { if (!stream.videoTracks.isEmpty()) {
VideoTrack videoTrack = stream.videoTracks.get(0); VideoTrack videoTrack = stream.videoTracks.get(0);
if (videoRenderer != null) { setupRemoteVideoTrack(videoTrack);
videoTrack.addSink(videoRenderer);
}
} }
} }
@Override @Override
public void onRemoveStream(MediaStream stream) {} public void onRemoveStream(MediaStream stream) {}
@Override
public void onTrack(RtpReceiver receiver, MediaStream[] streams) {
MediaStreamTrack track = receiver.track();
if (track instanceof VideoTrack) {
Log.d(TAG, "onTrack (video)");
setupRemoteVideoTrack((VideoTrack) track);
}
}
}); });
peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver); peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver);
// 使用 Unified Plan 方式请求视频流 (仅接收)
peerConnection.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO,
new RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY));
// 创建 DataChannel // 创建 DataChannel
DataChannel.Init dcInit = new DataChannel.Init(); DataChannel.Init dcInit = new DataChannel.Init();
dcInit.ordered = true; dcInit.ordered = true;
dataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL, dcInit); dataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL, dcInit);
handleDataChannel(dataChannel); handleDataChannel(dataChannel);
// 初始化远端视频渲染器 // 保存远端视频渲染器
if (remoteRenderer != null) { this.remoteSurfaceView = remoteRenderer;
videoRenderer = new VideoRenderer(remoteRenderer);
}
// 创建 Offer // 创建 Offer
MediaConstraints constraints = new MediaConstraints(); MediaConstraints constraints = new MediaConstraints();
@@ -193,6 +233,9 @@ public class WebRtcClient {
} }
public void close() { public void close() {
if (remoteVideoTrack != null && remoteSurfaceView != null) {
remoteVideoTrack.removeSink(remoteSurfaceView);
}
if (dataChannel != null) { if (dataChannel != null) {
dataChannel.close(); dataChannel.close();
dataChannel.dispose(); dataChannel.dispose();
@@ -206,6 +249,15 @@ public class WebRtcClient {
} }
} }
private void setupRemoteVideoTrack(VideoTrack videoTrack) {
if (remoteSurfaceView != null) {
this.remoteVideoTrack = videoTrack;
remoteVideoTrack.setEnabled(true);
remoteVideoTrack.addSink(remoteSurfaceView);
Log.d(TAG, "Remote video track attached to renderer");
}
}
private void handleDataChannel(DataChannel dc) { private void handleDataChannel(DataChannel dc) {
dc.registerObserver(new DataChannel.Observer() { dc.registerObserver(new DataChannel.Observer() {
@Override @Override
@@ -255,4 +307,10 @@ public class WebRtcClient {
wsClient.sendMessage(msg); wsClient.sendMessage(msg);
} }
public void getStats(org.webrtc.RTCStatsCollectorCallback callback) {
if (peerConnection != null) {
peerConnection.getStats(callback);
}
}
} }

View File

@@ -8,82 +8,83 @@
android:id="@+id/setup_panel" android:id="@+id/setup_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical" android:orientation="vertical"
android:padding="24dp" android:padding="24dp">
android:gravity="center_vertical">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="WebRTC 控制端" android:text="WebRTC 控制端"
android:textSize="24sp" android:textSize="24sp"
android:textStyle="bold" android:textStyle="bold" />
android:layout_marginBottom="24dp"/>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="信令服务器地址:" android:text="信令服务器地址:"
android:textSize="14sp"/> android:textSize="14sp" />
<EditText <EditText
android:id="@+id/et_server_url" android:id="@+id/et_server_url"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textUri" android:layout_marginBottom="16dp"
android:hint="ws://192.168.1.100:8080/ws/signal" android:hint="ws://192.168.100.222:8080/ws/signal"
android:layout_marginBottom="16dp"/> android:inputType="textUri" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="本机设备ID:" android:text="本机设备ID:"
android:textSize="14sp"/> android:textSize="14sp" />
<EditText <EditText
android:id="@+id/et_device_id" android:id="@+id/et_device_id"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:layout_marginBottom="16dp"
android:hint="设备ID" android:hint="设备ID"
android:layout_marginBottom="16dp"/> android:inputType="text" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="目标被控设备ID:" android:text="目标被控设备ID:"
android:textSize="14sp"/> android:textSize="14sp" />
<EditText <EditText
android:id="@+id/et_target_device_id" android:id="@+id/et_target_device_id"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:layout_marginBottom="24dp"
android:hint="被控端设备ID" android:hint="被控端设备ID"
android:layout_marginBottom="24dp"/> android:inputType="text"
android:text="981964879" />
<TextView <TextView
android:id="@+id/tv_status" android:id="@+id/tv_status"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="状态: 已停止" android:text="状态: 已停止"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" android:textStyle="bold" />
android:layout_marginBottom="24dp"/>
<Button <Button
android:id="@+id/btn_connect" android:id="@+id/btn_connect"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="连接被控设备" android:layout_marginBottom="8dp"
android:layout_marginBottom="8dp"/> android:text="连接被控设备" />
<Button <Button
android:id="@+id/btn_disconnect" android:id="@+id/btn_disconnect"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="断开连接" android:enabled="false"
android:enabled="false"/> android:text="断开连接" />
</LinearLayout> </LinearLayout>
@@ -98,26 +99,41 @@
<org.webrtc.SurfaceViewRenderer <org.webrtc.SurfaceViewRenderer
android:id="@+id/remote_video_view" android:id="@+id/remote_video_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"/> android:layout_height="match_parent" />
<!-- 触摸控制层(覆盖在视频上方) --> <!-- 触摸控制层(覆盖在视频上方) -->
<com.tt.controller.view.RemoteTouchView <com.tt.controller.view.RemoteTouchView
android:id="@+id/touch_overlay" android:id="@+id/touch_overlay"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@android:color/transparent"/> android:background="@android:color/transparent" />
<!-- 顶部状态栏 --> <!-- 顶部状态栏 -->
<TextView <LinearLayout
android:id="@+id/tv_status_control"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="top|start" android:layout_gravity="top|start"
android:padding="8dp"
android:background="#80000000" android:background="#80000000"
android:textColor="@android:color/white" android:orientation="vertical"
android:text="远程控制中" android:padding="8dp">
android:textSize="14sp"/>
<TextView
android:id="@+id/tv_status_control"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="远程控制中"
android:textColor="@android:color/white"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_stats"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="分辨率: - 帧率: - 延迟: -"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout> </FrameLayout>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -2,3 +2,4 @@
plugins { plugins {
id 'com.android.application' version '8.1.4' apply false id 'com.android.application' version '8.1.4' apply false
} }
apply from: "config.gradle"

View File

@@ -4,5 +4,5 @@
# Location of the SDK. This is only used by Gradle. # Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the # For customization when using a Version Control System, please read the
# header note. # header note.
#Sat Jul 11 15:36:50 CST 2026 #Mon Jul 13 11:51:30 CST 2026
sdk.dir=F\:\\AndroidSDK sdk.dir=D\:\\AndroidSdk\\Windows