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>

View File

@@ -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 ? "远程控制中" : "未连接");
}

View File

@@ -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) {
}

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

@@ -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"

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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');

View File

@@ -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),

View File

@@ -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 =

View File

@@ -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;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。

View File

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

View File

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

View File

@@ -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: - RTCVideoViewDelegateWebRTC

View File

@@ -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: {

View File

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