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); screenCaptureService.setStateListener(MainActivity.this);
setupLocalPreview(); setupLocalPreview();
setupResolutionSpinner(); setupResolutionSpinner();
setupFpsSpinner();
// 重新绑定(如 Activity 销毁后重新打开)时,依据服务实际状态恢复界面显示为“运行中”。 // 重新绑定(如 Activity 销毁后重新打开)时,依据服务实际状态恢复界面显示为“运行中”。
if (screenCaptureService.isStreaming()) { if (screenCaptureService.isStreaming()) {
updateUI(true); updateUI(true);
@@ -318,6 +319,7 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.etServerUrl.setEnabled(!running); binding.etServerUrl.setEnabled(!running);
binding.etDeviceId.setEnabled(!running); binding.etDeviceId.setEnabled(!running);
binding.spinnerResolution.setEnabled(running && isBound); binding.spinnerResolution.setEnabled(running && isBound);
binding.spinnerFps.setEnabled(running && isBound);
binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止"); binding.tvStatus.setText(running ? "状态: 运行中" : "状态: 已停止");
} }
@@ -368,9 +370,12 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.spinnerResolution.setSelection(selectIdx); binding.spinnerResolution.setSelection(selectIdx);
updateCurrentResolutionText(cur[0], cur[1], cur[2]); updateCurrentResolutionText(cur[0], cur[1], cur[2]);
// 注册监听:本地或远端切换分辨率后刷新当前分辨率文本 // 注册监听:本地或远端切换分辨率/帧率后刷新当前分辨率文本并同步帧率下拉框
screenCaptureService.setResolutionListener((w, h, fps, fromRemote) -> screenCaptureService.setResolutionListener((w, h, fps, fromRemote) ->
runOnUiThread(() -> updateCurrentResolutionText(w, h, fps))); runOnUiThread(() -> {
updateCurrentResolutionText(w, h, fps);
syncFpsSpinnerSelection(fps);
}));
// 注意:先 setSelection 再设置监听,避免初始化时触发一次多余的切换。 // 注意:先 setSelection 再设置监听,避免初始化时触发一次多余的切换。
binding.spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { binding.spinnerResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@@ -395,4 +400,65 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
binding.tvCurrentResolution.setText( binding.tvCurrentResolution.setText(
String.format("当前: %dx%d @ %dfps", w, h, fps)); 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.IceCandidate;
import org.webrtc.ScreenCapturerAndroid; import org.webrtc.ScreenCapturerAndroid;
import java.util.ArrayList;
import java.util.List;
public class ScreenCaptureService extends Service { public class ScreenCaptureService extends Service {
private static final String TAG = "ScreenCaptureService"; private static final String TAG = "ScreenCaptureService";
@@ -112,6 +115,10 @@ public class ScreenCaptureService extends Service {
private int currentCaptureWidth; private int currentCaptureWidth;
private int currentCaptureHeight; private int currentCaptureHeight;
private int currentCaptureFps; private int currentCaptureFps;
/** 屏幕最大刷新率(远程/本地请求的帧率不会超过该值)。 */
private int maxCaptureFps = 60;
/** 按屏幕刷新率筛选出的适合帧率档位(升序)。 */
private List<Integer> supportedFpsList = new ArrayList<>();
/** /**
* 主线程 Handler用于从 WebRTC 回调线程切回主线程执行 UI 提示。 * 主线程 Handler用于从 WebRTC 回调线程切回主线程执行 UI 提示。
*/ */
@@ -210,11 +217,15 @@ public class ScreenCaptureService extends Service {
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
int fps = Math.round(refreshRate); int maxFps = Math.round(refreshRate);
// 屏幕控制 30fps 已足够跟手,并显著降低 H264 硬编延迟(影响“跟手”体验)。 if (maxFps < 30) maxFps = 30;
// 更高的帧率会加重编码缓冲与带宽压力,得不偿失。 this.maxCaptureFps = maxFps;
if (fps > 30) fps = 30; // 根据屏幕刷新率生成适合的帧率档位,供本地 UI 与控制端选择。
if (fps <= 0) fps = 30; this.supportedFpsList = buildSupportedFpsList(maxFps);
// 初始默认 30fps屏幕控制 30fps 已足够跟手,并显著降低 H264 硬编延迟(影响“跟手”体验)。
// 更高的帧率会加重编码缓冲与带宽压力,用户可按需通过帧率档位手动调高。
int fps = Math.min(30, maxFps);
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");
@@ -334,6 +345,36 @@ public class ScreenCaptureService extends Service {
requestResolutionChange(longEdge, 0, 0, false); 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 targetH = size[1];
int targetFps = fps > 0 ? fps : currentCaptureFps; int targetFps = fps > 0 ? fps : currentCaptureFps;
if (targetFps <= 0) targetFps = 30; if (targetFps <= 0) targetFps = 30;
// 帧率不超过屏幕刷新率(超过无意义,只会浪费编码/带宽资源)。
if (maxCaptureFps > 0 && targetFps > maxCaptureFps) targetFps = maxCaptureFps;
if (webRtcClient == null) { if (webRtcClient == null) {
Log.w(TAG, "requestResolutionChange ignored: WebRTC not ready"); Log.w(TAG, "requestResolutionChange ignored: WebRTC not ready");
@@ -658,6 +701,8 @@ public class ScreenCaptureService extends Service {
// 初始化 WebRTC // 初始化 WebRTC
webRtcClient = new WebRtcClient(this, wsClient, deviceId); webRtcClient = new WebRtcClient(this, wsClient, deviceId);
webRtcClient.initialize(eglBase); webRtcClient.initialize(eglBase);
// 供 REPORT_RESOLUTION 上报给控制端,控制端据此展示帧率档位。
webRtcClient.setSupportedFpsList(supportedFpsList);
webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes)); webRtcClient.setInputCallback(commandBytes -> inputHandler.handleCommand(commandBytes));
webRtcClient.setIceEventListener(() -> { webRtcClient.setIceEventListener(() -> {
mainHandler.post(() -> { mainHandler.post(() -> {

View File

@@ -220,18 +220,28 @@ public class WebRtcClient {
* 通过 DataChannel 把当前实际采集分辨率上报给控制端action=REPORT_RESOLUTION * 通过 DataChannel 把当前实际采集分辨率上报给控制端action=REPORT_RESOLUTION
* 控制端(网页/Android/Flutter据此同步展示当前分辨率避免 UI 与实际不一致。 * 控制端(网页/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) { public void sendResolutionReport(int width, int height, int fps) {
if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) { if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) {
Log.d(TAG, "sendResolutionReport skipped: dataChannel not open"); Log.d(TAG, "sendResolutionReport skipped: dataChannel not open");
return; return;
} }
try { try {
ControlMessage report = ControlMessage.newBuilder() ControlMessage.Builder builder = ControlMessage.newBuilder()
.setAction(Action.REPORT_RESOLUTION) .setAction(Action.REPORT_RESOLUTION)
.setWidth(width) .setWidth(width)
.setHeight(height) .setHeight(height)
.setFps(fps) .setFps(fps);
.build(); if (supportedFpsList != null && !supportedFpsList.isEmpty()) {
builder.addAllSupportedFps(supportedFpsList);
}
ControlMessage report = builder.build();
ByteBuffer buffer = ByteBuffer.wrap(report.toByteArray()); ByteBuffer buffer = ByteBuffer.wrap(report.toByteArray());
// binary=true必须作为二进制发送控制端按 protobuf 二进制解析; // binary=true必须作为二进制发送控制端按 protobuf 二进制解析;
// 若传 false 会被当作文本Web 端收到的将是字符串导致解码失败。 // 若传 false 会被当作文本Web 端收到的将是字符串导致解码失败。

View File

@@ -51,4 +51,7 @@ message ControlMessage {
int32 height = 13; int32 height = 13;
int32 fps = 14; int32 fps = 14;
int32 stream_mode = 15; // 串流模式0=WebRTC 内置媒体流1=自编码(MediaCodec 硬编 + DataChannel 透传) 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:layout_marginBottom="8dp"
android:enabled="false" /> 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 <TextView
android:id="@+id/tv_current_resolution" android:id="@+id/tv_current_resolution"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

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

View File

@@ -69,6 +69,13 @@ public class MainActivity extends AppCompatActivity {
private TextView tvStatusControl; private TextView tvStatusControl;
private TextView tvStats; private TextView tvStats;
private Spinner spinnerResolution; private Spinner spinnerResolution;
private Spinner spinnerFps;
/** 帧率下拉框当前档位(以被控端上报的 supported_fps 为准)。 */
private final List<Integer> fpsOptions = new ArrayList<>();
/** 被控端最近上报的实际采集分辨率/帧率(仅切帧率时保持分辨率不变)。 */
private int lastReportedWidth = 0;
private int lastReportedHeight = 0;
private int lastKnownFps = 0;
private Button btnDisconnectControl; private Button btnDisconnectControl;
// 屏幕串流"自编码"模式相关 UI 与解码器 // 屏幕串流"自编码"模式相关 UI 与解码器
@@ -131,6 +138,7 @@ public class MainActivity extends AppCompatActivity {
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); spinnerResolution = findViewById(R.id.spinner_resolution);
spinnerFps = findViewById(R.id.spinner_fps);
btnDisconnectControl = findViewById(R.id.btn_disconnect_control); btnDisconnectControl = findViewById(R.id.btn_disconnect_control);
// 默认服务器地址 // 默认服务器地址
@@ -192,6 +200,7 @@ public class MainActivity extends AppCompatActivity {
}); });
setupResolutionSpinner(); setupResolutionSpinner();
setupFpsSpinner();
updateUI(false); updateUI(false);
setupSelfCodecUi(); setupSelfCodecUi();
@@ -414,6 +423,13 @@ public class MainActivity extends AppCompatActivity {
webRtcClient.setSelfCodecDecoder(selfCodecDecoder); webRtcClient.setSelfCodecDecoder(selfCodecDecoder);
webRtcClient.setRecorder(videoRecorder); webRtcClient.setRecorder(videoRecorder);
webRtcClient.setStreamModeReportListener(mode -> runOnUiThread(() -> syncStreamModeUi(mode))); 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() { webRtcClient.setConnectionListener(new WebRtcClient.ConnectionListener() {
@Override @Override
public void onConnectionEstablished() { public void onConnectionEstablished() {
@@ -689,6 +705,61 @@ public class MainActivity extends AppCompatActivity {
}); });
} }
/** 构建帧率下拉框档位以被控端上报REPORT_RESOLUTION.supported_fps为准未上报前使用常用档位。 */
private void setupFpsSpinner() {
List<Integer> 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<Integer> options, int currentFps) {
if (spinnerFps == null) return;
if (options != null && !options.isEmpty() && !options.equals(fpsOptions)) {
fpsOptions.clear();
fpsOptions.addAll(options);
List<String> labels = new ArrayList<>();
for (int f : fpsOptions) 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);
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) { 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);
@@ -697,6 +768,9 @@ public class MainActivity extends AppCompatActivity {
if (spinnerResolution != null) { if (spinnerResolution != null) {
spinnerResolution.setEnabled(connected); spinnerResolution.setEnabled(connected);
} }
if (spinnerFps != null) {
spinnerFps.setEnabled(connected);
}
if (tvStatusControl != null) { if (tvStatusControl != null) {
tvStatusControl.setText(connected ? "远程控制中" : "未连接"); tvStatusControl.setText(connected ? "远程控制中" : "未连接");
} }

View File

@@ -71,6 +71,17 @@ public class WebRtcClient {
void onStreamModeReported(int mode); void onStreamModeReported(int mode);
} }
/** 被控端上报当前采集分辨率/帧率与支持的帧率档位REPORT_RESOLUTION。 */
public interface ResolutionReportListener {
void onResolutionReported(int width, int height, int fps, List<Integer> supportedFps);
}
private ResolutionReportListener resolutionReportListener;
public void setResolutionReportListener(ResolutionReportListener l) {
this.resolutionReportListener = l;
}
private ConnectionListener connectionListener; private ConnectionListener connectionListener;
public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) { public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) {
@@ -371,10 +382,16 @@ public class WebRtcClient {
streamModeReportListener.onStreamModeReported(msg.getStreamMode()); streamModeReportListener.onStreamModeReported(msg.getStreamMode());
} }
} else if (msg.getAction() == Action.REPORT_RESOLUTION) { } 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) { if (selfCodecDecoder != null) {
selfCodecDecoder.updateResolution(msg.getWidth(), msg.getHeight()); selfCodecDecoder.updateResolution(msg.getWidth(), msg.getHeight());
} }
if (resolutionReportListener != null) {
resolutionReportListener.onResolutionReported(
msg.getWidth(), msg.getHeight(), msg.getFps(),
new ArrayList<>(msg.getSupportedFpsList()));
}
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }

View File

@@ -51,4 +51,7 @@ message ControlMessage {
int32 height = 13; int32 height = 13;
int32 fps = 14; int32 fps = 14;
int32 stream_mode = 15; // 串流模式0=WebRTC 内置媒体流1=自编码(MediaCodec 硬编 + DataChannel 透传) int32 stream_mode = 15; // 串流模式0=WebRTC 内置媒体流1=自编码(MediaCodec 硬编 + DataChannel 透传)
// 被控端支持的帧率档位列表REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
repeated int32 supported_fps = 16;
} }

View File

@@ -152,6 +152,13 @@
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:backgroundTint="#80FFFFFF" /> android:backgroundTint="#80FFFFFF" />
<Spinner
android:id="@+id/spinner_fps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:backgroundTint="#80FFFFFF" />
<Button <Button
android:id="@+id/btn_disconnect_control" android:id="@+id/btn_disconnect_control"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -48,4 +48,9 @@ message ControlMessage {
int32 width = 12; int32 width = 12;
int32 height = 13; int32 height = 13;
int32 fps = 14; int32 fps = 14;
// 15 预留给 Android 端的 stream_modeWeb 端暂未使用,编号保持对齐)。
// 被控端支持的帧率档位列表REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
repeated int32 supported_fps = 16;
} }

View File

@@ -25,6 +25,10 @@ const resolutionPresets = [
// 因此这里也必须用数字(不能用字符串 '0'),否则默认选中项与比较逻辑会类型不一致。 // 因此这里也必须用数字(不能用字符串 '0'),否则默认选中项与比较逻辑会类型不一致。
const selectedResolution = ref(0); const selectedResolution = ref(0);
// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
const fpsOptions = ref([15, 24, 30, 60]);
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
function press(code) { function press(code) {
if (!store.dataChannelOpen) return; if (!store.dataChannelOpen) return;
sendKey(code); sendKey(code);
@@ -39,12 +43,29 @@ function onResolutionChange() {
} }
} }
// 被控端上报当前实际采集分辨率时,把下拉框同步到对应预设(避免 UI 与实际不一致)。 function onFpsChange() {
if (!store.dataChannelOpen) return;
const fps = Number(selectedFps.value);
if (fps <= 0) return;
// 仅切换帧率分辨率沿用被控端最近上报的实际采集尺寸0 表示原生)。
const res = store.currentResolution;
sendResolutionChange(res ? res.width : 0, res ? res.height : 0, fps);
}
// 被控端上报当前实际采集分辨率/帧率时,同步下拉框(避免 UI 与实际不一致)。
watch(() => store.currentResolution, (res) => { watch(() => store.currentResolution, (res) => {
if (!res) return; if (!res) return;
const longEdge = Math.max(res.width, res.height); const longEdge = Math.max(res.width, res.height);
const preset = resolutionPresets.find((p) => p.width === longEdge); const preset = resolutionPresets.find((p) => p.width === longEdge);
if (preset) selectedResolution.value = preset.width; if (preset) selectedResolution.value = preset.width;
// 帧率档位以被控端上报为准(按屏幕刷新率筛选)
if (Array.isArray(res.supportedFps) && res.supportedFps.length > 0) {
fpsOptions.value = res.supportedFps;
}
if (res.fps > 0 && fpsOptions.value.includes(res.fps)) {
selectedFps.value = res.fps;
}
}); });
</script> </script>
@@ -62,8 +83,19 @@ watch(() => store.currentResolution, (res) => {
{{ p.label }} {{ p.label }}
</option> </option>
</select> </select>
<label class="resolution-label">帧率:</label>
<select
class="resolution-select"
v-model="selectedFps"
:disabled="!store.dataChannelOpen"
@change="onFpsChange"
>
<option v-if="selectedFps === 0" :value="0" disabled>--</option>
<option v-for="f in fpsOptions" :key="f" :value="f">{{ f }}fps</option>
</select>
<span class="resolution-current" v-if="store.currentResolution"> <span class="resolution-current" v-if="store.currentResolution">
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }} 当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}@{{ store.currentResolution.fps }}fps
</span> </span>
<span class="record-sep"></span> <span class="record-sep"></span>

View File

@@ -167,6 +167,8 @@ export class WebRtcController {
width: msg.width | 0, width: msg.width | 0,
height: msg.height | 0, height: msg.height | 0,
fps: msg.fps | 0, fps: msg.fps | 0,
// 被控端按屏幕刷新率筛选出的帧率档位(供帧率下拉框使用)
supportedFps: Array.isArray(msg.supportedFps) ? msg.supportedFps.map((f) => f | 0) : [],
}); });
} }
} catch (err) { } catch (err) {

View File

@@ -14,3 +14,10 @@ logging:
level: level:
com.ttstd.signaling: DEBUG com.ttstd.signaling: DEBUG
org.springframework.web.socket: DEBUG org.springframework.web.socket: DEBUG
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong

View File

@@ -60,6 +60,10 @@ class RemoteController {
/// 被控端/解码器上报分辨率。 /// 被控端/解码器上报分辨率。
void Function(int width, int height)? onResolutionReported; void Function(int width, int height)? onResolutionReported;
/// 被控端上报当前采集帧率与支持的帧率档位。
void Function(int width, int height, int fps, List<int> supportedFps)?
onFpsReport;
/// 当前平台不支持原生硬解时回调。 /// 当前平台不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported; void Function()? onSelfCodecNotSupported;
@@ -116,6 +120,8 @@ class RemoteController {
_webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call(); _webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call();
_webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode); _webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode);
_webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h); _webRtc!.onResolutionReported = (w, h) => onResolutionReported?.call(w, h);
_webRtc!.onFpsReport =
(w, h, fps, list) => onFpsReport?.call(w, h, fps, list);
_webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call(); _webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call();
_webRtc!.initialize().catchError((e) { _webRtc!.initialize().catchError((e) {
onStatusChanged?.call('状态: 连接失败 - $e'); onStatusChanged?.call('状态: 连接失败 - $e');

View File

@@ -82,6 +82,16 @@ class _ControllerHomeState extends State<ControllerHome> {
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0}, {'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
]; ];
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
List<int> _fpsOptions = const [15, 24, 30, 60];
/// 被控端当前采集帧率0 表示尚未收到上报)。
int _currentFps = 0;
/// 被控端最近上报的实际采集尺寸仅切帧率时保持分辨率不变0 表示原生)。
int _lastReportedWidth = 0;
int _lastReportedHeight = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -354,6 +364,16 @@ class _ControllerHomeState extends State<ControllerHome> {
_resizeWindowToAspect(aspect); _resizeWindowToAspect(aspect);
} }
}; };
// 被控端上报当前帧率与支持的帧率档位 -> 同步帧率菜单
_controller!.onFpsReport = (w, h, fps, supportedFps) {
if (!mounted) return;
setState(() {
if (w > 0) _lastReportedWidth = w;
if (h > 0) _lastReportedHeight = h;
if (fps > 0) _currentFps = fps;
if (supportedFps.isNotEmpty) _fpsOptions = supportedFps;
});
};
_controller!.onSelfCodecNotSupported = () { _controller!.onSelfCodecNotSupported = () {
if (mounted) setState(() => _selfCodecSupported = false); if (mounted) setState(() => _selfCodecSupported = false);
_showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。'); _showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。');
@@ -447,6 +467,47 @@ class _ControllerHomeState extends State<ControllerHome> {
); );
} }
/// 弹出帧率选择菜单iOS 风格 ActionSheet仅切帧率分辨率保持不变。
void _showFpsMenu() {
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) => CupertinoActionSheet(
title: const Text('切换帧率'),
actions: [
for (final fps in _fpsOptions)
CupertinoActionSheetAction(
onPressed: () {
Navigator.of(ctx).pop();
if (fps <= 0 || fps == _currentFps) return;
// 分辨率沿用被控端最近上报的实际采集尺寸0 表示原生)
_controller?.sendResolutionChange(
_lastReportedWidth,
_lastReportedHeight,
fps,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fps == _currentFps) ...[
const Icon(CupertinoIcons.check_mark,
size: 18, color: CupertinoColors.activeBlue),
const SizedBox(width: 8),
],
Text('${fps}fps'),
],
),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
);
}
/// 切换串流模式WebRTC 全托管 <-> 自编码(自建 MediaCodec 解码)。 /// 切换串流模式WebRTC 全托管 <-> 自编码(自建 MediaCodec 解码)。
void _toggleStreamMode() { void _toggleStreamMode() {
if (!_selfCodecSupported) { if (!_selfCodecSupported) {
@@ -534,6 +595,10 @@ class _ControllerHomeState extends State<ControllerHome> {
_recording = false; _recording = false;
_recordStatus = ''; _recordStatus = '';
_pendingRecordStart = false; _pendingRecordStart = false;
_currentFps = 0;
_lastReportedWidth = 0;
_lastReportedHeight = 0;
_fpsOptions = const [15, 24, 30, 60];
}); });
} }
@@ -636,6 +701,26 @@ class _ControllerHomeState extends State<ControllerHome> {
], ],
), ),
), ),
// 帧率菜单按钮:显示被控端当前采集帧率
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _showFpsMenu,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.speedometer,
color: CupertinoColors.white, size: 20),
const SizedBox(width: 6),
Text(
_currentFps > 0 ? '${_currentFps}fps' : '帧率',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 自编码串流开关:仅 Android 等支持原生硬解的平台可用。 // 自编码串流开关:仅 Android 等支持原生硬解的平台可用。
CupertinoButton( CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),

View File

@@ -40,6 +40,7 @@ class ControlMessage extends $pb.GeneratedMessage {
$core.int? height, $core.int? height,
$core.int? fps, $core.int? fps,
$core.int? streamMode, $core.int? streamMode,
$core.Iterable<$core.int>? supportedFps,
}) { }) {
final result = create(); final result = create();
if (action != null) result.action = action; if (action != null) result.action = action;
@@ -57,6 +58,7 @@ class ControlMessage extends $pb.GeneratedMessage {
if (height != null) result.height = height; if (height != null) result.height = height;
if (fps != null) result.fps = fps; if (fps != null) result.fps = fps;
if (streamMode != null) result.streamMode = streamMode; if (streamMode != null) result.streamMode = streamMode;
if (supportedFps != null) result.supportedFps.addAll(supportedFps);
return result; return result;
} }
@@ -89,6 +91,7 @@ class ControlMessage extends $pb.GeneratedMessage {
..aI(13, _omitFieldNames ? '' : 'height') ..aI(13, _omitFieldNames ? '' : 'height')
..aI(14, _omitFieldNames ? '' : 'fps') ..aI(14, _omitFieldNames ? '' : 'fps')
..aI(15, _omitFieldNames ? '' : 'stream_mode') ..aI(15, _omitFieldNames ? '' : 'stream_mode')
..p<$core.int>(16, _omitFieldNames ? '' : 'supportedFps', $pb.PbFieldType.K3)
..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.')
@@ -254,6 +257,10 @@ class ControlMessage extends $pb.GeneratedMessage {
$core.bool hasStreamMode() => $_has(14); $core.bool hasStreamMode() => $_has(14);
@$pb.TagNumber(15) @$pb.TagNumber(15)
void clearStreamMode() => $_clearField(15); void clearStreamMode() => $_clearField(15);
/// 被控端支持的帧率档位列表REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
@$pb.TagNumber(16)
$pb.PbList<$core.int> get supportedFps => $_getList(15);
} }
const $core.bool _omitFieldNames = const $core.bool _omitFieldNames =

