diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java index 9c62856..40543b9 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java @@ -65,6 +65,7 @@ public class MainActivity extends BaseMvvmActivity - runOnUiThread(() -> updateCurrentResolutionText(w, h, fps))); + runOnUiThread(() -> { + updateCurrentResolutionText(w, h, fps); + syncFpsSpinnerSelection(fps); + })); // 注意:先 setSelection 再设置监听,避免初始化时触发一次多余的切换。 binding.spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @@ -395,4 +400,65 @@ public class MainActivity extends BaseMvvmActivity fpsSpinnerOptions; + + /** 构建帧率下拉框;本地选择后仅切换帧率(保持当前分辨率)。 */ + private void setupFpsSpinner() { + if (screenCaptureService == null) return; + + List options = screenCaptureService.getSupportedFpsList(); + if (options == null || options.isEmpty()) { + options = new ArrayList<>(); + options.add(15); + options.add(24); + options.add(30); + } + fpsSpinnerOptions = options; + + List labels = new ArrayList<>(); + for (int f : options) labels.add(f + " fps"); + ArrayAdapter adapter = new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + binding.spinnerFps.setAdapter(adapter); + + int[] cur = screenCaptureService.getCurrentCaptureResolution(); + syncFpsSpinnerSelection(cur[2]); + + // 注意:先 setSelection 再设置监听,避免初始化时触发一次多余的切换。 + binding.spinnerFps.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (screenCaptureService == null || fpsSpinnerOptions == null) return; + int fps = fpsSpinnerOptions.get(position); + int[] now = screenCaptureService.getCurrentCaptureResolution(); + // 与当前一致则不重复切换,避免无谓的提示与编码重启 + if (fps == now[2]) return; + screenCaptureService.changeFps(fps); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + /** 将帧率下拉框选中项同步为最接近 fps 的档位(本地/远端切换后调用)。 */ + private void syncFpsSpinnerSelection(int fps) { + if (fpsSpinnerOptions == null || fpsSpinnerOptions.isEmpty()) return; + int idx = 0; + int bestDiff = Integer.MAX_VALUE; + for (int i = 0; i < fpsSpinnerOptions.size(); i++) { + int diff = Math.abs(fpsSpinnerOptions.get(i) - fps); + if (diff < bestDiff) { + bestDiff = diff; + idx = i; + } + } + if (binding.spinnerFps.getSelectedItemPosition() != idx) { + binding.spinnerFps.setSelection(idx); + } + } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java index ecbf0c5..06f7b25 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/service/ScreenCaptureService.java @@ -49,6 +49,9 @@ import org.webrtc.EglBase; import org.webrtc.IceCandidate; import org.webrtc.ScreenCapturerAndroid; +import java.util.ArrayList; +import java.util.List; + public class ScreenCaptureService extends Service { private static final String TAG = "ScreenCaptureService"; @@ -112,6 +115,10 @@ public class ScreenCaptureService extends Service { private int currentCaptureWidth; private int currentCaptureHeight; private int currentCaptureFps; + /** 屏幕最大刷新率(远程/本地请求的帧率不会超过该值)。 */ + private int maxCaptureFps = 60; + /** 按屏幕刷新率筛选出的适合帧率档位(升序)。 */ + private List supportedFpsList = new ArrayList<>(); /** * 主线程 Handler,用于从 WebRTC 回调线程切回主线程执行 UI 提示。 */ @@ -210,11 +217,15 @@ public class ScreenCaptureService extends Service { } catch (Exception ignored) { } } - int fps = Math.round(refreshRate); - // 屏幕控制 30fps 已足够跟手,并显著降低 H264 硬编延迟(影响“跟手”体验)。 - // 更高的帧率会加重编码缓冲与带宽压力,得不偿失。 - if (fps > 30) fps = 30; - if (fps <= 0) fps = 30; + int maxFps = Math.round(refreshRate); + if (maxFps < 30) maxFps = 30; + this.maxCaptureFps = maxFps; + // 根据屏幕刷新率生成适合的帧率档位,供本地 UI 与控制端选择。 + this.supportedFpsList = buildSupportedFpsList(maxFps); + + // 初始默认 30fps:屏幕控制 30fps 已足够跟手,并显著降低 H264 硬编延迟(影响“跟手”体验)。 + // 更高的帧率会加重编码缓冲与带宽压力,用户可按需通过帧率档位手动调高。 + int fps = Math.min(30, maxFps); Log.i(TAG, "Real resolution: " + realWidth + "x" + realHeight); Log.i(TAG, "Capture resolution: " + captureWidth + "x" + captureHeight + " @ " + fps + "fps"); @@ -334,6 +345,36 @@ public class ScreenCaptureService extends Service { requestResolutionChange(longEdge, 0, 0, false); } + /** + * 仅切换采集帧率(保持当前分辨率),供本地 UI 使用。 + */ + public void changeFps(int fps) { + requestResolutionChange(currentCaptureWidth, currentCaptureHeight, fps, false); + } + + /** + * 返回按屏幕刷新率筛选出的适合帧率档位(升序)。 + */ + public List getSupportedFpsList() { + return supportedFpsList; + } + + /** + * 根据屏幕最大刷新率生成帧率档位列表:从常用档位中筛选不超过刷新率的值, + * 若刷新率本身不在常用档位中则追加(如 75/144Hz 屏幕)。 + */ + private static List buildSupportedFpsList(int maxFps) { + List list = new ArrayList<>(); + int[] candidates = {15, 24, 30, 60, 90, 120}; + for (int c : candidates) { + if (c <= maxFps) list.add(c); + } + if (list.isEmpty() || list.get(list.size() - 1) < maxFps) { + list.add(maxFps); + } + return list; + } + /** * 返回当前采集分辨率(宽/高/帧率)。 */ @@ -374,6 +415,8 @@ public class ScreenCaptureService extends Service { int targetH = size[1]; int targetFps = fps > 0 ? fps : currentCaptureFps; if (targetFps <= 0) targetFps = 30; + // 帧率不超过屏幕刷新率(超过无意义,只会浪费编码/带宽资源)。 + if (maxCaptureFps > 0 && targetFps > maxCaptureFps) targetFps = maxCaptureFps; if (webRtcClient == null) { Log.w(TAG, "requestResolutionChange ignored: WebRTC not ready"); @@ -658,6 +701,8 @@ public class ScreenCaptureService extends Service { // 初始化 WebRTC webRtcClient = new WebRtcClient(this, wsClient, deviceId); webRtcClient.initialize(eglBase); + // 供 REPORT_RESOLUTION 上报给控制端,控制端据此展示帧率档位。 + webRtcClient.setSupportedFpsList(supportedFpsList); webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes)); webRtcClient.setIceEventListener(() -> { mainHandler.post(() -> { diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java index b6b10fa..cb4134d 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java @@ -220,18 +220,28 @@ public class WebRtcClient { * 通过 DataChannel 把当前实际采集分辨率上报给控制端(action=REPORT_RESOLUTION)。 * 控制端(网页/Android/Flutter)据此同步展示当前分辨率,避免 UI 与实际不一致。 */ + /** 被控端支持的帧率档位(随 REPORT_RESOLUTION 上报给控制端)。 */ + private List supportedFpsList; + + public void setSupportedFpsList(List fpsList) { + this.supportedFpsList = fpsList; + } + 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() + ControlMessage.Builder builder = ControlMessage.newBuilder() .setAction(Action.REPORT_RESOLUTION) .setWidth(width) .setHeight(height) - .setFps(fps) - .build(); + .setFps(fps); + if (supportedFpsList != null && !supportedFpsList.isEmpty()) { + builder.addAllSupportedFps(supportedFpsList); + } + ControlMessage report = builder.build(); ByteBuffer buffer = ByteBuffer.wrap(report.toByteArray()); // binary=true:必须作为二进制发送,控制端按 protobuf 二进制解析; // 若传 false 会被当作文本,Web 端收到的将是字符串导致解码失败。 diff --git a/WebRTCControlled/app/src/main/proto/control_message.proto b/WebRTCControlled/app/src/main/proto/control_message.proto index d53fc96..57f0abc 100644 --- a/WebRTCControlled/app/src/main/proto/control_message.proto +++ b/WebRTCControlled/app/src/main/proto/control_message.proto @@ -51,4 +51,7 @@ message ControlMessage { int32 height = 13; int32 fps = 14; int32 stream_mode = 15; // 串流模式:0=WebRTC 内置媒体流,1=自编码(MediaCodec 硬编 + DataChannel 透传) + + // 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。 + repeated int32 supported_fps = 16; } diff --git a/WebRTCControlled/app/src/main/res/layout/activity_main.xml b/WebRTCControlled/app/src/main/res/layout/activity_main.xml index c096192..34eed32 100644 --- a/WebRTCControlled/app/src/main/res/layout/activity_main.xml +++ b/WebRTCControlled/app/src/main/res/layout/activity_main.xml @@ -101,6 +101,21 @@ android:layout_marginBottom="8dp" android:enabled="false" /> + + + + 取消 采集分辨率 采集分辨率已切换为 %1$d×%2$d + 采集帧率 连接方式:免密连接(被控端手动确认) diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java index f8be8ab..bbb307d 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/MainActivity.java @@ -69,6 +69,13 @@ public class MainActivity extends AppCompatActivity { private TextView tvStatusControl; private TextView tvStats; private Spinner spinnerResolution; + private Spinner spinnerFps; + /** 帧率下拉框当前档位(以被控端上报的 supported_fps 为准)。 */ + private final List fpsOptions = new ArrayList<>(); + /** 被控端最近上报的实际采集分辨率/帧率(仅切帧率时保持分辨率不变)。 */ + private int lastReportedWidth = 0; + private int lastReportedHeight = 0; + private int lastKnownFps = 0; private Button btnDisconnectControl; // 屏幕串流"自编码"模式相关 UI 与解码器 @@ -131,6 +138,7 @@ public class MainActivity extends AppCompatActivity { tvStatusControl = findViewById(R.id.tv_status_control); tvStats = findViewById(R.id.tv_stats); spinnerResolution = findViewById(R.id.spinner_resolution); + spinnerFps = findViewById(R.id.spinner_fps); btnDisconnectControl = findViewById(R.id.btn_disconnect_control); // 默认服务器地址 @@ -192,6 +200,7 @@ public class MainActivity extends AppCompatActivity { }); setupResolutionSpinner(); + setupFpsSpinner(); updateUI(false); setupSelfCodecUi(); @@ -414,6 +423,13 @@ public class MainActivity extends AppCompatActivity { webRtcClient.setSelfCodecDecoder(selfCodecDecoder); webRtcClient.setRecorder(videoRecorder); webRtcClient.setStreamModeReportListener(mode -> runOnUiThread(() -> syncStreamModeUi(mode))); + // 被控端上报当前分辨率/帧率与支持的帧率档位 -> 同步帧率下拉框 + webRtcClient.setResolutionReportListener((w, h, fps, supportedFps) -> runOnUiThread(() -> { + if (w > 0) lastReportedWidth = w; + if (h > 0) lastReportedHeight = h; + if (fps > 0) lastKnownFps = fps; + applyFpsOptions(supportedFps, fps); + })); webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() { @Override public void onConnectionEstablished() { @@ -689,6 +705,61 @@ public class MainActivity extends AppCompatActivity { }); } + /** 构建帧率下拉框;档位以被控端上报(REPORT_RESOLUTION.supported_fps)为准,未上报前使用常用档位。 */ + private void setupFpsSpinner() { + List defaults = new ArrayList<>(); + defaults.add(15); + defaults.add(24); + defaults.add(30); + defaults.add(60); + applyFpsOptions(defaults, 0); + + spinnerFps.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (position < 0 || position >= fpsOptions.size()) return; + int fps = fpsOptions.get(position); + // 与被控端当前帧率一致时不重复发送(含上报同步 setSelection 触发的回调) + if (fps <= 0 || fps == lastKnownFps || webRtcClient == null) return; + // 仅切换帧率:分辨率沿用被控端最近上报的实际采集尺寸 + webRtcClient.requestResolutionChange(lastReportedWidth, lastReportedHeight, fps); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + /** 更新帧率档位并把选中项同步为最接近 currentFps 的档位。 */ + private void applyFpsOptions(List options, int currentFps) { + if (spinnerFps == null) return; + if (options != null && !options.isEmpty() && !options.equals(fpsOptions)) { + fpsOptions.clear(); + fpsOptions.addAll(options); + List labels = new ArrayList<>(); + for (int f : fpsOptions) labels.add(f + "fps"); + ArrayAdapter adapter = new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinnerFps.setAdapter(adapter); + } + if (currentFps > 0 && !fpsOptions.isEmpty()) { + int idx = 0; + int best = Integer.MAX_VALUE; + for (int i = 0; i < fpsOptions.size(); i++) { + int diff = Math.abs(fpsOptions.get(i) - currentFps); + if (diff < best) { + best = diff; + idx = i; + } + } + if (spinnerFps.getSelectedItemPosition() != idx) { + spinnerFps.setSelection(idx); + } + } + } + private void updateUI(boolean connected) { setupPanel.setVisibility(connected ? View.GONE : View.VISIBLE); controlPanel.setVisibility(connected ? View.VISIBLE : View.GONE); @@ -697,6 +768,9 @@ public class MainActivity extends AppCompatActivity { if (spinnerResolution != null) { spinnerResolution.setEnabled(connected); } + if (spinnerFps != null) { + spinnerFps.setEnabled(connected); + } if (tvStatusControl != null) { tvStatusControl.setText(connected ? "远程控制中" : "未连接"); } diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java index 0ad50d2..139ab7f 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/webrtc/WebRtcClient.java @@ -71,6 +71,17 @@ public class WebRtcClient { void onStreamModeReported(int mode); } + /** 被控端上报当前采集分辨率/帧率与支持的帧率档位(REPORT_RESOLUTION)。 */ + public interface ResolutionReportListener { + void onResolutionReported(int width, int height, int fps, List supportedFps); + } + + private ResolutionReportListener resolutionReportListener; + + public void setResolutionReportListener(ResolutionReportListener l) { + this.resolutionReportListener = l; + } + private ConnectionListener connectionListener; public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) { @@ -371,10 +382,16 @@ public class WebRtcClient { streamModeReportListener.onStreamModeReported(msg.getStreamMode()); } } else if (msg.getAction() == Action.REPORT_RESOLUTION) { - Log.i(TAG, "Received resolution report: " + msg.getWidth() + "x" + msg.getHeight()); + Log.i(TAG, "Received resolution report: " + msg.getWidth() + "x" + msg.getHeight() + + " @ " + msg.getFps() + "fps, supportedFps=" + msg.getSupportedFpsList()); if (selfCodecDecoder != null) { selfCodecDecoder.updateResolution(msg.getWidth(), msg.getHeight()); } + if (resolutionReportListener != null) { + resolutionReportListener.onResolutionReported( + msg.getWidth(), msg.getHeight(), msg.getFps(), + new ArrayList<>(msg.getSupportedFpsList())); + } } } catch (Exception ignored) { } diff --git a/WebRTCController/app/src/main/proto/control_message.proto b/WebRTCController/app/src/main/proto/control_message.proto index d53fc96..57f0abc 100644 --- a/WebRTCController/app/src/main/proto/control_message.proto +++ b/WebRTCController/app/src/main/proto/control_message.proto @@ -51,4 +51,7 @@ message ControlMessage { int32 height = 13; int32 fps = 14; int32 stream_mode = 15; // 串流模式:0=WebRTC 内置媒体流,1=自编码(MediaCodec 硬编 + DataChannel 透传) + + // 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。 + repeated int32 supported_fps = 16; } diff --git a/WebRTCController/app/src/main/res/layout/activity_main.xml b/WebRTCController/app/src/main/res/layout/activity_main.xml index 2cb53a6..1e49497 100644 --- a/WebRTCController/app/src/main/res/layout/activity_main.xml +++ b/WebRTCController/app/src/main/res/layout/activity_main.xml @@ -152,6 +152,13 @@ android:layout_marginTop="4dp" android:backgroundTint="#80FFFFFF" /> + +