- 新增 VideoRecorder 基于 flutter_webrtc MediaRecorder 录制远程视频为 MP4 - 新增 ControlMessageDecoder 解析被控端上报的 REPORT_* protobuf 消息 - 连接控制器支持分辨率/帧率切换、录制状态管理与远程上报处理 - 远程控制页新增更多菜单(静音/分辨率/帧率/录制),并统一退出时断开连接 - WebRTC 编排器在 DataChannel 打开及媒体就绪后主动请求默认分辨率,解决黑屏问题 - 新增 DisplayModeUtil 在 Android 上启用最高刷新率 - 登出时失效设备相关 Provider,避免切换账号后读到旧缓存
293 lines
9.6 KiB
Dart
293 lines
9.6 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||
|
||
import '../data/connection_providers.dart';
|
||
import '../data/protobuf_codec.dart';
|
||
import '../data/video_recorder.dart';
|
||
import '../data/webrtc_orchestrator.dart';
|
||
import '../domain/connection_models.dart';
|
||
|
||
/// 分辨率预设(与被控端 SET_RESOLUTION 语义一致:width<=0 表示原始)。
|
||
const List<Map<String, Object>> kResolutionOptions = [
|
||
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
|
||
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
|
||
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
|
||
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
|
||
];
|
||
|
||
/// 默认帧率档位(收到被控端上报的 supported_fps 后以上报列表为准)。
|
||
const List<int> kDefaultFpsOptions = [15, 24, 30, 60];
|
||
|
||
/// 远程控制会话状态(供 UI 渲染)。
|
||
class ConnectionSessionState {
|
||
final ConnectionState state;
|
||
final String? errorMessage;
|
||
final bool hasVideo;
|
||
|
||
/// 是否正在录制远程视频。
|
||
final bool recording;
|
||
/// 录制状态提示(已保存路径等)。
|
||
final String recordStatus;
|
||
|
||
/// 当前选中的分辨率预设下标。
|
||
final int selectedResolution;
|
||
/// 分辨率预设列表。
|
||
final List<Map<String, Object>> resolutionOptions;
|
||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||
final List<int> fpsOptions;
|
||
/// 被控端当前采集帧率(0 表示尚未收到上报)。
|
||
final int currentFps;
|
||
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
|
||
final int lastReportedWidth;
|
||
final int lastReportedHeight;
|
||
|
||
const ConnectionSessionState({
|
||
this.state = ConnectionState.idle,
|
||
this.errorMessage,
|
||
this.hasVideo = false,
|
||
this.recording = false,
|
||
this.recordStatus = '',
|
||
this.selectedResolution = 1,
|
||
this.resolutionOptions = kResolutionOptions,
|
||
this.fpsOptions = kDefaultFpsOptions,
|
||
this.currentFps = 0,
|
||
this.lastReportedWidth = 0,
|
||
this.lastReportedHeight = 0,
|
||
});
|
||
|
||
ConnectionSessionState copyWith({
|
||
ConnectionState? state,
|
||
String? errorMessage,
|
||
bool? hasVideo,
|
||
bool? recording,
|
||
String? recordStatus,
|
||
int? selectedResolution,
|
||
List<Map<String, Object>>? resolutionOptions,
|
||
List<int>? fpsOptions,
|
||
int? currentFps,
|
||
int? lastReportedWidth,
|
||
int? lastReportedHeight,
|
||
}) {
|
||
return ConnectionSessionState(
|
||
state: state ?? this.state,
|
||
errorMessage: errorMessage ?? this.errorMessage,
|
||
hasVideo: hasVideo ?? this.hasVideo,
|
||
recording: recording ?? this.recording,
|
||
recordStatus: recordStatus ?? this.recordStatus,
|
||
selectedResolution: selectedResolution ?? this.selectedResolution,
|
||
resolutionOptions: resolutionOptions ?? this.resolutionOptions,
|
||
fpsOptions: fpsOptions ?? this.fpsOptions,
|
||
currentFps: currentFps ?? this.currentFps,
|
||
lastReportedWidth: lastReportedWidth ?? this.lastReportedWidth,
|
||
lastReportedHeight: lastReportedHeight ?? this.lastReportedHeight,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 远程控制控制器:管理连接状态机(idle → connecting → connected → ended)。
|
||
class ConnectionController extends Notifier<ConnectionSessionState> {
|
||
WebRtcOrchestrator? _orchestrator;
|
||
final VideoRecorder _videoRecorder = VideoRecorder();
|
||
|
||
@override
|
||
ConnectionSessionState build() {
|
||
return const ConnectionSessionState();
|
||
}
|
||
|
||
/// 目标设备 SN(页面初始化时设置)。
|
||
String? _targetSn;
|
||
set targetSn(String? value) => _targetSn = value;
|
||
|
||
/// 远程屏幕渲染器(供页面 RTCVideoView 渲染)。
|
||
RTCVideoRenderer? get remoteRenderer => _orchestrator?.remoteRenderer;
|
||
|
||
/// 发起连接。
|
||
Future<void> connect() async {
|
||
final sn = _targetSn;
|
||
if (sn == null || sn.isEmpty) {
|
||
state = const ConnectionSessionState(
|
||
state: ConnectionState.error,
|
||
errorMessage: '缺少目标设备',
|
||
);
|
||
return;
|
||
}
|
||
|
||
final signaling = ref.read(signalingClientProvider);
|
||
final clientName = ref.read(clientNameProvider);
|
||
|
||
state = const ConnectionSessionState(state: ConnectionState.connecting);
|
||
|
||
_orchestrator = WebRtcOrchestrator(
|
||
signaling: signaling,
|
||
targetSn: sn,
|
||
clientName: clientName,
|
||
);
|
||
_orchestrator!.onConnectionState = (connected) {
|
||
_onRtcState(connected);
|
||
};
|
||
_orchestrator!.onReport = (report) {
|
||
_onReport(report);
|
||
};
|
||
|
||
signaling.onMessage = (message) {
|
||
// 先透传给 WebRTC 编排器处理(ANSWER/ICE 等),确保握手能继续
|
||
_orchestrator?.handleSignal(message);
|
||
// 仅"明确被拒"才进入错误态;TARGET_OFFLINE 可能因握手时序/目标会话短暂未就绪
|
||
// 而误报(如设备端刚连上信令、正在应答),不立即判定失败,避免闪断"设备不在线"。
|
||
if (message.type == SignalType.connectionRejected) {
|
||
state = ConnectionSessionState(
|
||
state: ConnectionState.error,
|
||
errorMessage: '连接被拒绝',
|
||
);
|
||
}
|
||
};
|
||
signaling.onError = (error) {
|
||
state = ConnectionSessionState(
|
||
state: ConnectionState.error,
|
||
errorMessage: error,
|
||
);
|
||
};
|
||
|
||
try {
|
||
await signaling.connect(signalServerUrl, targetSn: sn);
|
||
await _orchestrator!.start();
|
||
} catch (e) {
|
||
state = ConnectionSessionState(
|
||
state: ConnectionState.error,
|
||
errorMessage: '连接失败: $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
void _onRtcState(bool connected) {
|
||
if (connected) {
|
||
state = state.copyWith(
|
||
state: ConnectionState.connected,
|
||
hasVideo: true,
|
||
);
|
||
// 连接成功后按默认分辨率(1080P)下发一次,确保默认生效。
|
||
selectResolution(state.selectedResolution);
|
||
} else {
|
||
state = state.copyWith(
|
||
state: ConnectionState.ended,
|
||
hasVideo: false,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 处理被控端经 DataChannel 上报的 REPORT_RESOLUTION / REPORT_STREAM_MODE。
|
||
void _onReport(ReportInfo report) {
|
||
if (report.width > 0 && report.height > 0) {
|
||
state = state.copyWith(
|
||
lastReportedWidth: report.width,
|
||
lastReportedHeight: report.height,
|
||
);
|
||
}
|
||
if (report.fps > 0) {
|
||
state = state.copyWith(currentFps: report.fps);
|
||
}
|
||
if (report.supportedFps.isNotEmpty) {
|
||
state = state.copyWith(fpsOptions: report.supportedFps);
|
||
}
|
||
}
|
||
|
||
/// 发送点击(相对坐标 0~1)。
|
||
void sendTap(double relX, double relY) {
|
||
debugPrint('[touch] TAP rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
|
||
_orchestrator?.sendTap(relX, relY);
|
||
}
|
||
|
||
void sendLongPress(double relX, double relY) {
|
||
debugPrint('[touch] LONG_PRESS rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
|
||
_orchestrator?.sendLongPress(relX, relY);
|
||
}
|
||
|
||
void sendSwipe(double relX1, double relY1, double relX2, double relY2) {
|
||
debugPrint('[touch] SWIPE rel=(${relX1.toStringAsFixed(3)}, ${relY1.toStringAsFixed(3)}) -> (${relX2.toStringAsFixed(3)}, ${relY2.toStringAsFixed(3)})');
|
||
_orchestrator?.sendSwipe(relX1, relY1, relX2, relY2);
|
||
}
|
||
|
||
/// 发送实时触摸事件(MOTION_EVENT,跟手用)。action:0=DOWN,1=UP,2=MOVE。
|
||
void sendMotionEvent(int action, double relX, double relY) {
|
||
debugPrint('[touch] MOTION action=$action rel=(${relX.toStringAsFixed(3)}, ${relY.toStringAsFixed(3)})');
|
||
_orchestrator?.sendMotionEvent(action, relX, relY);
|
||
}
|
||
|
||
void sendKey(int keyCode) {
|
||
_orchestrator?.sendKey(keyCode);
|
||
}
|
||
|
||
void sendSetResolution({int width = 0, int height = 0, int fps = 0}) {
|
||
_orchestrator?.sendSetResolution(width: width, height: height, fps: fps);
|
||
}
|
||
|
||
/// 切换分辨率:写入状态并下发指令。
|
||
void selectResolution(int index) {
|
||
final options = state.resolutionOptions;
|
||
if (index < 0 || index >= options.length) return;
|
||
state = state.copyWith(selectedResolution: index);
|
||
final o = options[index];
|
||
_orchestrator?.sendSetResolution(
|
||
width: o['width'] as int,
|
||
height: o['height'] as int,
|
||
fps: o['fps'] as int,
|
||
);
|
||
}
|
||
|
||
/// 切换帧率:仅切帧率,分辨率保持不变。
|
||
void selectFps(int fps) {
|
||
if (fps <= 0 || fps == state.currentFps) return;
|
||
_orchestrator?.sendSetResolution(
|
||
width: state.lastReportedWidth,
|
||
height: state.lastReportedHeight,
|
||
fps: fps,
|
||
);
|
||
}
|
||
|
||
/// 切换远程视频录制:开始 / 停止。
|
||
Future<void> toggleRecord() async {
|
||
if (state.recording) {
|
||
await _stopRecording();
|
||
return;
|
||
}
|
||
await _startRecording();
|
||
}
|
||
|
||
Future<void> _startRecording() async {
|
||
if (state.recording) return;
|
||
final renderer = _orchestrator?.remoteRenderer;
|
||
final stream = renderer?.srcObject;
|
||
if (stream == null) {
|
||
state = state.copyWith(recordStatus: '尚未接收到视频画面,无法录制');
|
||
return;
|
||
}
|
||
try {
|
||
final ok = await _videoRecorder.start(stream);
|
||
if (ok) {
|
||
state = state.copyWith(recording: true, recordStatus: '录制中...');
|
||
}
|
||
} catch (e) {
|
||
state = state.copyWith(recordStatus: '');
|
||
debugPrint('[record] 开始录制失败: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _stopRecording() async {
|
||
final path = await _videoRecorder.stop();
|
||
state = state.copyWith(
|
||
recording: false,
|
||
recordStatus: path != null ? '已保存:$path' : '录制已停止',
|
||
);
|
||
}
|
||
|
||
/// 关闭并清理。
|
||
Future<void> disconnect() async {
|
||
await _videoRecorder.dispose();
|
||
await _orchestrator?.dispose();
|
||
_orchestrator = null;
|
||
ref.read(signalingClientProvider).dispose();
|
||
state = const ConnectionSessionState(state: ConnectionState.ended);
|
||
}
|
||
}
|