Files
ttstd_family_care/lib/features/connection/presentation/connection_controller.dart
TongTongStudio bfb106f1fb feat(connection): 增加远程视频录制、分辨率/帧率切换及高刷适配
- 新增 VideoRecorder 基于 flutter_webrtc MediaRecorder 录制远程视频为 MP4
- 新增 ControlMessageDecoder 解析被控端上报的 REPORT_* protobuf 消息
- 连接控制器支持分辨率/帧率切换、录制状态管理与远程上报处理
- 远程控制页新增更多菜单(静音/分辨率/帧率/录制),并统一退出时断开连接
- WebRTC 编排器在 DataChannel 打开及媒体就绪后主动请求默认分辨率,解决黑屏问题
- 新增 DisplayModeUtil 在 Android 上启用最高刷新率
- 登出时失效设备相关 Provider,避免切换账号后读到旧缓存
2026-08-27 11:39:42 +08:00

293 lines
9.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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跟手用。action0=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);
}
}