View File

@@ -77,6 +77,10 @@ class WebRtcController {
/// 被控端上报分辨率(自编码解码器输出尺寸变化时也会触发)。 /// 被控端上报分辨率(自编码解码器输出尺寸变化时也会触发)。
void Function(int width, int height)? onResolutionReported; void Function(int width, int height)? onResolutionReported;
/// 被控端上报当前采集帧率与支持的帧率档位(仅 REPORT_RESOLUTION 触发)。
void Function(int width, int height, int fps, List<int> supportedFps)?
onFpsReport;
/// 当前平台Windows / Linux / Web / iOS 等)不支持原生硬解时回调。 /// 当前平台Windows / Linux / Web / iOS 等)不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported; void Function()? onSelfCodecNotSupported;
@@ -370,6 +374,9 @@ class WebRtcController {
_selfCodecHeight = msg.height; _selfCodecHeight = msg.height;
} }
onResolutionReported?.call(msg.width, msg.height); onResolutionReported?.call(msg.width, msg.height);
// 同步帧率信息(当前帧率 + 被控端按刷新率筛选的帧率档位)
onFpsReport?.call(
msg.width, msg.height, msg.fps, msg.supportedFps.toList());
break; break;
default: default:
// ignore: avoid_print // ignore: avoid_print
@@ -794,6 +801,7 @@ class WebRtcController {
onSelfCodecReady = null; onSelfCodecReady = null;
onStreamModeReport = null; onStreamModeReport = null;
onResolutionReported = null; onResolutionReported = null;
onFpsReport = null;
onSelfCodecNotSupported = null; onSelfCodecNotSupported = null;
_pc = null; _pc = null;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。 // 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。

View File

@@ -54,4 +54,7 @@ message ControlMessage {
// 串流模式SET_STREAM_MODE / REPORT_STREAM_MODE // 串流模式SET_STREAM_MODE / REPORT_STREAM_MODE
// 0 = WebRTC 全托管1 = 自编码(自建 MediaCodec 编解码 + video DataChannel 裸流透传) // 0 = WebRTC 全托管1 = 自编码(自建 MediaCodec 编解码 + video DataChannel 裸流透传)
int32 stream_mode = 15; int32 stream_mode = 15;
// 被控端支持的帧率档位列表REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
repeated int32 supported_fps = 16;
} }

