feat: 增加分辨率修改

This commit is contained in:
2026-07-21 10:45:06 +08:00
parent 9e5ac91e46
commit de88776fb1
25 changed files with 646 additions and 6 deletions

View File

@@ -12,8 +12,15 @@ import android.os.Build;
import android.os.IBinder;
import android.provider.Settings;
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 java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
@@ -56,6 +63,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
// 注册状态监听,使界面能感知信令断开/控制端断开并同步显示。
screenCaptureService.setStateListener(MainActivity.this);
setupLocalPreview();
setupResolutionSpinner();
}
@Override
@@ -271,6 +279,82 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.btnStop.setEnabled(running);
binding.etServerUrl.setEnabled(!running);
binding.etDeviceId.setEnabled(!running);
binding.spinnerResolution.setEnabled(running && isBound);
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));
}
}

View File

@@ -18,6 +18,13 @@ public class InputCommandHandler {
private final int screenHeight;
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) {
this(screenWidth, screenHeight, new SystemInputUtils());
}
@@ -35,6 +42,10 @@ public class InputCommandHandler {
this.inputExecutor = inputExecutor;
}
public void setResolutionRequestListener(ResolutionRequestListener listener) {
this.resolutionListener = listener;
}
public void handleCommand(byte[] data) {
try {
ControlMessage command = ControlMessage.parseFrom(data);
@@ -56,6 +67,15 @@ public class InputCommandHandler {
case MOTION_EVENT:
handleMotionEvent(command);
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:
Log.w(TAG, "Unknown action: " + action);
}

View File

@@ -83,6 +83,13 @@ public class ScreenCaptureService extends Service {
private String pendingOfferSdp;
/** 当前正在控制本设备的控制端名称(用户名或设备 ID用于断开提示。 */
private String controllingName;
/** 设备真实屏幕分辨率,用于分辨率切换时按宽高比计算采集尺寸。 */
private int realScreenWidth;
private int realScreenHeight;
/** 当前实际采集分辨率与帧率(运行时可被本地/远端请求切换)。 */
private int currentCaptureWidth;
private int currentCaptureHeight;
private int currentCaptureFps;
/** 主线程 Handler用于从 WebRTC 回调线程切回主线程执行 UI 提示。 */
private final Handler mainHandler = new Handler(Looper.getMainLooper());
/** 标记服务是否正在关闭,避免重复停止/重复弹窗。 */
@@ -137,6 +144,8 @@ public class ScreenCaptureService extends Service {
}
int realWidth = dm.widthPixels;
int realHeight = dm.heightPixels;
this.realScreenWidth = realWidth;
this.realScreenHeight = realHeight;
// 分辨率缩放逻辑:如果设备分辨率大于 1080p则等比例缩小到 1080p 以内
int captureWidth = realWidth;
@@ -167,8 +176,15 @@ public class ScreenCaptureService extends Service {
Log.i(TAG, "Real resolution: " + realWidth + "x" + realHeight);
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());
// 控制端发来的 SET_RESOLUTION 指令转交本服务处理(坐标映射与输入执行器无关)。
inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested);
// 开启屏幕捕获,使用计算出的 capture 分辨率
startScreenCapture(resultCode, resultData, serverUrl, deviceId, captureWidth, captureHeight, fps);
@@ -216,6 +232,90 @@ public class ScreenCaptureService extends Service {
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 注册,用于把连接状态同步到界面。
*/
@@ -232,6 +332,19 @@ public class ScreenCaptureService extends Service {
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
public void onDestroy() {
super.onDestroy();

View File

@@ -6,6 +6,8 @@ import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
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.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;
public void setLocalSink(VideoSink sink) {
@@ -372,7 +437,10 @@ public class WebRtcClient {
@Override
public void onStateChange() {
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();
}
}

View File

@@ -13,6 +13,8 @@ enum Action {
KEY = 3;
LONG_PRESS = 4;
MOTION_EVENT = 5;
SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率
REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值)
}
// 通过 RTCDataChannel 传输的控制指令protobuf 二进制)。
@@ -38,4 +40,12 @@ message ControlMessage {
// 原始动作MOTION_EVENT对应 Android MotionEvent ACTION_*0=DOWN 1=UP 2=MOVE
int32 motion_action = 11;
// 分辨率切换SET_RESOLUTION
// width 为目标长边/宽度;<=0 表示使用被控端原始native分辨率。
// height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。
// fps 为目标帧率;<=0 表示沿用当前帧率。
int32 width = 12;
int32 height = 13;
int32 fps = 14;
}

View File

@@ -76,6 +76,30 @@
android:enabled="false"
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
android:id="@+id/local_view"
android:layout_width="match_parent"

View File

@@ -13,4 +13,6 @@
<string name="accessibility_dialog_message">当前应用未使用系统签名,也未获取 Root 权限。如需在远程控制时模拟键盘与触摸输入,请前往系统设置开启本应用的无障碍服务。</string>
<string name="accessibility_dialog_go">去开启</string>
<string name="accessibility_dialog_cancel">取消</string>
<string name="resolution_label">采集分辨率</string>
<string name="resolution_changed_message">采集分辨率已切换为 %1$d×%2$d</string>
</resources>