feat: 增加分辨率修改
This commit is contained in:
1
HarmonyOSNEXTApp
Submodule
1
HarmonyOSNEXTApp
Submodule
Submodule HarmonyOSNEXTApp added at a7a4a9f94c
@@ -12,8 +12,15 @@ import android.os.Build;
|
|||||||
import android.os.IBinder;
|
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.widget.AdapterView;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.Spinner;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
@@ -56,6 +63,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
// 注册状态监听,使界面能感知信令断开/控制端断开并同步显示。
|
// 注册状态监听,使界面能感知信令断开/控制端断开并同步显示。
|
||||||
screenCaptureService.setStateListener(MainActivity.this);
|
screenCaptureService.setStateListener(MainActivity.this);
|
||||||
setupLocalPreview();
|
setupLocalPreview();
|
||||||
|
setupResolutionSpinner();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -271,6 +279,82 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
|||||||
binding.btnStop.setEnabled(running);
|
binding.btnStop.setEnabled(running);
|
||||||
binding.etServerUrl.setEnabled(!running);
|
binding.etServerUrl.setEnabled(!running);
|
||||||
binding.etDeviceId.setEnabled(!running);
|
binding.etDeviceId.setEnabled(!running);
|
||||||
|
binding.spinnerResolution.setEnabled(running && isBound);
|
||||||
binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");
|
binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分辨率预设(按长边像素描述,0/负数表示使用设备原生分辨率)。 */
|
||||||
|
private static class ResolutionPreset {
|
||||||
|
final String label;
|
||||||
|
final int longEdge;
|
||||||
|
|
||||||
|
ResolutionPreset(String label, int longEdge) {
|
||||||
|
this.label = label;
|
||||||
|
this.longEdge = longEdge;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建分辨率下拉框并在本地/远端切换时同步显示当前采集分辨率。 */
|
||||||
|
private void setupResolutionSpinner() {
|
||||||
|
if (screenCaptureService == null) return;
|
||||||
|
|
||||||
|
int nativeLong = screenCaptureService.getNativeLongEdge();
|
||||||
|
List<ResolutionPreset> presets = new ArrayList<>();
|
||||||
|
presets.add(new ResolutionPreset("原始 (" + nativeLong + ")", nativeLong));
|
||||||
|
presets.add(new ResolutionPreset("1080P", 1920));
|
||||||
|
presets.add(new ResolutionPreset("720P", 1280));
|
||||||
|
presets.add(new ResolutionPreset("480P", 854));
|
||||||
|
|
||||||
|
ArrayAdapter<ResolutionPreset> adapter = new ArrayAdapter<>(
|
||||||
|
this, android.R.layout.simple_spinner_item, presets);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
binding.spinnerResolution.setAdapter(adapter);
|
||||||
|
|
||||||
|
// 选中当前采集分辨率对应的预设
|
||||||
|
int[] cur = screenCaptureService.getCurrentCaptureResolution();
|
||||||
|
int curLong = Math.max(cur[0], cur[1]);
|
||||||
|
int selectIdx = 0;
|
||||||
|
int bestDiff = Integer.MAX_VALUE;
|
||||||
|
for (int i = 0; i < presets.size(); i++) {
|
||||||
|
int diff = Math.abs(presets.get(i).longEdge - curLong);
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
selectIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.spinnerResolution.setSelection(selectIdx);
|
||||||
|
updateCurrentResolutionText(cur[0], cur[1], cur[2]);
|
||||||
|
|
||||||
|
// 注册监听:本地或远端切换分辨率后刷新当前分辨率文本
|
||||||
|
screenCaptureService.setResolutionListener((w, h, fps, fromRemote) ->
|
||||||
|
runOnUiThread(() -> updateCurrentResolutionText(w, h, fps)));
|
||||||
|
|
||||||
|
// 注意:先 setSelection 再设置监听,避免初始化时触发一次多余的切换。
|
||||||
|
binding.spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
|
if (screenCaptureService == null) return;
|
||||||
|
ResolutionPreset preset = (ResolutionPreset) parent.getItemAtPosition(position);
|
||||||
|
int[] now = screenCaptureService.getCurrentCaptureResolution();
|
||||||
|
int nowLong = Math.max(now[0], now[1]);
|
||||||
|
// 与当前一致则不重复切换,避免无谓的提示与编码重启
|
||||||
|
if (preset.longEdge == nowLong) return;
|
||||||
|
screenCaptureService.changeResolutionByLongEdge(preset.longEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(AdapterView<?> parent) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCurrentResolutionText(int w, int h, int fps) {
|
||||||
|
binding.tvCurrentResolution.setText(
|
||||||
|
String.format("当前: %dx%d @ %dfps", w, h, fps));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ public class InputCommandHandler {
|
|||||||
private final int screenHeight;
|
private final int screenHeight;
|
||||||
private final InputExecutor inputExecutor;
|
private final InputExecutor inputExecutor;
|
||||||
|
|
||||||
|
/** 控制端请求切换屏幕采集分辨率时的回调(由 ScreenCaptureService 实现)。 */
|
||||||
|
public interface ResolutionRequestListener {
|
||||||
|
void onResolutionRequested(int width, int height, int fps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolutionRequestListener resolutionListener;
|
||||||
|
|
||||||
public InputCommandHandler(int screenWidth, int screenHeight) {
|
public InputCommandHandler(int screenWidth, int screenHeight) {
|
||||||
this(screenWidth, screenHeight, new SystemInputUtils());
|
this(screenWidth, screenHeight, new SystemInputUtils());
|
||||||
}
|
}
|
||||||
@@ -35,6 +42,10 @@ public class InputCommandHandler {
|
|||||||
this.inputExecutor = inputExecutor;
|
this.inputExecutor = inputExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setResolutionRequestListener(ResolutionRequestListener listener) {
|
||||||
|
this.resolutionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
public void handleCommand(byte[] data) {
|
public void handleCommand(byte[] data) {
|
||||||
try {
|
try {
|
||||||
ControlMessage command = ControlMessage.parseFrom(data);
|
ControlMessage command = ControlMessage.parseFrom(data);
|
||||||
@@ -56,6 +67,15 @@ public class InputCommandHandler {
|
|||||||
case MOTION_EVENT:
|
case MOTION_EVENT:
|
||||||
handleMotionEvent(command);
|
handleMotionEvent(command);
|
||||||
break;
|
break;
|
||||||
|
case SET_RESOLUTION:
|
||||||
|
// 分辨率切换不依赖输入执行器,转发给屏幕采集服务处理。
|
||||||
|
if (resolutionListener != null) {
|
||||||
|
resolutionListener.onResolutionRequested(
|
||||||
|
command.getWidth(), command.getHeight(), command.getFps());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "SET_RESOLUTION received but no listener registered");
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
Log.w(TAG, "Unknown action: " + action);
|
Log.w(TAG, "Unknown action: " + action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ public class ScreenCaptureService extends Service {
|
|||||||
private String pendingOfferSdp;
|
private String pendingOfferSdp;
|
||||||
/** 当前正在控制本设备的控制端名称(用户名或设备 ID),用于断开提示。 */
|
/** 当前正在控制本设备的控制端名称(用户名或设备 ID),用于断开提示。 */
|
||||||
private String controllingName;
|
private String controllingName;
|
||||||
|
/** 设备真实屏幕分辨率,用于分辨率切换时按宽高比计算采集尺寸。 */
|
||||||
|
private int realScreenWidth;
|
||||||
|
private int realScreenHeight;
|
||||||
|
/** 当前实际采集分辨率与帧率(运行时可被本地/远端请求切换)。 */
|
||||||
|
private int currentCaptureWidth;
|
||||||
|
private int currentCaptureHeight;
|
||||||
|
private int currentCaptureFps;
|
||||||
/** 主线程 Handler,用于从 WebRTC 回调线程切回主线程执行 UI 提示。 */
|
/** 主线程 Handler,用于从 WebRTC 回调线程切回主线程执行 UI 提示。 */
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
/** 标记服务是否正在关闭,避免重复停止/重复弹窗。 */
|
/** 标记服务是否正在关闭,避免重复停止/重复弹窗。 */
|
||||||
@@ -137,6 +144,8 @@ public class ScreenCaptureService extends Service {
|
|||||||
}
|
}
|
||||||
int realWidth = dm.widthPixels;
|
int realWidth = dm.widthPixels;
|
||||||
int realHeight = dm.heightPixels;
|
int realHeight = dm.heightPixels;
|
||||||
|
this.realScreenWidth = realWidth;
|
||||||
|
this.realScreenHeight = realHeight;
|
||||||
|
|
||||||
// 分辨率缩放逻辑:如果设备分辨率大于 1080p,则等比例缩小到 1080p 以内
|
// 分辨率缩放逻辑:如果设备分辨率大于 1080p,则等比例缩小到 1080p 以内
|
||||||
int captureWidth = realWidth;
|
int captureWidth = realWidth;
|
||||||
@@ -167,8 +176,15 @@ public class ScreenCaptureService extends Service {
|
|||||||
Log.i(TAG, "Real resolution: " + realWidth + "x" + realHeight);
|
Log.i(TAG, "Real resolution: " + realWidth + "x" + realHeight);
|
||||||
Log.i(TAG, "Capture resolution: " + captureWidth + "x" + captureHeight + " @ " + fps + "fps");
|
Log.i(TAG, "Capture resolution: " + captureWidth + "x" + captureHeight + " @ " + fps + "fps");
|
||||||
|
|
||||||
|
// 记录初始采集分辨率,供 UI 与运行时切换使用
|
||||||
|
this.currentCaptureWidth = captureWidth;
|
||||||
|
this.currentCaptureHeight = captureHeight;
|
||||||
|
this.currentCaptureFps = fps;
|
||||||
|
|
||||||
// 输入处理器必须使用真实分辨率进行坐标映射
|
// 输入处理器必须使用真实分辨率进行坐标映射
|
||||||
inputHandler = new InputCommandHandler(realWidth, realHeight, createInputExecutor());
|
inputHandler = new InputCommandHandler(realWidth, realHeight, createInputExecutor());
|
||||||
|
// 控制端发来的 SET_RESOLUTION 指令转交本服务处理(坐标映射与输入执行器无关)。
|
||||||
|
inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested);
|
||||||
|
|
||||||
// 开启屏幕捕获,使用计算出的 capture 分辨率
|
// 开启屏幕捕获,使用计算出的 capture 分辨率
|
||||||
startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps);
|
startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps);
|
||||||
@@ -216,6 +232,90 @@ public class ScreenCaptureService extends Service {
|
|||||||
return webRtcClient;
|
return webRtcClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换屏幕采集分辨率(本地 UI 调用)。
|
||||||
|
* @param longEdge 目标长边像素;<=0 表示使用设备原生分辨率(按屏幕宽高比缩放)。
|
||||||
|
*/
|
||||||
|
public void changeResolutionByLongEdge(int longEdge) {
|
||||||
|
requestResolutionChange(longEdge, 0, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前采集分辨率(宽/高/帧率)。 */
|
||||||
|
public int[] getCurrentCaptureResolution() {
|
||||||
|
return new int[]{ currentCaptureWidth, currentCaptureHeight, currentCaptureFps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回设备原生分辨率的长边(像素),供 UI 生成“原始画质”选项。 */
|
||||||
|
public int getNativeLongEdge() {
|
||||||
|
return Math.max(realScreenWidth, realScreenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控制端经 DataChannel 发来的 SET_RESOLUTION 指令入口。 */
|
||||||
|
private void onRemoteResolutionRequested(int width, int height, int fps) {
|
||||||
|
requestResolutionChange(width, height, fps, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并应用目标采集分辨率。
|
||||||
|
* 语义(与控制端约定一致):
|
||||||
|
* - width <= 0:使用设备原生分辨率。
|
||||||
|
* - height <= 0:仅按 width 指定长边,按屏幕宽高比计算另一维度。
|
||||||
|
* - 否则使用精确 width x height。
|
||||||
|
* 最终宽高会偶数对齐(部分 H264 编码器要求),并触发一次关键帧刷新远端画面。
|
||||||
|
*
|
||||||
|
* @param width 目标宽度/长边(像素)
|
||||||
|
* @param height 目标高度(像素,<=0 时按宽高比计算)
|
||||||
|
* @param fps 目标帧率(<=0 时沿用当前帧率)
|
||||||
|
* @param fromRemote 是否由远端控制端触发(仅用于 UI 提示区分)
|
||||||
|
*/
|
||||||
|
public void requestResolutionChange(int width, int height, int fps, boolean fromRemote) {
|
||||||
|
int[] size = resolveCaptureSize(width, height);
|
||||||
|
int targetW = size[0];
|
||||||
|
int targetH = size[1];
|
||||||
|
int targetFps = fps > 0 ? fps : currentCaptureFps;
|
||||||
|
if (targetFps <= 0) targetFps = 30;
|
||||||
|
|
||||||
|
if (webRtcClient == null) {
|
||||||
|
Log.w(TAG, "requestResolutionChange ignored: WebRTC not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webRtcClient.changeCaptureFormat(targetW, targetH, targetFps);
|
||||||
|
currentCaptureWidth = targetW;
|
||||||
|
currentCaptureHeight = targetH;
|
||||||
|
currentCaptureFps = targetFps;
|
||||||
|
|
||||||
|
Log.i(TAG, "Capture resolution changed -> " + targetW + "x" + targetH + " @ " + targetFps + "fps (remote=" + fromRemote + ")");
|
||||||
|
showToast(getString(R.string.resolution_changed_message, targetW, targetH));
|
||||||
|
|
||||||
|
if (resolutionListener != null) {
|
||||||
|
resolutionListener.onResolutionChanged(targetW, targetH, targetFps, fromRemote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据指令参数解析出最终采集尺寸(保留屏幕宽高比、偶数对齐)。 */
|
||||||
|
private int[] resolveCaptureSize(int width, int height) {
|
||||||
|
int w, h;
|
||||||
|
if (width <= 0) {
|
||||||
|
w = realScreenWidth;
|
||||||
|
h = realScreenHeight;
|
||||||
|
} else if (height <= 0) {
|
||||||
|
int longEdge = Math.max(realScreenWidth, realScreenHeight);
|
||||||
|
float scale = (float) width / longEdge;
|
||||||
|
w = Math.round(realScreenWidth * scale);
|
||||||
|
h = Math.round(realScreenHeight * scale);
|
||||||
|
} else {
|
||||||
|
w = width;
|
||||||
|
h = height;
|
||||||
|
}
|
||||||
|
// 偶数对齐(部分 H264 编码器要求宽高为偶数)
|
||||||
|
if (w % 2 != 0) w--;
|
||||||
|
if (h % 2 != 0) h--;
|
||||||
|
if (w <= 0) w = 2;
|
||||||
|
if (h <= 0) h = 2;
|
||||||
|
return new int[]{ w, h };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态回调。由已 bind 的 MainActivity 注册,用于把连接状态同步到界面。
|
* 状态回调。由已 bind 的 MainActivity 注册,用于把连接状态同步到界面。
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +332,19 @@ public class ScreenCaptureService extends Service {
|
|||||||
this.stateListener = listener;
|
this.stateListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分辨率变化回调。由已 bind 的 MainActivity 注册,用于在界面上同步当前采集分辨率。
|
||||||
|
*/
|
||||||
|
public interface ResolutionChangeListener {
|
||||||
|
void onResolutionChanged(int width, int height, int fps, boolean fromRemote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolutionChangeListener resolutionListener;
|
||||||
|
|
||||||
|
public void setResolutionListener(ResolutionChangeListener listener) {
|
||||||
|
this.resolutionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.util.Log;
|
|||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
|
import com.ttstd.control.Action;
|
||||||
|
import com.ttstd.control.ControlMessage;
|
||||||
import com.ttstd.controlled.signaling.SignalMessage;
|
import com.ttstd.controlled.signaling.SignalMessage;
|
||||||
import com.ttstd.controlled.signaling.WebSocketClient;
|
import com.ttstd.controlled.signaling.WebSocketClient;
|
||||||
|
|
||||||
@@ -148,6 +150,69 @@ public class WebRtcClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时切换屏幕采集分辨率(无需重建采集器/连接)。
|
||||||
|
* 通过 {@link org.webrtc.VideoCapturer#changeCaptureFormat} 平滑改变编码尺寸,
|
||||||
|
* 并触发一次关键帧,避免远端解码器出现花屏/卡顿。
|
||||||
|
*
|
||||||
|
* @param width 目标宽度(像素,需为偶数)
|
||||||
|
* @param height 目标高度(像素,需为偶数)
|
||||||
|
* @param fps 目标帧率,<=0 时沿用当前帧率
|
||||||
|
*/
|
||||||
|
public void changeCaptureFormat(int width, int height, int fps) {
|
||||||
|
if (videoCapturer == null) {
|
||||||
|
Log.w(TAG, "changeCaptureFormat ignored: capturer not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int targetFps = fps > 0 ? fps : this.videoFps;
|
||||||
|
if (targetFps <= 0) targetFps = 30;
|
||||||
|
Log.i(TAG, "Changing capture format -> " + width + "x" + height + " @ " + targetFps + "fps");
|
||||||
|
try {
|
||||||
|
videoCapturer.changeCaptureFormat(width, height, targetFps);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "changeCaptureFormat failed", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.videoWidth = width;
|
||||||
|
this.videoHeight = height;
|
||||||
|
this.videoFps = targetFps;
|
||||||
|
// 编码尺寸变化后诱发关键帧,使远端立即刷新画面。
|
||||||
|
triggerKeyFrame();
|
||||||
|
// 主动把最新采集分辨率上报给控制端,使其 UI 与实际保持一致。
|
||||||
|
sendResolutionReport(width, height, targetFps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 DataChannel 把当前实际采集分辨率上报给控制端(action=REPORT_RESOLUTION)。
|
||||||
|
* 控制端(网页/Android/Flutter)据此同步展示当前分辨率,避免 UI 与实际不一致。
|
||||||
|
*/
|
||||||
|
public void sendResolutionReport(int width, int height, int fps) {
|
||||||
|
if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) {
|
||||||
|
Log.d(TAG, "sendResolutionReport skipped: dataChannel not open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ControlMessage report = ControlMessage.newBuilder()
|
||||||
|
.setAction(Action.REPORT_RESOLUTION)
|
||||||
|
.setWidth(width)
|
||||||
|
.setHeight(height)
|
||||||
|
.setFps(fps)
|
||||||
|
.build();
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(report.toByteArray());
|
||||||
|
// binary=true:必须作为二进制发送,控制端按 protobuf 二进制解析;
|
||||||
|
// 若传 false 会被当作文本,Web 端收到的将是字符串导致解码失败。
|
||||||
|
dataChannel.send(new DataChannel.Buffer(buffer, true));
|
||||||
|
Log.i(TAG, "Reported capture resolution -> " + width + "x" + height + " @ " + fps + "fps");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "sendResolutionReport failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前采集分辨率(像素),用于 UI 展示。 */
|
||||||
|
public int[] getCurrentResolution() {
|
||||||
|
return new int[]{ videoWidth, videoHeight, videoFps };
|
||||||
|
}
|
||||||
|
|
||||||
private VideoSink localSink;
|
private VideoSink localSink;
|
||||||
|
|
||||||
public void setLocalSink(VideoSink sink) {
|
public void setLocalSink(VideoSink sink) {
|
||||||
@@ -372,7 +437,10 @@ public class WebRtcClient {
|
|||||||
@Override
|
@Override
|
||||||
public void onStateChange() {
|
public void onStateChange() {
|
||||||
Log.d(TAG, "DataChannel state: " + dc.state());
|
Log.d(TAG, "DataChannel state: " + dc.state());
|
||||||
if (dc.state() == DataChannel.State.CLOSED) {
|
if (dc.state() == DataChannel.State.OPEN) {
|
||||||
|
// 通道建立后,立即把当前实际采集分辨率上报给控制端(初始值或最近一次切换值)。
|
||||||
|
sendResolutionReport(videoWidth, videoHeight, videoFps);
|
||||||
|
} else if (dc.state() == DataChannel.State.CLOSED) {
|
||||||
notifyRemoteDisconnected();
|
notifyRemoteDisconnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ enum Action {
|
|||||||
KEY = 3;
|
KEY = 3;
|
||||||
LONG_PRESS = 4;
|
LONG_PRESS = 4;
|
||||||
MOTION_EVENT = 5;
|
MOTION_EVENT = 5;
|
||||||
|
SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率
|
||||||
|
REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||||
@@ -38,4 +40,12 @@ message ControlMessage {
|
|||||||
|
|
||||||
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||||
int32 motion_action = 11;
|
int32 motion_action = 11;
|
||||||
|
|
||||||
|
// 分辨率切换(SET_RESOLUTION):
|
||||||
|
// width 为目标长边/宽度;<=0 表示使用被控端原始(native)分辨率。
|
||||||
|
// height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。
|
||||||
|
// fps 为目标帧率;<=0 表示沿用当前帧率。
|
||||||
|
int32 width = 12;
|
||||||
|
int32 height = 13;
|
||||||
|
int32 fps = 14;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,30 @@
|
|||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
android:text="停止屏幕共享" />
|
android:text="停止屏幕共享" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_resolution_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="@string/resolution_label"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_resolution"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:enabled="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_current_resolution"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="当前: -"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
<org.webrtc.SurfaceViewRenderer
|
<org.webrtc.SurfaceViewRenderer
|
||||||
android:id="@+id/local_view"
|
android:id="@+id/local_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -13,4 +13,6 @@
|
|||||||
<string name="accessibility_dialog_message">当前应用未使用系统签名,也未获取 Root 权限。如需在远程控制时模拟键盘与触摸输入,请前往系统设置开启本应用的无障碍服务。</string>
|
<string name="accessibility_dialog_message">当前应用未使用系统签名,也未获取 Root 权限。如需在远程控制时模拟键盘与触摸输入,请前往系统设置开启本应用的无障碍服务。</string>
|
||||||
<string name="accessibility_dialog_go">去开启</string>
|
<string name="accessibility_dialog_go">去开启</string>
|
||||||
<string name="accessibility_dialog_cancel">取消</string>
|
<string name="accessibility_dialog_cancel">取消</string>
|
||||||
|
<string name="resolution_label">采集分辨率</string>
|
||||||
|
<string name="resolution_changed_message">采集分辨率已切换为 %1$d×%2$d</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import android.widget.FrameLayout;
|
|||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.AdapterView;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.Spinner;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
@@ -47,6 +54,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private LinearLayout setupPanel;
|
private LinearLayout setupPanel;
|
||||||
private TextView tvStatusControl;
|
private TextView tvStatusControl;
|
||||||
private TextView tvStats;
|
private TextView tvStats;
|
||||||
|
private Spinner spinnerResolution;
|
||||||
|
|
||||||
private WebSocketClient wsClient;
|
private WebSocketClient wsClient;
|
||||||
private WebRtcClient webRtcClient;
|
private WebRtcClient webRtcClient;
|
||||||
@@ -89,6 +97,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
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);
|
tvStats = findViewById(R.id.tv_stats);
|
||||||
|
spinnerResolution = findViewById(R.id.spinner_resolution);
|
||||||
|
|
||||||
// 默认服务器地址
|
// 默认服务器地址
|
||||||
etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
|
etServerUrl.setText("ws://175.178.213.60:8088/ws/signal");
|
||||||
@@ -147,6 +156,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setupResolutionSpinner();
|
||||||
updateUI(false);
|
updateUI(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,11 +468,64 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
tvStatus.setText("状态: 已停止");
|
tvStatus.setText("状态: 已停止");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分辨率预设(发送给被控端的 SET_RESOLUTION 参数)。 */
|
||||||
|
private static class ResolutionPreset {
|
||||||
|
final String label;
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
final int fps;
|
||||||
|
|
||||||
|
ResolutionPreset(String label, int width, int height, int fps) {
|
||||||
|
this.label = label;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.fps = fps;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建分辨率下拉框,选择后请求被控端切换采集分辨率。 */
|
||||||
|
private void setupResolutionSpinner() {
|
||||||
|
List<ResolutionPreset> presets = new ArrayList<>();
|
||||||
|
presets.add(new ResolutionPreset("原始画质", 0, 0, 0)); // 被控端原生分辨率
|
||||||
|
presets.add(new ResolutionPreset("1080P", 1920, 0, 0));
|
||||||
|
presets.add(new ResolutionPreset("720P", 1280, 0, 0));
|
||||||
|
presets.add(new ResolutionPreset("480P", 854, 0, 0));
|
||||||
|
|
||||||
|
ArrayAdapter<ResolutionPreset> adapter = new ArrayAdapter<>(
|
||||||
|
this, android.R.layout.simple_spinner_item, presets);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
spinnerResolution.setAdapter(adapter);
|
||||||
|
spinnerResolution.setSelection(0);
|
||||||
|
|
||||||
|
// 先选中默认项再设置监听,避免初始化即触发一次切换。
|
||||||
|
spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
|
ResolutionPreset preset = (ResolutionPreset) parent.getItemAtPosition(position);
|
||||||
|
if (webRtcClient != null) {
|
||||||
|
webRtcClient.requestResolutionChange(preset.width, preset.height, preset.fps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(AdapterView<?> parent) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void updateUI(boolean connected) {
|
private void updateUI(boolean connected) {
|
||||||
setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
|
setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE);
|
||||||
controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
|
controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE);
|
||||||
btnConnect.setEnabled(!connected);
|
btnConnect.setEnabled(!connected);
|
||||||
btnDisconnect.setEnabled(connected);
|
btnDisconnect.setEnabled(connected);
|
||||||
|
if (spinnerResolution != null) {
|
||||||
|
spinnerResolution.setEnabled(connected);
|
||||||
|
}
|
||||||
if (tvStatusControl != null) {
|
if (tvStatusControl != null) {
|
||||||
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
|
tvStatusControl.setText(connected ? "远程控制中" : "未连接");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
|||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
|
import com.ttstd.control.Action;
|
||||||
import com.ttstd.controller.signaling.SignalMessage;
|
import com.ttstd.controller.signaling.SignalMessage;
|
||||||
import com.ttstd.controller.signaling.WebSocketClient;
|
import com.ttstd.controller.signaling.WebSocketClient;
|
||||||
import com.ttstd.control.ControlMessage;
|
import com.ttstd.control.ControlMessage;
|
||||||
@@ -239,6 +240,23 @@ public class WebRtcClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求被控端切换屏幕采集分辨率(通过 DataChannel 发送 SET_RESOLUTION 指令)。
|
||||||
|
* 语义:width <= 0 表示被控端原生分辨率;height <= 0 时按 width 指定长边依屏幕宽高比缩放;
|
||||||
|
* fps <= 0 表示沿用当前帧率。
|
||||||
|
*/
|
||||||
|
public void requestResolutionChange(int width, int height, int fps) {
|
||||||
|
if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) {
|
||||||
|
ControlMessage msg = ControlMessage.newBuilder()
|
||||||
|
.setAction(Action.SET_RESOLUTION)
|
||||||
|
.setWidth(width)
|
||||||
|
.setHeight(height)
|
||||||
|
.setFps(fps)
|
||||||
|
.build();
|
||||||
|
sendControlCommand(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void close() {
|
public void close() {
|
||||||
if (remoteVideoTrack != null && remoteSurfaceView != null) {
|
if (remoteVideoTrack != null && remoteSurfaceView != null) {
|
||||||
remoteVideoTrack.removeSink(remoteSurfaceView);
|
remoteVideoTrack.removeSink(remoteSurfaceView);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ enum Action {
|
|||||||
KEY = 3;
|
KEY = 3;
|
||||||
LONG_PRESS = 4;
|
LONG_PRESS = 4;
|
||||||
MOTION_EVENT = 5;
|
MOTION_EVENT = 5;
|
||||||
|
SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率
|
||||||
|
REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||||
@@ -38,4 +40,12 @@ message ControlMessage {
|
|||||||
|
|
||||||
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||||
int32 motion_action = 11;
|
int32 motion_action = 11;
|
||||||
|
|
||||||
|
// 分辨率切换(SET_RESOLUTION):
|
||||||
|
// width 为目标长边/宽度;<=0 表示使用被控端原始(native)分辨率。
|
||||||
|
// height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。
|
||||||
|
// fps 为目标帧率;<=0 表示沿用当前帧率。
|
||||||
|
int32 width = 12;
|
||||||
|
int32 height = 13;
|
||||||
|
int32 fps = 14;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,13 @@
|
|||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_resolution"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:backgroundTint="#80FFFFFF" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ enum Action {
|
|||||||
KEY = 3;
|
KEY = 3;
|
||||||
LONG_PRESS = 4;
|
LONG_PRESS = 4;
|
||||||
MOTION_EVENT = 5;
|
MOTION_EVENT = 5;
|
||||||
|
SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率
|
||||||
|
REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||||
@@ -38,4 +40,12 @@ message ControlMessage {
|
|||||||
|
|
||||||
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||||
int32 motion_action = 11;
|
int32 motion_action = 11;
|
||||||
|
|
||||||
|
// 分辨率切换(SET_RESOLUTION):
|
||||||
|
// width 为目标长边/宽度;<=0 表示使用被控端原始(native)分辨率。
|
||||||
|
// height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。
|
||||||
|
// fps 为目标帧率;<=0 表示沿用当前帧率。
|
||||||
|
int32 width = 12;
|
||||||
|
int32 height = 13;
|
||||||
|
int32 fps = 14;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { store, sendKey } from '../store/controllerStore';
|
import { ref, watch } from 'vue';
|
||||||
|
import { store, sendKey, sendResolutionChange } from '../store/controllerStore';
|
||||||
|
|
||||||
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
|
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
|
||||||
const keys = [
|
const keys = [
|
||||||
@@ -12,14 +13,60 @@ const keys = [
|
|||||||
{ label: '电源', code: 26 }, // KEYCODE_POWER
|
{ label: '电源', code: 26 }, // KEYCODE_POWER
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 分辨率预设:width 为长边像素;0 表示被控端原生分辨率;
|
||||||
|
// height 传 0(由被控端按屏幕宽高比自动计算),fps 传 0(沿用当前帧率)。
|
||||||
|
const resolutionPresets = [
|
||||||
|
{ label: '原始画质', width: 0, height: 0, fps: 0 },
|
||||||
|
{ label: '1080P', width: 1920, height: 0, fps: 0 },
|
||||||
|
{ label: '720P', width: 1280, height: 0, fps: 0 },
|
||||||
|
{ label: '480P', width: 854, height: 0, fps: 0 },
|
||||||
|
];
|
||||||
|
// 注意:<option :value="p.width"> 的 value 是数字,Vue 的 v-model 会按数字写入,
|
||||||
|
// 因此这里也必须用数字(不能用字符串 '0'),否则默认选中项与比较逻辑会类型不一致。
|
||||||
|
const selectedResolution = ref(0);
|
||||||
|
|
||||||
function press(code) {
|
function press(code) {
|
||||||
if (!store.dataChannelOpen) return;
|
if (!store.dataChannelOpen) return;
|
||||||
sendKey(code);
|
sendKey(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onResolutionChange() {
|
||||||
|
if (!store.dataChannelOpen) return;
|
||||||
|
// 统一按数值匹配:v-model 写入的是数字,不能用 String() 再与数字比较。
|
||||||
|
const preset = resolutionPresets.find((p) => p.width === Number(selectedResolution.value));
|
||||||
|
if (preset) {
|
||||||
|
sendResolutionChange(preset.width, preset.height, preset.fps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被控端上报当前实际采集分辨率时,把下拉框同步到对应预设(避免 UI 与实际不一致)。
|
||||||
|
watch(() => store.currentResolution, (res) => {
|
||||||
|
if (!res) return;
|
||||||
|
const longEdge = Math.max(res.width, res.height);
|
||||||
|
const preset = resolutionPresets.find((p) => p.width === longEdge);
|
||||||
|
if (preset) selectedResolution.value = preset.width;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="control-bar">
|
<div class="control-bar">
|
||||||
|
<div class="resolution-row">
|
||||||
|
<label class="resolution-label">分辨率:</label>
|
||||||
|
<select
|
||||||
|
class="resolution-select"
|
||||||
|
v-model="selectedResolution"
|
||||||
|
:disabled="!store.dataChannelOpen"
|
||||||
|
@change="onResolutionChange"
|
||||||
|
>
|
||||||
|
<option v-for="p in resolutionPresets" :key="p.label" :value="p.width">
|
||||||
|
{{ p.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span class="resolution-current" v-if="store.currentResolution">
|
||||||
|
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="k in keys"
|
v-for="k in keys"
|
||||||
:key="k.code"
|
:key="k.code"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const Action = {
|
|||||||
KEY: 3,
|
KEY: 3,
|
||||||
LONG_PRESS: 4,
|
LONG_PRESS: 4,
|
||||||
MOTION_EVENT: 5,
|
MOTION_EVENT: 5,
|
||||||
|
SET_RESOLUTION: 6,
|
||||||
|
REPORT_RESOLUTION: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadProto() {
|
export async function loadProto() {
|
||||||
@@ -29,3 +31,9 @@ export function encodeControlMessage(fields) {
|
|||||||
// 返回 Uint8Array,可直接通过 RTCDataChannel.send 发送(二进制)。
|
// 返回 Uint8Array,可直接通过 RTCDataChannel.send 发送(二进制)。
|
||||||
return ControlMessage.encode(message).finish();
|
return ControlMessage.encode(message).finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析被控端经 DataChannel 发来的二进制 protobuf(如分辨率上报)。返回消息对象。 */
|
||||||
|
export function decodeControlMessage(bytes) {
|
||||||
|
if (!ControlMessage) throw new Error('protobuf 尚未加载,请先调用 loadProto()');
|
||||||
|
return ControlMessage.decode(bytes);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { encodeControlMessage } from '../proto/controlMessage';
|
import { encodeControlMessage, decodeControlMessage, Action } from '../proto/controlMessage';
|
||||||
|
|
||||||
// 对应 Android 端 WebRtcClient / Flutter webrtc_controller.dart。
|
// 对应 Android 端 WebRtcClient / Flutter webrtc_controller.dart。
|
||||||
// 作为 OFFER 方:仅接收远端视频(recvonly)+ 创建控制用 DataChannel,
|
// 作为 OFFER 方:仅接收远端视频(recvonly)+ 创建控制用 DataChannel,
|
||||||
@@ -6,7 +6,7 @@ import { encodeControlMessage } from '../proto/controlMessage';
|
|||||||
const DATA_CHANNEL_LABEL = 'control_channel';
|
const DATA_CHANNEL_LABEL = 'control_channel';
|
||||||
|
|
||||||
export class WebRtcController {
|
export class WebRtcController {
|
||||||
constructor({ iceServers, deviceId, targetDeviceId, signaling, onIceState, onDataChannelState, onStream, onStats, onError }) {
|
constructor({ iceServers, deviceId, targetDeviceId, signaling, onIceState, onDataChannelState, onStream, onStats, onError, onResolutionReport }) {
|
||||||
this.iceServers = iceServers;
|
this.iceServers = iceServers;
|
||||||
this.deviceId = deviceId;
|
this.deviceId = deviceId;
|
||||||
this.targetDeviceId = targetDeviceId;
|
this.targetDeviceId = targetDeviceId;
|
||||||
@@ -16,6 +16,7 @@ export class WebRtcController {
|
|||||||
this.onStream = onStream;
|
this.onStream = onStream;
|
||||||
this.onStats = onStats;
|
this.onStats = onStats;
|
||||||
this.onError = onError;
|
this.onError = onError;
|
||||||
|
this.onResolutionReport = onResolutionReport;
|
||||||
|
|
||||||
this.pc = null;
|
this.pc = null;
|
||||||
this.dataChannel = null;
|
this.dataChannel = null;
|
||||||
@@ -96,7 +97,22 @@ export class WebRtcController {
|
|||||||
this.dataChannel = dc;
|
this.dataChannel = dc;
|
||||||
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
|
dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
|
||||||
dc.onclose = () => this.onDataChannelState && this.onDataChannelState(false);
|
dc.onclose = () => this.onDataChannelState && this.onDataChannelState(false);
|
||||||
dc.onmessage = (e) => console.debug('[DataChannel] 收到消息', e.data);
|
dc.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
// 被控端发来的是二进制 protobuf(目前仅 REPORT_RESOLUTION 上报)。
|
||||||
|
const bytes = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data;
|
||||||
|
const msg = decodeControlMessage(bytes);
|
||||||
|
if (msg && msg.action === Action.REPORT_RESOLUTION) {
|
||||||
|
this.onResolutionReport && this.onResolutionReport({
|
||||||
|
width: msg.width | 0,
|
||||||
|
height: msg.height | 0,
|
||||||
|
fps: msg.fps | 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('[DataChannel] 解码消息失败(非分辨率上报或格式异常)', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAnswer(sdp) {
|
async handleAnswer(sdp) {
|
||||||
@@ -147,6 +163,7 @@ export class WebRtcController {
|
|||||||
sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY
|
sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY
|
||||||
sendLongPress(x, y) { return this.sendControlMessage({ action: 4, x, y }); } // LONG_PRESS
|
sendLongPress(x, y) { return this.sendControlMessage({ action: 4, x, y }); } // LONG_PRESS
|
||||||
sendMotionEvent(action, x, y) { return this.sendControlMessage({ action: 5, motionAction: action, x, y }); } // MOTION_EVENT
|
sendMotionEvent(action, x, y) { return this.sendControlMessage({ action: 5, motionAction: action, x, y }); } // MOTION_EVENT
|
||||||
|
sendResolutionChange(width, height, fps) { return this.sendControlMessage({ action: 6, width, height, fps }); } // SET_RESOLUTION
|
||||||
|
|
||||||
_startStats() {
|
_startStats() {
|
||||||
this._statsTimer = setInterval(async () => {
|
this._statsTimer = setInterval(async () => {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const store = reactive({
|
|||||||
controlledDevices: [],
|
controlledDevices: [],
|
||||||
stats: null,
|
stats: null,
|
||||||
remoteStream: null,
|
remoteStream: null,
|
||||||
|
// 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。
|
||||||
|
currentResolution: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let signaling = null;
|
let signaling = null;
|
||||||
@@ -142,6 +144,8 @@ export async function connectToDevice(targetId) {
|
|||||||
onStream: (stream) => { store.remoteStream = markRaw(stream); },
|
onStream: (stream) => { store.remoteStream = markRaw(stream); },
|
||||||
onStats: (stats) => { store.stats = stats; },
|
onStats: (stats) => { store.stats = stats; },
|
||||||
onError: (msg) => { if (msg) store.error = msg; },
|
onError: (msg) => { if (msg) store.error = msg; },
|
||||||
|
// 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。
|
||||||
|
onResolutionReport: (res) => { store.currentResolution = res; },
|
||||||
});
|
});
|
||||||
await webrtc.createOffer();
|
await webrtc.createOffer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -160,6 +164,7 @@ export async function disconnectDevice() {
|
|||||||
store.dataChannelOpen = false;
|
store.dataChannelOpen = false;
|
||||||
store.remoteStream = null;
|
store.remoteStream = null;
|
||||||
store.stats = null;
|
store.stats = null;
|
||||||
|
store.currentResolution = null;
|
||||||
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
|
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,3 +182,5 @@ export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.se
|
|||||||
export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
|
export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
|
||||||
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
|
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
|
||||||
export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }
|
export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }
|
||||||
|
// 请求被控端切换屏幕采集分辨率(width<=0 表示原生分辨率;height<=0 时按 width 长边依宽高比缩放)
|
||||||
|
export function sendResolutionChange(width, height, fps) { return webrtc && webrtc.sendResolutionChange(width, height, fps); }
|
||||||
|
|||||||
@@ -161,6 +161,26 @@ body {
|
|||||||
.key-btn:hover { border-color: var(--accent); }
|
.key-btn:hover { border-color: var(--accent); }
|
||||||
.key-btn:active { background: var(--accent); }
|
.key-btn:active { background: var(--accent); }
|
||||||
|
|
||||||
|
.resolution-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.resolution-label { font-size: 13px; color: var(--text); font-weight: 600; }
|
||||||
|
.resolution-select {
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.resolution-select:focus { border-color: var(--accent); }
|
||||||
|
.resolution-select:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.resolution-current { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
.footer { background: var(--panel); border-top: 1px solid var(--border); }
|
.footer { background: var(--panel); border-top: 1px solid var(--border); }
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ class RemoteController {
|
|||||||
_webRtc?.sendControlCommand(command);
|
_webRtc?.sendControlCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 请求被控端切换屏幕采集分辨率。
|
||||||
|
void sendResolutionChange(int width, int height, int fps) {
|
||||||
|
_webRtc?.sendResolutionChange(width, height, fps);
|
||||||
|
}
|
||||||
|
|
||||||
/// 断开连接并释放资源。
|
/// 断开连接并释放资源。
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
String _stats = '';
|
String _stats = '';
|
||||||
double _videoAspect = 16 / 9;
|
double _videoAspect = 16 / 9;
|
||||||
|
|
||||||
|
/// 分辨率预设:width 为长边像素;0 表示被控端原生分辨率;
|
||||||
|
/// height 传 0(由被控端按屏幕宽高比计算),fps 传 0(沿用当前帧率)。
|
||||||
|
int _selectedResolution = 0;
|
||||||
|
final List<Map<String, Object>> _resolutionOptions = const [
|
||||||
|
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
|
||||||
|
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
|
||||||
|
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
|
||||||
|
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -320,6 +330,29 @@ class _ControllerHomeState extends State<ControllerHome> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
child: CupertinoSegmentedControl<int>(
|
||||||
|
groupValue: _selectedResolution,
|
||||||
|
children: <int, Widget>{
|
||||||
|
for (int i = 0; i < _resolutionOptions.length; i++)
|
||||||
|
i: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
child: Text(_resolutionOptions[i]['label'] as String),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
onValueChanged: (int idx) {
|
||||||
|
setState(() => _selectedResolution = idx);
|
||||||
|
final o = _resolutionOptions[idx];
|
||||||
|
_controller?.sendResolutionChange(
|
||||||
|
o['width'] as int,
|
||||||
|
o['height'] as int,
|
||||||
|
o['fps'] as int,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class ControlMessage extends $pb.GeneratedMessage {
|
|||||||
$core.int? keyCode,
|
$core.int? keyCode,
|
||||||
$core.int? keyAction,
|
$core.int? keyAction,
|
||||||
$core.int? motionAction,
|
$core.int? motionAction,
|
||||||
|
$core.int? width,
|
||||||
|
$core.int? height,
|
||||||
|
$core.int? fps,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (action != null) result.action = action;
|
if (action != null) result.action = action;
|
||||||
@@ -49,6 +52,9 @@ class ControlMessage extends $pb.GeneratedMessage {
|
|||||||
if (keyCode != null) result.keyCode = keyCode;
|
if (keyCode != null) result.keyCode = keyCode;
|
||||||
if (keyAction != null) result.keyAction = keyAction;
|
if (keyAction != null) result.keyAction = keyAction;
|
||||||
if (motionAction != null) result.motionAction = motionAction;
|
if (motionAction != null) result.motionAction = motionAction;
|
||||||
|
if (width != null) result.width = width;
|
||||||
|
if (height != null) result.height = height;
|
||||||
|
if (fps != null) result.fps = fps;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +83,9 @@ class ControlMessage extends $pb.GeneratedMessage {
|
|||||||
..aI(9, _omitFieldNames ? '' : 'keyCode')
|
..aI(9, _omitFieldNames ? '' : 'keyCode')
|
||||||
..aI(10, _omitFieldNames ? '' : 'keyAction')
|
..aI(10, _omitFieldNames ? '' : 'keyAction')
|
||||||
..aI(11, _omitFieldNames ? '' : 'motionAction')
|
..aI(11, _omitFieldNames ? '' : 'motionAction')
|
||||||
|
..aI(12, _omitFieldNames ? '' : 'width')
|
||||||
|
..aI(13, _omitFieldNames ? '' : 'height')
|
||||||
|
..aI(14, _omitFieldNames ? '' : 'fps')
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -201,6 +210,36 @@ class ControlMessage extends $pb.GeneratedMessage {
|
|||||||
$core.bool hasMotionAction() => $_has(10);
|
$core.bool hasMotionAction() => $_has(10);
|
||||||
@$pb.TagNumber(11)
|
@$pb.TagNumber(11)
|
||||||
void clearMotionAction() => $_clearField(11);
|
void clearMotionAction() => $_clearField(11);
|
||||||
|
|
||||||
|
/// 分辨率切换(SET_RESOLUTION):目标宽度/长边(<=0 表示原生分辨率)
|
||||||
|
@$pb.TagNumber(12)
|
||||||
|
$core.int get width => $_getIZ(11);
|
||||||
|
@$pb.TagNumber(12)
|
||||||
|
set width($core.int value) => $_setSignedInt32(11, value);
|
||||||
|
@$pb.TagNumber(12)
|
||||||
|
$core.bool hasWidth() => $_has(11);
|
||||||
|
@$pb.TagNumber(12)
|
||||||
|
void clearWidth() => $_clearField(12);
|
||||||
|
|
||||||
|
/// 分辨率切换(SET_RESOLUTION):目标高度(<=0 时按屏幕宽高比基于 width 计算)
|
||||||
|
@$pb.TagNumber(13)
|
||||||
|
$core.int get height => $_getIZ(12);
|
||||||
|
@$pb.TagNumber(13)
|
||||||
|
set height($core.int value) => $_setSignedInt32(12, value);
|
||||||
|
@$pb.TagNumber(13)
|
||||||
|
$core.bool hasHeight() => $_has(12);
|
||||||
|
@$pb.TagNumber(13)
|
||||||
|
void clearHeight() => $_clearField(13);
|
||||||
|
|
||||||
|
/// 分辨率切换(SET_RESOLUTION):目标帧率(<=0 表示沿用当前帧率)
|
||||||
|
@$pb.TagNumber(14)
|
||||||
|
$core.int get fps => $_getIZ(13);
|
||||||
|
@$pb.TagNumber(14)
|
||||||
|
set fps($core.int value) => $_setSignedInt32(13, value);
|
||||||
|
@$pb.TagNumber(14)
|
||||||
|
$core.bool hasFps() => $_has(13);
|
||||||
|
@$pb.TagNumber(14)
|
||||||
|
void clearFps() => $_clearField(14);
|
||||||
}
|
}
|
||||||
|
|
||||||
const $core.bool _omitFieldNames =
|
const $core.bool _omitFieldNames =
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class Action extends $pb.ProtobufEnum {
|
|||||||
Action._(4, _omitEnumNames ? '' : 'LONG_PRESS');
|
Action._(4, _omitEnumNames ? '' : 'LONG_PRESS');
|
||||||
static const Action MOTION_EVENT =
|
static const Action MOTION_EVENT =
|
||||||
Action._(5, _omitEnumNames ? '' : 'MOTION_EVENT');
|
Action._(5, _omitEnumNames ? '' : 'MOTION_EVENT');
|
||||||
|
static const Action SET_RESOLUTION =
|
||||||
|
Action._(6, _omitEnumNames ? '' : 'SET_RESOLUTION');
|
||||||
|
static const Action REPORT_RESOLUTION =
|
||||||
|
Action._(7, _omitEnumNames ? '' : 'REPORT_RESOLUTION');
|
||||||
|
|
||||||
static const $core.List<Action> values = <Action>[
|
static const $core.List<Action> values = <Action>[
|
||||||
ACTION_UNKNOWN,
|
ACTION_UNKNOWN,
|
||||||
@@ -33,10 +37,12 @@ class Action extends $pb.ProtobufEnum {
|
|||||||
KEY,
|
KEY,
|
||||||
LONG_PRESS,
|
LONG_PRESS,
|
||||||
MOTION_EVENT,
|
MOTION_EVENT,
|
||||||
|
SET_RESOLUTION,
|
||||||
|
REPORT_RESOLUTION,
|
||||||
];
|
];
|
||||||
|
|
||||||
static final $core.List<Action?> _byValue =
|
static final $core.List<Action?> _byValue =
|
||||||
$pb.ProtobufEnum.$_initByValueList(values, 5);
|
$pb.ProtobufEnum.$_initByValueList(values, 7);
|
||||||
static Action? valueOf($core.int value) =>
|
static Action? valueOf($core.int value) =>
|
||||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,16 @@ class ControlCommands {
|
|||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 分辨率切换指令。
|
||||||
|
/// [width] 目标长边/宽度(<=0 表示被控端原生分辨率);
|
||||||
|
/// [height] 目标高度(<=0 时由被控端按屏幕宽高比计算);
|
||||||
|
/// [fps] 目标帧率(<=0 表示沿用当前帧率)。
|
||||||
|
static ControlMessage setResolution(int width, int height, int fps) =>
|
||||||
|
ControlMessage(
|
||||||
|
action: Action.SET_RESOLUTION,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fps: fps,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../config/ice_servers.dart';
|
|||||||
import '../models/signal_message.dart';
|
import '../models/signal_message.dart';
|
||||||
import '../proto/control_message.pb.dart';
|
import '../proto/control_message.pb.dart';
|
||||||
import '../signaling/signaling_client.dart';
|
import '../signaling/signaling_client.dart';
|
||||||
|
import '../utils/control_commands.dart';
|
||||||
|
|
||||||
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
||||||
///
|
///
|
||||||
@@ -192,6 +193,11 @@ class WebRtcController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 请求被控端切换屏幕采集分辨率(SET_RESOLUTION 指令)。
|
||||||
|
void sendResolutionChange(int width, int height, int fps) {
|
||||||
|
sendControlCommand(ControlCommands.setResolution(width, height, fps));
|
||||||
|
}
|
||||||
|
|
||||||
/// 上一次统计采样的字节数与时间戳,用于计算每秒网速。
|
/// 上一次统计采样的字节数与时间戳,用于计算每秒网速。
|
||||||
int _prevBytesReceived = 0;
|
int _prevBytesReceived = 0;
|
||||||
int _prevBytesSent = 0;
|
int _prevBytesSent = 0;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ enum Action {
|
|||||||
KEY = 3;
|
KEY = 3;
|
||||||
LONG_PRESS = 4;
|
LONG_PRESS = 4;
|
||||||
MOTION_EVENT = 5;
|
MOTION_EVENT = 5;
|
||||||
|
SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率
|
||||||
|
REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
|
||||||
@@ -38,4 +40,12 @@ message ControlMessage {
|
|||||||
|
|
||||||
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
// 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
|
||||||
int32 motion_action = 11;
|
int32 motion_action = 11;
|
||||||
|
|
||||||
|
// 分辨率切换(SET_RESOLUTION):
|
||||||
|
// width 为目标长边/宽度;<=0 表示使用被控端原始(native)分辨率。
|
||||||
|
// height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。
|
||||||
|
// fps 为目标帧率;<=0 表示沿用当前帧率。
|
||||||
|
int32 width = 12;
|
||||||
|
int32 height = 13;
|
||||||
|
int32 fps = 14;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user