View File

@@ -34,6 +34,8 @@ struct ControlMessage {
var height: Int32 = 0 // field 13, varint var height: Int32 = 0 // field 13, varint
var fps: Int32 = 0 // field 14, varint var fps: Int32 = 0 // field 14, varint
var streamMode: Int32 = 0 // field 15, varint (0=WebRTC 1=) var streamMode: Int32 = 0 // field 15, varint (0=WebRTC 1=)
/// REPORT_RESOLUTION
var supportedFps: [Int32] = [] // field 16, repeated varint (packed)
// MARK: - Encode // MARK: - Encode
@@ -54,6 +56,16 @@ struct ControlMessage {
w.writeVarintField(13, UInt64(bitPattern: Int64(height))) w.writeVarintField(13, UInt64(bitPattern: Int64(height)))
w.writeVarintField(14, UInt64(bitPattern: Int64(fps))) w.writeVarintField(14, UInt64(bitPattern: Int64(fps)))
w.writeVarintField(15, UInt64(bitPattern: Int64(streamMode))) w.writeVarintField(15, UInt64(bitPattern: Int64(streamMode)))
// repeated int32 supported_fps = 16packed
if !supportedFps.isEmpty {
var payload = ProtoWriter()
for v in supportedFps {
payload.writeVarint(UInt64(bitPattern: Int64(v)))
}
w.writeVarint(UInt64(16 << 3 | 2))
w.writeVarint(UInt64(payload.data.count))
w.data.append(payload.data)
}
return w.data return w.data
} }
@@ -81,6 +93,19 @@ struct ControlMessage {
case (13, 0): guard let v = r.readVarint() else { return nil }; msg.height = Int32(truncatingIfNeeded: Int64(bitPattern: v)) case (13, 0): guard let v = r.readVarint() else { return nil }; msg.height = Int32(truncatingIfNeeded: Int64(bitPattern: v))
case (14, 0): guard let v = r.readVarint() else { return nil }; msg.fps = Int32(truncatingIfNeeded: Int64(bitPattern: v)) case (14, 0): guard let v = r.readVarint() else { return nil }; msg.fps = Int32(truncatingIfNeeded: Int64(bitPattern: v))
case (15, 0): guard let v = r.readVarint() else { return nil }; msg.streamMode = Int32(truncatingIfNeeded: Int64(bitPattern: v)) case (15, 0): guard let v = r.readVarint() else { return nil }; msg.streamMode = Int32(truncatingIfNeeded: Int64(bitPattern: v))
case (16, 2):
// packed repeated int32 varint
guard let len = r.readVarint() else { return nil }
let end = r.offset + Int(len)
guard end <= data.endIndex else { return nil }
while r.offset < end {
guard let v = r.readVarint() else { return nil }
msg.supportedFps.append(Int32(truncatingIfNeeded: Int64(bitPattern: v)))
}
case (16, 0):
// packed varint
guard let v = r.readVarint() else { return nil }
msg.supportedFps.append(Int32(truncatingIfNeeded: Int64(bitPattern: v)))
default: default:
// wire type // wire type
if !r.skip(wireType: wire) { return nil } if !r.skip(wireType: wire) { return nil }

View File

@@ -56,6 +56,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
@Published var statsText: String = "" @Published var statsText: String = ""
@Published var streamMode: StreamMode = .webrtc @Published var streamMode: StreamMode = .webrtc
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0] @Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
/// supported_fps
@Published var fpsOptions: [Int] = [15, 24, 30, 60]
/// 0
@Published var currentFps: Int = 0
/// / /// /
@Published var videoAspect: CGFloat = 9.0 / 16.0 @Published var videoAspect: CGFloat = 9.0 / 16.0
@Published var showAuthSheet: Bool = false @Published var showAuthSheet: Bool = false
@@ -82,6 +86,9 @@ final class ControllerViewModel: NSObject, ObservableObject {
private var pendingAuthValue: String = "" private var pendingAuthValue: String = ""
/// ///
private var suppressStreamModeSend = false private var suppressStreamModeSend = false
/// 0
private var lastReportedWidth: Int32 = 0
private var lastReportedHeight: Int32 = 0
override init() { override init() {
super.init() super.init()
@@ -138,6 +145,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
statsText = "" statsText = ""
streamMode = .webrtc streamMode = .webrtc
selectedResolution = ResolutionOption.all[0] selectedResolution = ResolutionOption.all[0]
fpsOptions = [15, 24, 30, 60]
currentFps = 0
lastReportedWidth = 0
lastReportedHeight = 0
videoAspect = 9.0 / 16.0 videoAspect = 9.0 / 16.0
} }
@@ -192,6 +203,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
webRTCClient?.requestResolutionChange(width: option.width, height: option.height, fps: 0) webRTCClient?.requestResolutionChange(width: option.width, height: option.height, fps: 0)
} }
/// 沿0
func selectFps(_ fps: Int) {
guard fps > 0, fps != currentFps else { return }
webRTCClient?.requestResolutionChange(width: lastReportedWidth,
height: lastReportedHeight,
fps: Int32(fps))
}
func toggleStreamMode(_ selfCodecOn: Bool) { func toggleStreamMode(_ selfCodecOn: Bool) {
let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc
guard newMode != streamMode else { return } guard newMode != streamMode else { return }
@@ -441,10 +460,17 @@ extension ControllerViewModel: WebRTCClientDelegate {
func webRTCClient(didReportResolution width: Int, height: Int) { func webRTCClient(didReportResolution width: Int, height: Int) {
guard width > 0, height > 0 else { return } guard width > 0, height > 0 else { return }
lastReportedWidth = Int32(width)
lastReportedHeight = Int32(height)
if streamMode == .webrtc { if streamMode == .webrtc {
videoAspect = CGFloat(width) / CGFloat(height) videoAspect = CGFloat(width) / CGFloat(height)
} }
} }
func webRTCClient(didReportFps fps: Int, supportedFps: [Int]) {
if fps > 0 { currentFps = fps }
if !supportedFps.isEmpty { fpsOptions = supportedFps }
}
} }
// MARK: - RTCVideoViewDelegateWebRTC // MARK: - RTCVideoViewDelegateWebRTC

View File

@@ -147,6 +147,25 @@ private struct ControlPanelView: View {
.font(.footnote) .font(.footnote)
} }
// REPORT_RESOLUTION
Menu {
ForEach(viewModel.fpsOptions, id: \.self) { fps in
Button {
viewModel.selectFps(fps)
} label: {
if fps == viewModel.currentFps {
Label("\(fps)fps", systemImage: "checkmark")
} else {
Text("\(fps)fps")
}
}
}
} label: {
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
systemImage: "speedometer")
.font(.footnote)
}
Button(role: .destructive) { Button(role: .destructive) {
viewModel.disconnect() viewModel.disconnect()
} label: { } label: {

View File

@@ -12,6 +12,8 @@ protocol WebRTCClientDelegate: AnyObject {
func webRTCClient(didReportStreamMode mode: Int) func webRTCClient(didReportStreamMode mode: Int)
/// ///
func webRTCClient(didReportResolution width: Int, height: Int) func webRTCClient(didReportResolution width: Int, height: Int)
///
func webRTCClient(didReportFps fps: Int, supportedFps: [Int])
} }
/// WebRTC PeerConnection Offer/Answer /// WebRTC PeerConnection Offer/Answer
@@ -313,6 +315,8 @@ extension WebRTCClient: RTCDataChannelDelegate {
self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode)) self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode))
case .reportResolution: case .reportResolution:
self.delegate?.webRTCClient(didReportResolution: Int(msg.width), height: Int(msg.height)) self.delegate?.webRTCClient(didReportResolution: Int(msg.width), height: Int(msg.height))
self.delegate?.webRTCClient(didReportFps: Int(msg.fps),
supportedFps: msg.supportedFps.map(Int.init))
default: default:
break break
} }