feat: 增加信令自动重连保活与控制端视频录制
- 信令客户端增加心跳保活与指数退避自动重连机制 - 服务端优化传输异常日志级别,客户端断开不再报错 - 被控端信令断开时保持服务运行并自动重连 - Flutter 控制端增加远程视频录制功能,兼容标准 WebRTC 模式切换 - 各端连接按钮增加“连接中”状态防重复点击
This commit is contained in:
95
webrtc_controller_flutter/lib/webrtc/video_recorder.dart
Normal file
95
webrtc_controller_flutter/lib/webrtc/video_recorder.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user