feat: 支持手动切换采集帧率

在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。

另附带更新信号服务器的数据库配置。
This commit is contained in:
TongTongStudio
2026-07-31 03:26:40 +08:00
parent c3602493dd
commit 362b9f238a
23 changed files with 483 additions and 13 deletions

View File

@@ -65,6 +65,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
screenCaptureService.setStateListener(MainActivity.this);
setupLocalPreview();
setupResolutionSpinner();
setupFpsSpinner();
// 重新绑定(如 Activity 销毁后重新打开)时,依据服务实际状态恢复界面显示为“运行中”。
if (screenCaptureService.isStreaming()) {
updateUI(true);
@@ -318,6 +319,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.etServerUrl.setEnabled(!running);
binding.etDeviceId.setEnabled(!running);
binding.spinnerResolution.setEnabled(running && isBound);
binding.spinnerFps.setEnabled(running && isBound);
binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");
}
@@ -368,9 +370,12 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.spinnerResolution.setSelection(selectIdx);
updateCurrentResolutionText(cur[0], cur[1], cur[2]);
// 注册监听:本地或远端切换分辨率后刷新当前分辨率文本
// 注册监听:本地或远端切换分辨率/帧率后刷新当前分辨率文本并同步帧率下拉框
screenCaptureService.setResolutionListener((w, h, fps, fromRemote) ->
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<MainViewModel, ActivityMainBi
binding.tvCurrentResolution.setText(
String.format("当前: %dx%d @ %dfps", w, h, fps));
}
/** 帧率下拉框可选档位(由服务按屏幕刷新率给出)。 */
private List<Integer> fpsSpinnerOptions;
/** 构建帧率下拉框;本地选择后仅切换帧率(保持当前分辨率)。 */
private void setupFpsSpinner() {
if (screenCaptureService == null) return;
List<Integer> options = screenCaptureService.getSupportedFpsList();
if (options == null || options.isEmpty()) {
options = new ArrayList<>();
options.add(15);
options.add(24);
options.add(30);
}
fpsSpinnerOptions = options;
List<String> labels = new ArrayList<>();
for (int f : options) labels.add(f + " fps");
ArrayAdapter<String> 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);
}
}
}

View File

@@ -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<Integer> 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<Integer> getSupportedFpsList() {
return supportedFpsList;
}
/**
* 根据屏幕最大刷新率生成帧率档位列表:从常用档位中筛选不超过刷新率的值,
* 若刷新率本身不在常用档位中则追加(如 75/144Hz 屏幕)。
*/
private static List<Integer> buildSupportedFpsList(int maxFps) {
List<Integer> 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(() -> {

View File

@@ -220,18 +220,28 @@ public class WebRtcClient {
* 通过 DataChannel 把当前实际采集分辨率上报给控制端action=REPORT_RESOLUTION
* 控制端(网页/Android/Flutter据此同步展示当前分辨率避免 UI 与实际不一致。
*/
/** 被控端支持的帧率档位(随 REPORT_RESOLUTION 上报给控制端)。 */
private List<Integer> supportedFpsList;
public void setSupportedFpsList(List<Integer> 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 端收到的将是字符串导致解码失败。

View File

@@ -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;
}

View File

@@ -101,6 +101,21 @@
android:layout_marginBottom="8dp"
android:enabled="false" />
<TextView
android:id="@+id/tv_fps_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/fps_label"
android:textSize="14sp" />
<Spinner
android:id="@+id/spinner_fps"
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"

View File

@@ -16,6 +16,7 @@
<string name="accessibility_dialog_cancel">取消</string>
<string name="resolution_label">采集分辨率</string>
<string name="resolution_changed_message">采集分辨率已切换为 %1$d×%2$d</string>
<string name="fps_label">采集帧率</string>
<!-- 免密连接 -->
<string name="connection_request_mode_none">连接方式:免密连接(被控端手动确认)</string>