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

@@ -2,6 +2,15 @@
<!-- 远程控制需要通过网络(含 ws:// 明文)连接信令服务器 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 录制文件保存位置Android Q 及以上写入 app 专属目录免授权;
低版本如需兼容可保留以下声明,运行时并不强制索取) -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application
android:label="webrtc_controller_flutter"

View File

@@ -89,6 +89,7 @@ class RemoteController {
};
_signaling.onError = (error) {
onStatusChanged?.call('状态: 连接错误 - $error');
onConnectionFailed?.call(error);
};
_signaling.connect();
}

View File

@@ -9,6 +9,7 @@ import 'controller/remote_controller.dart';
import 'utils/control_commands.dart';
import 'utils/device_utils.dart';
import 'webrtc/self_codec_decoder.dart';
import 'webrtc/video_recorder.dart';
import 'widgets/remote_touch_view.dart';
void main() {
@@ -52,6 +53,8 @@ class _ControllerHomeState extends State<ControllerHome> {
RTCVideoRenderer? _renderer;
bool _connected = false;
/// 是否正在连接信令服务器 / 建立 WebRTC连接过程中禁用“连接”按钮
bool _connecting = false;
String _status = '状态: 已停止';
String _stats = '';
double _videoAspect = 16 / 9;
@@ -62,6 +65,13 @@ class _ControllerHomeState extends State<ControllerHome> {
bool _selfCodecReady = false;
bool _selfCodecSupported = true;
/// 远程视频录制器(基于 flutter_webrtc 的 MediaRecorder
final VideoRecorder _videoRecorder = VideoRecorder();
bool _recording = false;
String _recordStatus = '';
/// 自编码模式下请求录制时,先切回 WebRTC 标准模式、待就绪后再开始录制。
bool _pendingRecordStart = false;
/// 分辨率预设width 为长边像素0 表示被控端原生分辨率;
/// height 传 0由被控端按屏幕宽高比计算fps 传 0沿用当前帧率
int _selectedResolution = 0;
@@ -88,6 +98,7 @@ class _ControllerHomeState extends State<ControllerHome> {
@override
void dispose() {
_videoRecorder.dispose();
_controller?.disconnect();
_serverUrlController.dispose();
_deviceIdController.dispose();
@@ -266,6 +277,7 @@ class _ControllerHomeState extends State<ControllerHome> {
return;
}
setState(() => _connecting = true);
_setStatus('状态: 正在连接信令服务器...');
_controller = RemoteController(
@@ -279,11 +291,19 @@ class _ControllerHomeState extends State<ControllerHome> {
_controller!.onConnectionEstablished = () {
setState(() {
_connected = true;
_connecting = false;
_status = '状态: 已连接 - 远程控制中';
});
};
_controller!.onConnectionFailed = (error) {
if (mounted) setState(() => _connecting = false);
_showAlert('连接失败:$error');
};
_controller!.onDisconnected = () {
setState(() => _connected = false);
setState(() {
_connected = false;
_connecting = false;
});
_setStatus('状态: 远端已断开');
};
_controller!.onIceDisconnected = (message) {
@@ -298,6 +318,10 @@ class _ControllerHomeState extends State<ControllerHome> {
_controller!.onRemoteStream = (renderer) {
setState(() => _renderer = renderer);
renderer.addListener(_onRendererUpdate);
if (_pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onStats = (stats) => setState(() => _stats = stats);
_controller!.onSelfCodecReady = (textureId) {
@@ -318,6 +342,10 @@ class _ControllerHomeState extends State<ControllerHome> {
};
_controller!.onStreamModeReport = (mode) {
if (mounted) setState(() => _streamMode = mode);
if (mode == SelfCodecDecoder.streamModeWebRtc && _pendingRecordStart) {
_pendingRecordStart = false;
_startRecording();
}
};
_controller!.onResolutionReported = (w, h) {
if (w > 0 && h > 0 && mounted) {
@@ -363,13 +391,19 @@ class _ControllerHomeState extends State<ControllerHome> {
/// 目标被控端不在线:提示用户并复位到连接设置面板。
Future<void> _onTargetOffline(String message) async {
_showAlert(message);
setState(() => _connected = false);
setState(() {
_connected = false;
_connecting = false;
});
}
/// 被控端拒绝连接请求:提示用户并复位到连接设置面板。
Future<void> _onConnectionRejected(String message) async {
_showAlert(message);
setState(() => _connected = false);
setState(() {
_connected = false;
_connecting = false;
});
}
/// 弹出分辨率选择菜单iOS 风格 ActionSheet
@@ -426,13 +460,70 @@ class _ControllerHomeState extends State<ControllerHome> {
_controller?.sendStreamMode(next);
}
/// 切换远程视频录制:开始 / 停止。
Future<void> _toggleRecord() async {
if (_recording) {
await _stopRecording();
return;
}
if (_renderer == null) {
_showAlert('尚未连接或没有视频画面,无法录制');
return;
}
if (_streamMode == SelfCodecDecoder.streamModeSelfCodec) {
// 自编码模式下没有 WebRTC 视频轨道,先切回标准模式再开始录制。
_pendingRecordStart = true;
setState(() => _recordStatus = '正在切回标准模式以开始录制...');
_controller?.sendStreamMode(SelfCodecDecoder.streamModeWebRtc);
return;
}
await _startRecording();
}
/// 开始录制当前远端视频轨道。
Future<void> _startRecording() async {
if (_recording || !mounted) return;
final stream = _renderer?.srcObject;
if (stream == null) {
_showAlert('尚未接收到视频画面,无法录制');
return;
}
try {
final ok = await _videoRecorder.start(stream);
if (ok && mounted) {
setState(() {
_recording = true;
_recordStatus = '录制中...';
});
}
} catch (e) {
if (mounted) {
setState(() => _recordStatus = '');
_showAlert('开始录制失败:$e');
}
}
}
/// 停止录制并提示保存位置。
Future<void> _stopRecording() async {
final path = await _videoRecorder.stop();
if (mounted) {
setState(() {
_recording = false;
_recordStatus = path != null ? '已保存:$path' : '录制已停止';
});
}
}
Future<void> _disconnect() async {
await _videoRecorder.dispose();
await _controller?.disconnect();
_controller = null;
_renderer?.removeListener(_onRendererUpdate);
_lastResizedAspect = 0;
setState(() {
_connected = false;
_connecting = false;
_renderer = null;
_status = '状态: 已停止';
_stats = '';
@@ -440,6 +531,9 @@ class _ControllerHomeState extends State<ControllerHome> {
_selfCodecTextureId = null;
_selfCodecReady = false;
_selfCodecSupported = true;
_recording = false;
_recordStatus = '';
_pendingRecordStart = false;
});
}
@@ -501,8 +595,8 @@ class _ControllerHomeState extends State<ControllerHome> {
SizedBox(
width: double.infinity,
child: CupertinoButton.filled(
onPressed: _showAuthDialog,
child: const Text('连接被控设备'),
onPressed: _connecting ? null : _showAuthDialog,
child: Text(_connecting ? '正在连接...' : '连接被控设备'),
),
),
],
@@ -569,6 +663,33 @@ class _ControllerHomeState extends State<ControllerHome> {
],
),
),
// 远程视频录制开关
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
onPressed: _toggleRecord,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_recording
? CupertinoIcons.stop_circle
: CupertinoIcons.video_camera,
color: _recording
? CupertinoColors.destructiveRed
: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 6),
Text(
_recording ? '停止' : '录制',
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 14,
),
),
],
),
),
// 分隔线
Container(
width: 1,
@@ -668,6 +789,14 @@ class _ControllerHomeState extends State<ControllerHome> {
_stats,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
if (_recordStatus.isNotEmpty)
Text(
_recordStatus,
style: const TextStyle(
color: CupertinoColors.systemOrange,
fontSize: 12,
),
),
],
),
),

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

View File

@@ -297,7 +297,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825

View File

@@ -38,6 +38,9 @@ dependencies:
# 跨平台 WebRTC同时支持 Android 与 iOS
flutter_webrtc: ^1.5.2
# 录制文件保存路径app 专属目录,免运行时存储权限)
path_provider: ^2.1.5
# WebSocket 信令通信
web_socket_channel: ^3.0.3