feat: 支持手动切换采集帧率
在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。 另附带更新信号服务器的数据库配置。
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() -> {
|
||||
|
||||
@@ -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 端收到的将是字符串导致解码失败。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<Integer> 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<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) {
|
||||
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 ? "远程控制中" : "未连接");
|
||||
}
|
||||
|
||||
@@ -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<Integer> 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) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -152,6 +152,13 @@
|
||||
android:layout_marginTop="4dp"
|
||||
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
|
||||
android:id="@+id/btn_disconnect_control"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -48,4 +48,9 @@ message ControlMessage {
|
||||
int32 width = 12;
|
||||
int32 height = 13;
|
||||
int32 fps = 14;
|
||||
|
||||
// 15 预留给 Android 端的 stream_mode(Web 端暂未使用,编号保持对齐)。
|
||||
|
||||
// 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
|
||||
repeated int32 supported_fps = 16;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ const resolutionPresets = [
|
||||
// 因此这里也必须用数字(不能用字符串 '0'),否则默认选中项与比较逻辑会类型不一致。
|
||||
const selectedResolution = ref(0);
|
||||
|
||||
// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准。
|
||||
const fpsOptions = ref([15, 24, 30, 60]);
|
||||
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
|
||||
|
||||
function press(code) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
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) => {
|
||||
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;
|
||||
|
||||
// 帧率档位以被控端上报为准(按屏幕刷新率筛选)
|
||||
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>
|
||||
|
||||
@@ -62,8 +83,19 @@ watch(() => store.currentResolution, (res) => {
|
||||
{{ p.label }}
|
||||
</option>
|
||||
</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">
|
||||
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}
|
||||
当前: {{ store.currentResolution.width }}×{{ store.currentResolution.height }}@{{ store.currentResolution.fps }}fps
|
||||
</span>
|
||||
|
||||
<span class="record-sep"></span>
|
||||
|
||||
@@ -167,6 +167,8 @@ export class WebRtcController {
|
||||
width: msg.width | 0,
|
||||
height: msg.height | 0,
|
||||
fps: msg.fps | 0,
|
||||
// 被控端按屏幕刷新率筛选出的帧率档位(供帧率下拉框使用)
|
||||
supportedFps: Array.isArray(msg.supportedFps) ? msg.supportedFps.map((f) => f | 0) : [],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,3 +14,10 @@ logging:
|
||||
level:
|
||||
com.ttstd.signaling: 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
|
||||
@@ -60,6 +60,10 @@ class RemoteController {
|
||||
/// 被控端/解码器上报分辨率。
|
||||
void Function(int width, int height)? onResolutionReported;
|
||||
|
||||
/// 被控端上报当前采集帧率与支持的帧率档位。
|
||||
void Function(int width, int height, int fps, List<int> supportedFps)?
|
||||
onFpsReport;
|
||||
|
||||
/// 当前平台不支持原生硬解时回调。
|
||||
void Function()? onSelfCodecNotSupported;
|
||||
|
||||
@@ -116,6 +120,8 @@ class RemoteController {
|
||||
_webRtc!.onSelfCodecLost = () => onSelfCodecLost?.call();
|
||||
_webRtc!.onStreamModeReport = (mode) => onStreamModeReport?.call(mode);
|
||||
_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!.initialize().catchError((e) {
|
||||
onStatusChanged?.call('状态: 连接失败 - $e');
|
||||
|
||||
@@ -82,6 +82,16 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
{'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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -354,6 +364,16 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_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 = () {
|
||||
if (mounted) setState(() => _selfCodecSupported = false);
|
||||
_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 解码)。
|
||||
void _toggleStreamMode() {
|
||||
if (!_selfCodecSupported) {
|
||||
@@ -534,6 +595,10 @@ class _ControllerHomeState extends State<ControllerHome> {
|
||||
_recording = false;
|
||||
_recordStatus = '';
|
||||
_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 等支持原生硬解的平台可用。
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
|
||||
@@ -40,6 +40,7 @@ class ControlMessage extends $pb.GeneratedMessage {
|
||||
$core.int? height,
|
||||
$core.int? fps,
|
||||
$core.int? streamMode,
|
||||
$core.Iterable<$core.int>? supportedFps,
|
||||
}) {
|
||||
final result = create();
|
||||
if (action != null) result.action = action;
|
||||
@@ -57,6 +58,7 @@ class ControlMessage extends $pb.GeneratedMessage {
|
||||
if (height != null) result.height = height;
|
||||
if (fps != null) result.fps = fps;
|
||||
if (streamMode != null) result.streamMode = streamMode;
|
||||
if (supportedFps != null) result.supportedFps.addAll(supportedFps);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ class ControlMessage extends $pb.GeneratedMessage {
|
||||
..aI(13, _omitFieldNames ? '' : 'height')
|
||||
..aI(14, _omitFieldNames ? '' : 'fps')
|
||||
..aI(15, _omitFieldNames ? '' : 'stream_mode')
|
||||
..p<$core.int>(16, _omitFieldNames ? '' : 'supportedFps', $pb.PbFieldType.K3)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$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);
|
||||
@$pb.TagNumber(15)
|
||||
void clearStreamMode() => $_clearField(15);
|
||||
|
||||
/// 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
|
||||
@$pb.TagNumber(16)
|
||||
$pb.PbList<$core.int> get supportedFps => $_getList(15);
|
||||
}
|
||||
|
||||
const $core.bool _omitFieldNames =
|
||||
|
||||
@@ -77,6 +77,10 @@ class WebRtcController {
|
||||
/// 被控端上报分辨率(自编码解码器输出尺寸变化时也会触发)。
|
||||
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 等)不支持原生硬解时回调。
|
||||
void Function()? onSelfCodecNotSupported;
|
||||
|
||||
@@ -370,6 +374,9 @@ class WebRtcController {
|
||||
_selfCodecHeight = msg.height;
|
||||
}
|
||||
onResolutionReported?.call(msg.width, msg.height);
|
||||
// 同步帧率信息(当前帧率 + 被控端按刷新率筛选的帧率档位)
|
||||
onFpsReport?.call(
|
||||
msg.width, msg.height, msg.fps, msg.supportedFps.toList());
|
||||
break;
|
||||
default:
|
||||
// ignore: avoid_print
|
||||
@@ -794,6 +801,7 @@ class WebRtcController {
|
||||
onSelfCodecReady = null;
|
||||
onStreamModeReport = null;
|
||||
onResolutionReported = null;
|
||||
onFpsReport = null;
|
||||
onSelfCodecNotSupported = null;
|
||||
_pc = null;
|
||||
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
|
||||
|
||||
@@ -54,4 +54,7 @@ message ControlMessage {
|
||||
// 串流模式(SET_STREAM_MODE / REPORT_STREAM_MODE):
|
||||
// 0 = WebRTC 全托管;1 = 自编码(自建 MediaCodec 编解码 + video DataChannel 裸流透传)
|
||||
int32 stream_mode = 15;
|
||||
|
||||
// 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
|
||||
repeated int32 supported_fps = 16;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ struct ControlMessage {
|
||||
var height: Int32 = 0 // field 13, varint
|
||||
var fps: Int32 = 0 // field 14, varint
|
||||
var streamMode: Int32 = 0 // field 15, varint (0=WebRTC 1=自编码)
|
||||
/// 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。
|
||||
var supportedFps: [Int32] = [] // field 16, repeated varint (packed)
|
||||
|
||||
// MARK: - Encode
|
||||
|
||||
@@ -54,6 +56,16 @@ struct ControlMessage {
|
||||
w.writeVarintField(13, UInt64(bitPattern: Int64(height)))
|
||||
w.writeVarintField(14, UInt64(bitPattern: Int64(fps)))
|
||||
w.writeVarintField(15, UInt64(bitPattern: Int64(streamMode)))
|
||||
// repeated int32 supported_fps = 16(packed 编码);空列表不编码
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 (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 (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:
|
||||
// 未知字段按 wire type 跳过
|
||||
if !r.skip(wireType: wire) { return nil }
|
||||
|
||||
@@ -56,6 +56,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
@Published var statsText: String = ""
|
||||
@Published var streamMode: StreamMode = .webrtc
|
||||
@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 showAuthSheet: Bool = false
|
||||
@@ -82,6 +86,9 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
private var pendingAuthValue: String = ""
|
||||
/// 是否为本端主动切换串流模式(避免上报回环再次发送)
|
||||
private var suppressStreamModeSend = false
|
||||
/// 被控端最近上报的实际采集尺寸(仅切帧率时保持分辨率不变,0 表示原生)
|
||||
private var lastReportedWidth: Int32 = 0
|
||||
private var lastReportedHeight: Int32 = 0
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -138,6 +145,10 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
statsText = ""
|
||||
streamMode = .webrtc
|
||||
selectedResolution = ResolutionOption.all[0]
|
||||
fpsOptions = [15, 24, 30, 60]
|
||||
currentFps = 0
|
||||
lastReportedWidth = 0
|
||||
lastReportedHeight = 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)
|
||||
}
|
||||
|
||||
/// 仅切换帧率:分辨率沿用被控端最近上报的实际采集尺寸(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) {
|
||||
let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc
|
||||
guard newMode != streamMode else { return }
|
||||
@@ -441,10 +460,17 @@ extension ControllerViewModel: WebRTCClientDelegate {
|
||||
|
||||
func webRTCClient(didReportResolution width: Int, height: Int) {
|
||||
guard width > 0, height > 0 else { return }
|
||||
lastReportedWidth = Int32(width)
|
||||
lastReportedHeight = Int32(height)
|
||||
if streamMode == .webrtc {
|
||||
videoAspect = CGFloat(width) / CGFloat(height)
|
||||
}
|
||||
}
|
||||
|
||||
func webRTCClient(didReportFps fps: Int, supportedFps: [Int]) {
|
||||
if fps > 0 { currentFps = fps }
|
||||
if !supportedFps.isEmpty { fpsOptions = supportedFps }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RTCVideoViewDelegate(WebRTC 模式下画面尺寸变化)
|
||||
|
||||
@@ -147,6 +147,25 @@ private struct ControlPanelView: View {
|
||||
.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) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
|
||||
@@ -12,6 +12,8 @@ protocol WebRTCClientDelegate: AnyObject {
|
||||
func webRTCClient(didReportStreamMode mode: Int)
|
||||
/// 被控端上报当前采集分辨率
|
||||
func webRTCClient(didReportResolution width: Int, height: Int)
|
||||
/// 被控端上报当前采集帧率与支持的帧率档位(按屏幕刷新率筛选,升序)
|
||||
func webRTCClient(didReportFps fps: Int, supportedFps: [Int])
|
||||
}
|
||||
|
||||
/// WebRTC 客户端:负责 PeerConnection 的创建、Offer/Answer 协商、
|
||||
@@ -313,6 +315,8 @@ extension WebRTCClient: RTCDataChannelDelegate {
|
||||
self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode))
|
||||
case .reportResolution:
|
||||
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:
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user