feat: 增加信令自动重连保活与控制端视频录制

- 信令客户端增加心跳保活与指数退避自动重连机制
- 服务端优化传输异常日志级别,客户端断开不再报错
- 被控端信令断开时保持服务运行并自动重连
- Flutter 控制端增加远程视频录制功能,兼容标准 WebRTC 模式切换
- 各端连接按钮增加“连接中”状态防重复点击
This commit is contained in:
TongTongStudio
2026-07-30 00:40:22 +08:00
parent 7f545359d9
commit e831ef2795
14 changed files with 444 additions and 27 deletions

View File

@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:path_provider/path_provider.dart';
/// 远程视频录制器:基于 flutter_webrtc 的 [MediaRecorder]
/// 把远端视频轨道MediaStreamTrack直接封装为 MP4 保存到本地。
///
/// 跨平台Android / iOS由 flutter_webrtc 内部完成 H.264 编码与封装,
/// 无需自行触碰原生 VideoSink。
///
/// 录制文件保存位置:
/// - Android/Android/data/<包名>/files/WebRTCRecordings/app 专属外部存储,无需存储权限)
/// - iOS<App>/Documents/WebRTCRecordings/
class VideoRecorder {
MediaRecorder? _recorder;
bool _recording = false;
String? _currentPath;
/// 是否正在录制。
bool get isRecording => _recording;
/// 是否为空(从未真正开始)。
String? get currentPath => _currentPath;
/// 开始录制指定媒体流中的视频轨道。
///
/// 返回 true 表示成功开始;空流 / 无视频轨道会抛异常。
Future<bool> start(MediaStream stream) async {
if (_recording) return false;
final videoTracks = stream.getVideoTracks();
if (videoTracks.isEmpty) {
throw Exception('当前没有可用的远端视频轨道,无法录制');
}
final path = await _buildOutputPath();
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
await _recorder!.start(path, videoTrack: videoTracks.first);
_currentPath = path;
_recording = true;
return true;
}
/// 停止录制,返回最终保存的文件路径;未开始录制返回 null。
Future<String?> stop() async {
if (!_recording || _recorder == null) return null;
try {
await _recorder!.stop();
} catch (e) {
// 停止失败时仍清理状态,避免界面卡在“录制中”
// ignore: avoid_print
print('[VideoRecorder] stop error: $e');
} finally {
_recorder = null;
_recording = false;
}
final p = _currentPath;
_currentPath = null;
return p;
}
/// 释放资源(断开连接 / 页面销毁时调用)。
Future<void> dispose() async {
if (_recording && _recorder != null) {
try {
await _recorder!.stop();
} catch (_) {
// 忽略
}
}
_recorder = null;
_recording = false;
_currentPath = null;
}
static String _pad(int n) => n.toString().padLeft(2, '0');
static Future<Directory> _getRecordingDir() async {
final base = Platform.isAndroid
? (await getExternalStorageDirectory())!
: await getApplicationDocumentsDirectory();
final dir = Directory('${base.path}/WebRTCRecordings');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static Future<String> _buildOutputPath() async {
final dir = await _getRecordingDir();
final now = DateTime.now();
final name = 'rec_${now.year}${_pad(now.month)}${_pad(now.day)}_'
'${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}.mp4';
return '${dir.path}/$name';
}
}